use crate::{error::Result, plugin::PluginInfo};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::ptr;
use std::time::Duration;
pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FactoryInfo {
pub vendor: String,
pub url: String,
pub email: String,
pub flags: i32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModuleFactoryFlags {
pub unicode: bool,
pub classes_discardable: bool,
pub license_check: bool,
pub component_non_discardable: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModuleFactoryInfo {
pub vendor: String,
pub url: String,
pub email: String,
pub flags: ModuleFactoryFlags,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ModuleClassInfo {
pub class_id: String,
pub category: String,
pub name: String,
pub vendor: String,
pub version: String,
pub sdk_version: String,
pub sub_categories: Vec<String>,
pub class_flags: i32,
pub cardinality: i32,
pub snapshots: Vec<PluginSnapshot>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PluginSnapshot {
pub class_id: String,
pub scale_factor: f64,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClassCompatibility {
pub new_class_id: String,
pub old_class_ids: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ModuleInfo {
pub source: PathBuf,
pub name: String,
pub version: String,
pub factory: ModuleFactoryInfo,
pub classes: Vec<ModuleClassInfo>,
pub compatibility: Vec<ClassCompatibility>,
}
impl ModuleInfo {
pub fn resolve_class_id(&self, requested_class_id: &str) -> Option<&str> {
if let Some(class) = self.classes.iter().find(|class| {
crate::internal::utils::class_uid_matches(&class.class_id, requested_class_id)
}) {
return Some(&class.class_id);
}
self.compatibility.iter().find_map(|mapping| {
mapping
.old_class_ids
.iter()
.any(|old| crate::internal::utils::class_uid_matches(old, requested_class_id))
.then_some(mapping.new_class_id.as_str())
})
}
pub fn replaced_class_ids(&self, current_class_id: &str) -> &[String] {
self.compatibility
.iter()
.find(|mapping| {
crate::internal::utils::class_uid_matches(&mapping.new_class_id, current_class_id)
})
.map_or(&[], |mapping| mapping.old_class_ids.as_slice())
}
}
pub fn read_module_info(path: &Path) -> Result<Option<ModuleInfo>> {
crate::internal::module_info::read(path)
}
pub fn get_plugin_compatibility(path: &Path) -> Result<Vec<ClassCompatibility>> {
if let Some(module_info) = read_module_info(path)? {
return Ok(module_info.compatibility);
}
use vst3::{ComPtr, Steinberg::Vst::IHostApplication, Steinberg::*};
unsafe {
let host_app = crate::internal::com_implementations::create_host_application();
let host_ctx = host_app.to_com_ptr::<IHostApplication>();
let context = host_ctx
.as_ref()
.map(|pointer| pointer.as_ptr() as *mut FUnknown)
.unwrap_or(ptr::null_mut());
let module = crate::internal::module_loader::load_module(path)?;
let factory_ptr = module.get_factory()?;
let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
})?;
if let Some(factory3) = factory.cast::<IPluginFactory3>() {
let result = factory3.setHostContext(context);
if result != kResultOk && result != kResultTrue {
log::warn!(
"IPluginFactory3::setHostContext failed during compatibility discovery: \
{result:#x}"
);
}
}
crate::internal::module_info::read_factory_compatibility(&factory)
}
}
pub fn discover_plugin_snapshots(
path: &Path,
current_class_id: &str,
) -> Result<Vec<PluginSnapshot>> {
crate::internal::module_info::discover_snapshots(path, current_class_id)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClassInfo {
pub name: String,
pub category: String,
pub class_id: String,
pub cardinality: i32,
pub version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BusInfo {
pub name: String,
pub bus_type: i32,
pub flags: i32,
pub channel_count: i32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BusLayout {
pub audio_inputs: Vec<BusInfo>,
pub audio_outputs: Vec<BusInfo>,
pub event_inputs: Vec<BusInfo>,
pub event_outputs: Vec<BusInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedPluginInfo {
pub info: PluginInfo,
pub factory: FactoryInfo,
pub classes: Vec<ClassInfo>,
pub buses: BusLayout,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module_info: Option<ModuleInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compatibility: Vec<ClassCompatibility>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginReport {
pub detailed: DetailedPluginInfo,
pub parameters: Vec<crate::parameters::Parameter>,
}
impl PluginReport {
pub fn new(
detailed: DetailedPluginInfo,
parameters: Vec<crate::parameters::Parameter>,
) -> Self {
Self {
detailed,
parameters,
}
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
}
pub fn scan_standard_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(target_os = "macos")]
{
paths.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
if let Ok(home) = std::env::var("HOME") {
paths.push(PathBuf::from(format!(
"{}/Library/Audio/Plug-Ins/VST3",
home
)));
}
}
#[cfg(target_os = "windows")]
{
paths.push(PathBuf::from(r"C:\Program Files\Common Files\VST3"));
paths.push(PathBuf::from(r"C:\Program Files (x86)\Common Files\VST3"));
}
#[cfg(target_os = "linux")]
{
paths.push(PathBuf::from("/usr/lib/vst3"));
paths.push(PathBuf::from("/usr/local/lib/vst3"));
if let Ok(home) = std::env::var("HOME") {
paths.push(PathBuf::from(format!("{}/.vst3", home)));
}
}
paths
}
pub fn scan_directories(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut plugins = Vec::new();
let mut visited = std::collections::HashSet::new();
for path in paths {
if path.exists() {
scan_directory(path, &mut plugins, &mut visited)?;
}
}
plugins.sort();
plugins.dedup();
Ok(plugins)
}
fn scan_directory(
dir: &Path,
plugins: &mut Vec<PathBuf>,
visited: &mut std::collections::HashSet<PathBuf>,
) -> Result<()> {
match dir.canonicalize() {
Ok(real) => {
if !visited.insert(real) {
return Ok(());
}
}
Err(_) => return Ok(()),
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "vst3" {
plugins.push(path.clone());
}
}
if path.is_dir() && path.extension() != Some(std::ffi::OsStr::new("vst3")) {
scan_directory(&path, plugins, visited)?;
}
}
}
Ok(())
}
pub fn get_plugin_info(path: &Path) -> Result<PluginInfo> {
use vst3::Steinberg::Vst::BusDirections_::*;
use vst3::Steinberg::Vst::MediaTypes_::*;
use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
unsafe {
let host_app = crate::internal::com_implementations::create_host_application();
let host_ctx = host_app.to_com_ptr::<IHostApplication>();
let context = host_ctx
.as_ref()
.map(|p| p.as_ptr() as *mut FUnknown)
.unwrap_or(ptr::null_mut());
let module = crate::internal::module_loader::load_module(path)?;
let factory_ptr = module.get_factory()?;
let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
})?;
if let Some(factory3) = factory.cast::<IPluginFactory3>() {
let result = factory3.setHostContext(context);
if result != kResultOk && result != kResultTrue {
log::warn!("IPluginFactory3::setHostContext failed during discovery: {result:#x}");
}
}
let mut factory_info: PFactoryInfo = std::mem::zeroed();
factory.getFactoryInfo(&mut factory_info);
let vendor = crate::internal::utils::c_str_to_string(&factory_info.vendor);
let num_classes = factory.countClasses();
let mut plugin_name = String::new();
let mut category = String::new();
let mut version = String::new();
let mut uid = String::new();
let mut has_midi_input = false;
let mut has_midi_output = false;
let mut audio_inputs = 0u32;
let mut audio_outputs = 0u32;
let mut has_gui = false;
for i in 0..num_classes {
let mut class_info: PClassInfo = std::mem::zeroed();
if factory.getClassInfo(i, &mut class_info) == kResultOk {
let class_category = crate::internal::utils::c_str_to_string(&class_info.category);
if class_category.contains("Audio Module Class") {
plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
if let Some(f2) = factory.cast::<IPluginFactory2>() {
let mut info2: PClassInfo2 = std::mem::zeroed();
if f2.getClassInfo2(i, &mut info2) == kResultOk {
version = crate::internal::utils::c_str_to_string(&info2.version);
category =
crate::internal::utils::c_str_to_string(&info2.subCategories);
}
}
if let Some(f3) = factory.cast::<IPluginFactory3>() {
let mut info3: PClassInfoW = std::mem::zeroed();
if f3.getClassInfoUnicode(i, &mut info3) == kResultOk {
let utf16 = |value: &[u16]| {
let end =
value.iter().position(|&ch| ch == 0).unwrap_or(value.len());
String::from_utf16_lossy(&value[..end])
};
let unicode_name = utf16(&info3.name);
let unicode_version = utf16(&info3.version);
if !unicode_name.is_empty() {
plugin_name = unicode_name;
}
if !unicode_version.is_empty() {
version = unicode_version;
}
let unicode_category =
crate::internal::utils::c_str_to_string(&info3.subCategories);
if !unicode_category.is_empty() {
category = unicode_category;
}
}
}
uid = crate::internal::utils::format_class_uid(&class_info.cid);
let mut component_ptr: *mut IComponent = ptr::null_mut();
let result = factory.createInstance(
class_info.cid.as_ptr() as *const std::os::raw::c_char,
IComponent::IID.as_ptr() as *const std::os::raw::c_char,
&mut component_ptr as *mut _ as *mut _,
);
if result == kResultOk && !component_ptr.is_null() {
let component =
ComPtr::<IComponent>::from_raw(component_ptr).ok_or_else(|| {
crate::error::Error::Other("Failed to wrap component".to_string())
})?;
component.initialize(context);
audio_inputs = component.getBusCount(kAudio as i32, kInput as i32) as u32;
audio_outputs = component.getBusCount(kAudio as i32, kOutput as i32) as u32;
has_midi_input = component.getBusCount(kEvent as i32, kInput as i32) > 0;
has_midi_output = component.getBusCount(kEvent as i32, kOutput as i32) > 0;
has_gui = component.cast::<IEditController>().is_some() || {
let mut cid: [std::os::raw::c_char; 16] = [0; 16];
component.getControllerClassId(&mut cid) == kResultOk
};
component.terminate();
}
break;
}
}
}
if plugin_name.is_empty() && num_classes > 0 {
let mut class_info: PClassInfo = std::mem::zeroed();
if factory.getClassInfo(0, &mut class_info) == kResultOk {
plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
}
}
Ok(PluginInfo {
path: path.to_path_buf(),
name: if plugin_name.is_empty() {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
} else {
plugin_name
},
vendor,
version,
category,
uid,
audio_inputs,
audio_outputs,
has_midi_input,
has_midi_output,
has_gui,
})
}
}
pub fn get_detailed_plugin_info(path: &Path) -> Result<DetailedPluginInfo> {
use vst3::Steinberg::Vst::BusDirections_::*;
use vst3::Steinberg::Vst::BusInfo as VstBusInfo;
use vst3::Steinberg::Vst::MediaTypes_::*;
use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
let module_info = read_module_info(path)?;
let info = get_plugin_info(path)?;
unsafe {
let host_app = crate::internal::com_implementations::create_host_application();
let host_ctx = host_app.to_com_ptr::<IHostApplication>();
let context = host_ctx
.as_ref()
.map(|p| p.as_ptr() as *mut FUnknown)
.unwrap_or(ptr::null_mut());
let module = crate::internal::module_loader::load_module(path)?;
let factory_ptr = module.get_factory()?;
let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
})?;
if let Some(factory3) = factory.cast::<IPluginFactory3>() {
let result = factory3.setHostContext(context);
if result != kResultOk && result != kResultTrue {
log::warn!(
"IPluginFactory3::setHostContext failed during detailed discovery: \
{result:#x}"
);
}
}
let compatibility = match module_info.as_ref() {
Some(module_info) => module_info.compatibility.clone(),
None => crate::internal::module_info::read_factory_compatibility(&factory)?,
};
let mut fi: PFactoryInfo = std::mem::zeroed();
factory.getFactoryInfo(&mut fi);
let factory_info = FactoryInfo {
vendor: crate::internal::utils::c_str_to_string(&fi.vendor),
url: crate::internal::utils::c_str_to_string(&fi.url),
email: crate::internal::utils::c_str_to_string(&fi.email),
flags: fi.flags,
};
let num_classes = factory.countClasses();
let mut classes = Vec::new();
let mut audio_cid: Option<[std::os::raw::c_char; 16]> = None;
for i in 0..num_classes {
let mut ci: PClassInfo = std::mem::zeroed();
if factory.getClassInfo(i, &mut ci) == kResultOk {
let category = crate::internal::utils::c_str_to_string(&ci.category);
let class_id = crate::internal::utils::format_class_uid(&ci.cid);
if category.contains("Audio Module Class") && audio_cid.is_none() {
audio_cid = Some(ci.cid);
}
let mut name = crate::internal::utils::c_str_to_string(&ci.name);
let mut version = String::new();
if let Some(factory3) = factory.cast::<IPluginFactory3>() {
let mut info3: PClassInfoW = std::mem::zeroed();
if factory3.getClassInfoUnicode(i, &mut info3) == kResultOk {
let utf16 = |value: &[u16]| {
let end = value.iter().position(|&ch| ch == 0).unwrap_or(value.len());
String::from_utf16_lossy(&value[..end])
};
let unicode_name = utf16(&info3.name);
if !unicode_name.is_empty() {
name = unicode_name;
}
version = utf16(&info3.version);
}
} else if let Some(factory2) = factory.cast::<IPluginFactory2>() {
let mut info2: PClassInfo2 = std::mem::zeroed();
if factory2.getClassInfo2(i, &mut info2) == kResultOk {
version = crate::internal::utils::c_str_to_string(&info2.version);
}
}
classes.push(ClassInfo {
name,
category,
class_id,
cardinality: ci.cardinality,
version,
});
}
}
let mut buses = BusLayout::default();
if let Some(cid) = audio_cid {
let mut component_ptr: *mut IComponent = ptr::null_mut();
let result = factory.createInstance(
cid.as_ptr(),
IComponent::IID.as_ptr() as *const std::os::raw::c_char,
&mut component_ptr as *mut _ as *mut _,
);
if result == kResultOk && !component_ptr.is_null() {
if let Some(component) = ComPtr::<IComponent>::from_raw(component_ptr) {
component.initialize(context);
let collect = |media: i32, dir: i32| -> Vec<crate::discovery::BusInfo> {
let mut out = Vec::new();
let count = component.getBusCount(media, dir);
for i in 0..count {
let mut bi: VstBusInfo = std::mem::zeroed();
if component.getBusInfo(media, dir, i, &mut bi) == kResultOk {
out.push(crate::discovery::BusInfo {
name: crate::internal::utils::vst_string_to_string(&bi.name),
bus_type: bi.busType,
flags: bi.flags as i32,
channel_count: bi.channelCount,
});
}
}
out
};
buses.audio_inputs = collect(kAudio as i32, kInput as i32);
buses.audio_outputs = collect(kAudio as i32, kOutput as i32);
buses.event_inputs = collect(kEvent as i32, kInput as i32);
buses.event_outputs = collect(kEvent as i32, kOutput as i32);
component.terminate();
}
}
}
Ok(DetailedPluginInfo {
info,
factory: factory_info,
classes,
buses,
module_info,
compatibility,
})
}
}
#[derive(Debug, Clone)]
pub enum SafeDiscoverySkip {
Crashed {
path: PathBuf,
detail: String,
},
TimedOut {
path: PathBuf,
},
Failed {
path: PathBuf,
detail: String,
},
}
impl SafeDiscoverySkip {
pub fn path(&self) -> &Path {
match self {
SafeDiscoverySkip::Crashed { path, .. }
| SafeDiscoverySkip::TimedOut { path }
| SafeDiscoverySkip::Failed { path, .. } => path,
}
}
}
#[derive(Debug, Default)]
pub struct SafeDiscoveryReport {
pub plugins: Vec<DetailedPluginInfo>,
pub skipped: Vec<SafeDiscoverySkip>,
pub error: Option<String>,
}
impl SafeDiscoveryReport {
pub fn scan_ran(&self) -> bool {
self.error.is_none()
}
}
pub(crate) fn running_from_cargo_target(exe_dir: &Path) -> bool {
exe_dir.ancestors().any(|dir| {
matches!(
dir.file_name().and_then(|n| n.to_str()),
Some("debug") | Some("release")
) && dir
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
== Some("target")
})
}
fn find_probe_binary() -> std::result::Result<PathBuf, String> {
let probe_name = format!("vst3-host-probe{}", std::env::consts::EXE_SUFFIX);
if let Some(p) = std::env::var_os("VST3_HOST_PROBE_PATH").map(PathBuf::from) {
if p.exists() {
return Ok(p);
}
return Err(format!(
"VST3_HOST_PROBE_PATH does not exist: {}",
p.display()
));
}
let exe_path =
std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
let direct = exe_dir.join(&probe_name);
if direct.exists() {
return Ok(direct);
}
if exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
if let Some(parent) = exe_dir.parent() {
let p = parent.join(&probe_name);
if p.exists() {
return Ok(p);
}
}
}
if running_from_cargo_target(exe_dir) {
let mut current = exe_dir;
while let Some(parent) = current.parent() {
for profile in ["debug", "release"] {
let candidate = parent.join("target").join(profile).join(&probe_name);
if candidate.exists() {
return Ok(candidate);
}
}
if parent.join("Cargo.toml").exists() {
break;
}
current = parent;
}
}
Err(format!(
"Probe executable '{probe_name}' not found near {} or in target/{{debug,release}}. \
Build it with `cargo build --bin vst3-host-probe`, or set VST3_HOST_PROBE_PATH.",
exe_dir.display()
))
}
enum ProbeOutcome {
Ok(Box<DetailedPluginInfo>),
Crashed(String),
TimedOut,
Failed(String),
}
const PROBE_OUTPUT_GRACE: Duration = Duration::from_millis(250);
fn run_probe(probe: &Path, plugin: &Path, timeout: Duration) -> ProbeOutcome {
use std::process::{Command, Stdio};
let mut child = match Command::new(probe)
.arg(plugin)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => return ProbeOutcome::Failed(format!("failed to spawn probe: {e}")),
};
let stdout = match child.stdout.take() {
Some(s) => s,
None => {
let _ = child.kill();
let _ = child.wait();
return ProbeOutcome::Failed("probe produced no stdout pipe".to_string());
}
};
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
use std::io::BufRead;
let mut line = String::new();
let mut reader = std::io::BufReader::new(stdout);
let _ = reader.read_line(&mut line);
let _ = tx.send(line);
});
fn remaining(deadline: std::time::Instant) -> Duration {
deadline
.saturating_duration_since(std::time::Instant::now())
.max(PROBE_OUTPUT_GRACE)
}
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let output = rx.recv_timeout(remaining(deadline)).unwrap_or_default();
if status.success() {
let line = output.trim();
return match serde_json::from_str::<DetailedPluginInfo>(line) {
Ok(info) => ProbeOutcome::Ok(Box::new(info)),
Err(e) => ProbeOutcome::Failed(format!(
"probe succeeded but its output did not parse: {e}"
)),
};
}
return ProbeOutcome::Crashed(format!("probe exited with {status}"));
}
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return ProbeOutcome::TimedOut;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return ProbeOutcome::Failed(format!("failed to wait on probe: {e}"));
}
}
}
}
pub fn probe_plugin_info_isolated(path: &Path, timeout: Duration) -> Result<DetailedPluginInfo> {
let probe = find_probe_binary().map_err(crate::Error::Other)?;
match run_probe(&probe, path, timeout) {
ProbeOutcome::Ok(info) => Ok(*info),
ProbeOutcome::Crashed(detail) => Err(crate::Error::PluginLoadFailed(format!(
"probe crashed introspecting {}: {detail}",
path.display()
))),
ProbeOutcome::TimedOut => Err(crate::Error::PluginTimeout),
ProbeOutcome::Failed(detail) => Err(crate::Error::PluginLoadFailed(detail)),
}
}
pub fn discover_plugins_safe(paths: &[PathBuf], timeout: Duration) -> SafeDiscoveryReport {
let probe = match find_probe_binary() {
Ok(p) => p,
Err(e) => {
log::warn!("Safe discovery unavailable: {e}");
return SafeDiscoveryReport {
error: Some(e),
..Default::default()
};
}
};
let plugin_paths = scan_directories(paths).unwrap_or_default();
let mut report = SafeDiscoveryReport::default();
for path in plugin_paths {
match run_probe(&probe, &path, timeout) {
ProbeOutcome::Ok(info) => report.plugins.push(*info),
ProbeOutcome::Crashed(detail) => {
log::warn!(
"Skipping plugin that crashed the probe: {} ({detail})",
path.display()
);
report
.skipped
.push(SafeDiscoverySkip::Crashed { path, detail });
}
ProbeOutcome::TimedOut => {
log::warn!("Skipping plugin whose probe timed out: {}", path.display());
report.skipped.push(SafeDiscoverySkip::TimedOut { path });
}
ProbeOutcome::Failed(detail) => {
log::warn!(
"Skipping plugin the probe could not introspect: {} ({detail})",
path.display()
);
report
.skipped
.push(SafeDiscoverySkip::Failed { path, detail });
}
}
}
report
}
#[cfg(any(test, target_os = "windows"))]
fn windows_arch_folders(arch: &str, on_arm64_windows: bool) -> &'static [&'static str] {
match arch {
"x86" => &["x86-win"],
"x86_64" if on_arm64_windows => &["x86_64-win", "arm64ec-win", "arm64x-win"],
"x86_64" => &["x86_64-win"],
"arm64ec" => &["arm64ec-win", "arm64x-win", "x86_64-win"],
"aarch64" => &["arm64-win", "arm64x-win"],
_ => &[],
}
}
#[cfg(any(test, target_os = "linux"))]
fn linux_arch_folders(arch: &str) -> &'static [&'static str] {
match arch {
"x86" => &["i386-linux"],
"x86_64" => &["x86_64-linux"],
"aarch64" => &["aarch64-linux"],
_ => &[],
}
}
#[cfg(any(test, target_os = "windows", target_os = "linux"))]
fn find_arch_binary(bundle_path: &Path, folders: &[&str], extension: &str) -> Option<PathBuf> {
let contents = bundle_path.join("Contents");
folders.iter().find_map(|folder| {
std::fs::read_dir(contents.join(folder))
.ok()?
.flatten()
.map(|entry| entry.path())
.find(|path| path.extension() == Some(std::ffi::OsStr::new(extension)))
})
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn no_loadable_binary(bundle_path: &Path, folders: &[&str]) -> crate::Error {
let searched = if folders.is_empty() {
"none".to_owned()
} else {
folders.join(", ")
};
crate::Error::PluginLoadFailed(format!(
"{} has no binary this {} process can load (Contents folders searched: {searched})",
bundle_path.display(),
std::env::consts::ARCH,
))
}
pub fn get_vst3_binary_path(bundle_path: &Path) -> Result<PathBuf> {
if bundle_path.is_file() {
return Ok(bundle_path.to_path_buf());
}
#[cfg(target_os = "macos")]
{
if bundle_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
let contents_path = bundle_path.join("Contents").join("MacOS");
if let Ok(entries) = std::fs::read_dir(&contents_path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.is_file() {
if let Some(name) = file_path.file_name() {
if let Some(name_str) = name.to_str() {
if !name_str.starts_with('.')
&& !name_str.ends_with(".plist")
&& !name_str.ends_with(".txt")
{
return Ok(file_path);
}
}
}
}
}
}
}
}
#[cfg(target_os = "windows")]
{
if bundle_path.is_dir() {
let folders = windows_arch_folders(
std::env::consts::ARCH,
crate::internal::module_loader::windows::runs_on_arm64_windows(),
);
return find_arch_binary(bundle_path, folders, "vst3")
.ok_or_else(|| no_loadable_binary(bundle_path, folders));
}
}
#[cfg(target_os = "linux")]
{
if bundle_path.is_dir() {
let folders = linux_arch_folders(std::env::consts::ARCH);
return find_arch_binary(bundle_path, folders, "so")
.ok_or_else(|| no_loadable_binary(bundle_path, folders));
}
}
Err(crate::Error::PluginNotFound(format!(
"Could not find VST3 binary in bundle: {}",
bundle_path.display()
)))
}
#[cfg(test)]
mod bundle_binary_tests {
use super::*;
struct TempBundle(PathBuf);
impl TempBundle {
fn new(folders: &[&str], extension: &str) -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let bundle = std::env::temp_dir().join(format!(
"vst3-host-multi-arch-{}-{}.vst3",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&bundle);
for folder in folders {
let dir = bundle.join("Contents").join(folder);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("Test.{extension}")), b"").unwrap();
}
Self(bundle)
}
}
impl Drop for TempBundle {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_windows_process_looks_only_in_folders_it_can_load_its_own_first() {
let cases: &[(&str, bool, &[&str])] = &[
("x86", false, &["x86-win"]),
("x86_64", false, &["x86_64-win"]),
("x86_64", true, &["x86_64-win", "arm64ec-win", "arm64x-win"]),
(
"arm64ec",
true,
&["arm64ec-win", "arm64x-win", "x86_64-win"],
),
("aarch64", true, &["arm64-win", "arm64x-win"]),
];
for &(arch, on_arm64, expected) in cases {
assert_eq!(
windows_arch_folders(arch, on_arm64),
expected,
"{arch} process, on Arm64 Windows: {on_arm64}"
);
}
}
#[test]
fn a_linux_process_looks_only_in_its_own_folder() {
for (arch, expected) in [
("x86", "i386-linux"),
("x86_64", "x86_64-linux"),
("aarch64", "aarch64-linux"),
] {
assert_eq!(linux_arch_folders(arch), [expected], "{arch} process");
}
}
#[test]
fn a_missing_native_binary_falls_back_in_order_to_a_loadable_one() {
let bundle = TempBundle::new(&["fallback-a", "fallback-b", "unloadable"], "vst3");
let found_in = |folders: &[&str]| {
let binary = find_arch_binary(&bundle.0, folders, "vst3")?;
Some(binary.parent()?.file_name()?.to_str()?.to_owned())
};
let a_first = found_in(&["native", "fallback-a", "fallback-b"]);
assert_eq!(a_first.as_deref(), Some("fallback-a"));
let b_first = found_in(&["native", "fallback-b", "fallback-a"]);
assert_eq!(b_first.as_deref(), Some("fallback-b"));
assert_eq!(found_in(&["native"]), None);
}
#[cfg(target_os = "windows")]
const ALL_FOLDERS: &[&str] = &[
"arm64-win",
"arm64ec-win",
"arm64x-win",
"x86-win",
"x86_64-win",
];
#[cfg(target_os = "windows")]
const EXTENSION: &str = "vst3";
#[cfg(target_os = "linux")]
const ALL_FOLDERS: &[&str] = &["aarch64-linux", "i386-linux", "x86_64-linux"];
#[cfg(target_os = "linux")]
const EXTENSION: &str = "so";
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn own_and_unloadable_folders() -> (&'static str, &'static [&'static str]) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86") => (
"x86-win",
&["arm64-win", "arm64ec-win", "arm64x-win", "x86_64-win"],
),
("windows", "x86_64") => ("x86_64-win", &["arm64-win", "x86-win"]),
("windows", "arm64ec") => ("arm64ec-win", &["arm64-win", "x86-win"]),
("windows", "aarch64") => ("arm64-win", &["arm64ec-win", "x86-win", "x86_64-win"]),
("linux", "x86") => ("i386-linux", &["aarch64-linux", "x86_64-linux"]),
("linux", "x86_64") => ("x86_64-linux", &["aarch64-linux", "i386-linux"]),
("linux", "aarch64") => ("aarch64-linux", &["i386-linux", "x86_64-linux"]),
(os, arch) => panic!("no VST3 bundle folder is known for {arch} {os}"),
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[test]
fn a_multi_arch_bundle_resolves_to_this_processs_own_binary() {
let (own, _) = own_and_unloadable_folders();
let bundle = TempBundle::new(ALL_FOLDERS, EXTENSION);
let found = get_vst3_binary_path(&bundle.0).unwrap();
assert_eq!(
found.parent().and_then(|folder| folder.file_name()),
Some(std::ffi::OsStr::new(own)),
"{}",
found.display()
);
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[test]
fn a_bundle_with_only_other_architectures_binaries_fails_to_load_saying_so() {
let (_, unloadable) = own_and_unloadable_folders();
let bundle = TempBundle::new(unloadable, EXTENSION);
let Err(error) = crate::internal::module_loader::load_module(&bundle.0) else {
panic!("loaded a bundle with only other architectures' binaries");
};
assert!(
matches!(error, crate::Error::PluginLoadFailed(_)),
"{error}"
);
let arch = std::env::consts::ARCH;
assert!(
error.to_string().contains(&format!("this {arch} process")),
"{error}"
);
}
}
#[cfg(test)]
mod report_tests {
use super::*;
use crate::plugin::PluginInfo;
#[test]
fn plugin_report_serializes_and_round_trips() {
let detail = DetailedPluginInfo {
info: PluginInfo {
path: std::path::PathBuf::from("/x/Dexed.vst3"),
name: "Dexed".into(),
vendor: "Digital Suburban".into(),
version: "1.0.0".into(),
category: "Instrument|Synth".into(),
uid: "ABCD".into(),
audio_inputs: 0,
audio_outputs: 1,
has_midi_input: true,
has_midi_output: true,
has_gui: true,
},
factory: FactoryInfo {
vendor: "Digital Suburban".into(),
..Default::default()
},
classes: vec![ClassInfo {
name: "Dexed".into(),
..Default::default()
}],
buses: BusLayout::default(),
module_info: None,
compatibility: Vec::new(),
};
let report = PluginReport::new(detail, Vec::new());
let json = report.to_json().expect("to_json");
let back: PluginReport = serde_json::from_str(&json).expect("round-trip");
assert_eq!(back.detailed.info.name, "Dexed");
assert_eq!(back.detailed.info.category, "Instrument|Synth");
assert!(back.detailed.info.has_midi_output);
assert_eq!(back.detailed.classes.len(), 1);
}
}
#[cfg(test)]
mod scan_tests {
use super::*;
#[cfg(unix)]
#[test]
fn scan_terminates_on_a_symlink_cycle_and_does_not_duplicate() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!("vst3-scan-cycle-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("mk root");
std::fs::create_dir_all(root.join("Real.vst3")).expect("mk bundle");
for name in ["a", "b", "c"] {
let sub = root.join(name);
std::fs::create_dir_all(&sub).expect("mk sub");
symlink(&root, sub.join("loop")).expect("symlink");
}
let found = scan_directories(std::slice::from_ref(&root)).expect("scan");
let bundles: Vec<_> = found
.iter()
.filter(|p| p.file_name() == Some(std::ffi::OsStr::new("Real.vst3")))
.collect();
assert_eq!(
bundles.len(),
1,
"the same bundle was reported {} times through symlink routes: {found:?}",
bundles.len()
);
let _ = std::fs::remove_dir_all(&root);
}
}