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> {
const PROBE_NAME: &str = "vst3-host-probe";
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
}
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 contents = bundle_path.join("Contents");
let arm64_path = contents.join("arm64-win");
let arm64ec_path = contents.join("arm64ec-win");
let x64_path = contents.join("x86_64-win");
let x86_path = contents.join("x86-win");
for contents_path in &[arm64_path, arm64ec_path, x64_path, x86_path] {
if let Ok(entries) = std::fs::read_dir(contents_path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
return Ok(file_path);
}
}
}
}
}
}
#[cfg(target_os = "linux")]
{
if bundle_path.is_dir() {
let contents_path = bundle_path.join("Contents");
let arch_paths = [
contents_path.join("aarch64-linux"),
contents_path.join("x86_64-linux"),
contents_path.join("i386-linux"),
];
for arch_path in &arch_paths {
if let Ok(entries) = std::fs::read_dir(arch_path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension() == Some(std::ffi::OsStr::new("so")) {
return Ok(file_path);
}
}
}
}
}
}
Err(crate::Error::PluginNotFound(format!(
"Could not find VST3 binary in bundle: {}",
bundle_path.display()
)))
}
#[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);
}
}