1use crate::{error::Result, plugin::PluginInfo};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::ptr;
7use std::time::Duration;
8
9pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct FactoryInfo {
16 pub vendor: String,
18 pub url: String,
20 pub email: String,
22 pub flags: i32,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ModuleFactoryFlags {
29 pub unicode: bool,
31 pub classes_discardable: bool,
33 pub license_check: bool,
35 pub component_non_discardable: bool,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41pub struct ModuleFactoryInfo {
42 pub vendor: String,
44 pub url: String,
46 pub email: String,
48 pub flags: ModuleFactoryFlags,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
54pub struct ModuleClassInfo {
55 pub class_id: String,
57 pub category: String,
59 pub name: String,
61 pub vendor: String,
63 pub version: String,
65 pub sdk_version: String,
67 pub sub_categories: Vec<String>,
69 pub class_flags: i32,
71 pub cardinality: i32,
73 pub snapshots: Vec<PluginSnapshot>,
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
79pub struct PluginSnapshot {
80 pub class_id: String,
82 pub scale_factor: f64,
84 pub path: PathBuf,
86}
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
90pub struct ClassCompatibility {
91 pub new_class_id: String,
93 pub old_class_ids: Vec<String>,
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99pub struct ModuleInfo {
100 pub source: PathBuf,
102 pub name: String,
104 pub version: String,
106 pub factory: ModuleFactoryInfo,
108 pub classes: Vec<ModuleClassInfo>,
110 pub compatibility: Vec<ClassCompatibility>,
112}
113
114impl ModuleInfo {
115 pub fn resolve_class_id(&self, requested_class_id: &str) -> Option<&str> {
117 if let Some(class) = self.classes.iter().find(|class| {
118 crate::internal::utils::class_uid_matches(&class.class_id, requested_class_id)
119 }) {
120 return Some(&class.class_id);
121 }
122 self.compatibility.iter().find_map(|mapping| {
123 mapping
124 .old_class_ids
125 .iter()
126 .any(|old| crate::internal::utils::class_uid_matches(old, requested_class_id))
127 .then_some(mapping.new_class_id.as_str())
128 })
129 }
130
131 pub fn replaced_class_ids(&self, current_class_id: &str) -> &[String] {
133 self.compatibility
134 .iter()
135 .find(|mapping| {
136 crate::internal::utils::class_uid_matches(&mapping.new_class_id, current_class_id)
137 })
138 .map_or(&[], |mapping| mapping.old_class_ids.as_slice())
139 }
140}
141
142pub fn read_module_info(path: &Path) -> Result<Option<ModuleInfo>> {
149 crate::internal::module_info::read(path)
150}
151
152pub fn get_plugin_compatibility(path: &Path) -> Result<Vec<ClassCompatibility>> {
158 if let Some(module_info) = read_module_info(path)? {
159 return Ok(module_info.compatibility);
160 }
161
162 use vst3::{ComPtr, Steinberg::Vst::IHostApplication, Steinberg::*};
163 unsafe {
164 let host_app = crate::internal::com_implementations::create_host_application();
167 let host_ctx = host_app.to_com_ptr::<IHostApplication>();
168 let context = host_ctx
169 .as_ref()
170 .map(|pointer| pointer.as_ptr() as *mut FUnknown)
171 .unwrap_or(ptr::null_mut());
172
173 let module = crate::internal::module_loader::load_module(path)?;
174 let factory_ptr = module.get_factory()?;
175 let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
176 crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
177 })?;
178 if let Some(factory3) = factory.cast::<IPluginFactory3>() {
179 let result = factory3.setHostContext(context);
180 if result != kResultOk && result != kResultTrue {
181 log::warn!(
182 "IPluginFactory3::setHostContext failed during compatibility discovery: \
183 {result:#x}"
184 );
185 }
186 }
187 crate::internal::module_info::read_factory_compatibility(&factory)
188 }
189}
190
191pub fn discover_plugin_snapshots(
198 path: &Path,
199 current_class_id: &str,
200) -> Result<Vec<PluginSnapshot>> {
201 crate::internal::module_info::discover_snapshots(path, current_class_id)
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ClassInfo {
207 pub name: String,
209 pub category: String,
211 pub class_id: String,
213 pub cardinality: i32,
215 pub version: String,
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct BusInfo {
222 pub name: String,
224 pub bus_type: i32,
226 pub flags: i32,
228 pub channel_count: i32,
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct BusLayout {
235 pub audio_inputs: Vec<BusInfo>,
237 pub audio_outputs: Vec<BusInfo>,
239 pub event_inputs: Vec<BusInfo>,
241 pub event_outputs: Vec<BusInfo>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DetailedPluginInfo {
251 pub info: PluginInfo,
253 pub factory: FactoryInfo,
255 pub classes: Vec<ClassInfo>,
257 pub buses: BusLayout,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub module_info: Option<ModuleInfo>,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub compatibility: Vec<ClassCompatibility>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct PluginReport {
272 pub detailed: DetailedPluginInfo,
274 pub parameters: Vec<crate::parameters::Parameter>,
276}
277
278impl PluginReport {
279 pub fn new(
282 detailed: DetailedPluginInfo,
283 parameters: Vec<crate::parameters::Parameter>,
284 ) -> Self {
285 Self {
286 detailed,
287 parameters,
288 }
289 }
290
291 pub fn to_json(&self) -> serde_json::Result<String> {
293 serde_json::to_string_pretty(self)
294 }
295}
296
297pub fn scan_standard_paths() -> Vec<PathBuf> {
299 let mut paths = Vec::new();
300
301 #[cfg(target_os = "macos")]
302 {
303 paths.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
304 if let Ok(home) = std::env::var("HOME") {
305 paths.push(PathBuf::from(format!(
306 "{}/Library/Audio/Plug-Ins/VST3",
307 home
308 )));
309 }
310 }
311
312 #[cfg(target_os = "windows")]
313 {
314 paths.push(PathBuf::from(r"C:\Program Files\Common Files\VST3"));
315 paths.push(PathBuf::from(r"C:\Program Files (x86)\Common Files\VST3"));
316 }
317
318 #[cfg(target_os = "linux")]
319 {
320 paths.push(PathBuf::from("/usr/lib/vst3"));
321 paths.push(PathBuf::from("/usr/local/lib/vst3"));
322 if let Ok(home) = std::env::var("HOME") {
323 paths.push(PathBuf::from(format!("{}/.vst3", home)));
324 }
325 }
326
327 paths
328}
329
330pub fn scan_directories(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
332 let mut plugins = Vec::new();
333
334 let mut visited = std::collections::HashSet::new();
339 for path in paths {
340 if path.exists() {
341 scan_directory(path, &mut plugins, &mut visited)?;
342 }
343 }
344
345 plugins.sort();
347 plugins.dedup();
348
349 Ok(plugins)
350}
351
352fn scan_directory(
357 dir: &Path,
358 plugins: &mut Vec<PathBuf>,
359 visited: &mut std::collections::HashSet<PathBuf>,
360) -> Result<()> {
361 match dir.canonicalize() {
364 Ok(real) => {
365 if !visited.insert(real) {
366 return Ok(());
367 }
368 }
369 Err(_) => return Ok(()),
370 }
371
372 if let Ok(entries) = std::fs::read_dir(dir) {
373 for entry in entries.flatten() {
374 let path = entry.path();
375
376 if let Some(ext) = path.extension() {
378 if ext == "vst3" {
379 plugins.push(path.clone());
380 }
381 }
382
383 if path.is_dir() && path.extension() != Some(std::ffi::OsStr::new("vst3")) {
385 scan_directory(&path, plugins, visited)?;
386 }
387 }
388 }
389
390 Ok(())
391}
392
393pub fn get_plugin_info(path: &Path) -> Result<PluginInfo> {
395 use vst3::Steinberg::Vst::BusDirections_::*;
396 use vst3::Steinberg::Vst::MediaTypes_::*;
397 use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
398
399 unsafe {
400 let host_app = crate::internal::com_implementations::create_host_application();
406 let host_ctx = host_app.to_com_ptr::<IHostApplication>();
407 let context = host_ctx
408 .as_ref()
409 .map(|p| p.as_ptr() as *mut FUnknown)
410 .unwrap_or(ptr::null_mut());
411
412 let module = crate::internal::module_loader::load_module(path)?;
414
415 let factory_ptr = module.get_factory()?;
417
418 let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
419 crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
420 })?;
421 if let Some(factory3) = factory.cast::<IPluginFactory3>() {
422 let result = factory3.setHostContext(context);
423 if result != kResultOk && result != kResultTrue {
424 log::warn!("IPluginFactory3::setHostContext failed during discovery: {result:#x}");
425 }
426 }
427
428 let mut factory_info: PFactoryInfo = std::mem::zeroed();
430 factory.getFactoryInfo(&mut factory_info);
431
432 let vendor = crate::internal::utils::c_str_to_string(&factory_info.vendor);
433
434 let num_classes = factory.countClasses();
436 let mut plugin_name = String::new();
437 let mut category = String::new();
438 let mut version = String::new();
439 let mut uid = String::new();
440 let mut has_midi_input = false;
441 let mut has_midi_output = false;
442 let mut audio_inputs = 0u32;
443 let mut audio_outputs = 0u32;
444 let mut has_gui = false;
445
446 for i in 0..num_classes {
447 let mut class_info: PClassInfo = std::mem::zeroed();
448 if factory.getClassInfo(i, &mut class_info) == kResultOk {
449 let class_category = crate::internal::utils::c_str_to_string(&class_info.category);
450
451 if class_category.contains("Audio Module Class") {
452 plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
453
454 if let Some(f2) = factory.cast::<IPluginFactory2>() {
458 let mut info2: PClassInfo2 = std::mem::zeroed();
459 if f2.getClassInfo2(i, &mut info2) == kResultOk {
460 version = crate::internal::utils::c_str_to_string(&info2.version);
461 category =
462 crate::internal::utils::c_str_to_string(&info2.subCategories);
463 }
464 }
465 if let Some(f3) = factory.cast::<IPluginFactory3>() {
466 let mut info3: PClassInfoW = std::mem::zeroed();
467 if f3.getClassInfoUnicode(i, &mut info3) == kResultOk {
468 let utf16 = |value: &[u16]| {
469 let end =
470 value.iter().position(|&ch| ch == 0).unwrap_or(value.len());
471 String::from_utf16_lossy(&value[..end])
472 };
473 let unicode_name = utf16(&info3.name);
474 let unicode_version = utf16(&info3.version);
475 if !unicode_name.is_empty() {
476 plugin_name = unicode_name;
477 }
478 if !unicode_version.is_empty() {
479 version = unicode_version;
480 }
481 let unicode_category =
482 crate::internal::utils::c_str_to_string(&info3.subCategories);
483 if !unicode_category.is_empty() {
484 category = unicode_category;
485 }
486 }
487 }
488
489 uid = crate::internal::utils::format_class_uid(&class_info.cid);
490
491 let mut component_ptr: *mut IComponent = ptr::null_mut();
493 let result = factory.createInstance(
494 class_info.cid.as_ptr() as *const std::os::raw::c_char,
495 IComponent::IID.as_ptr() as *const std::os::raw::c_char,
496 &mut component_ptr as *mut _ as *mut _,
497 );
498
499 if result == kResultOk && !component_ptr.is_null() {
500 let component =
501 ComPtr::<IComponent>::from_raw(component_ptr).ok_or_else(|| {
502 crate::error::Error::Other("Failed to wrap component".to_string())
503 })?;
504
505 component.initialize(context);
507
508 audio_inputs = component.getBusCount(kAudio as i32, kInput as i32) as u32;
510 audio_outputs = component.getBusCount(kAudio as i32, kOutput as i32) as u32;
511
512 has_midi_input = component.getBusCount(kEvent as i32, kInput as i32) > 0;
514 has_midi_output = component.getBusCount(kEvent as i32, kOutput as i32) > 0;
515
516 has_gui = component.cast::<IEditController>().is_some() || {
525 let mut cid: [std::os::raw::c_char; 16] = [0; 16];
526 component.getControllerClassId(&mut cid) == kResultOk
527 };
528
529 component.terminate();
531 }
532
533 break;
534 }
535 }
536 }
537
538 if plugin_name.is_empty() && num_classes > 0 {
540 let mut class_info: PClassInfo = std::mem::zeroed();
541 if factory.getClassInfo(0, &mut class_info) == kResultOk {
542 plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
543 }
544 }
545
546 Ok(PluginInfo {
547 path: path.to_path_buf(),
548 name: if plugin_name.is_empty() {
549 path.file_stem()
550 .and_then(|s| s.to_str())
551 .unwrap_or("Unknown")
552 .to_string()
553 } else {
554 plugin_name
555 },
556 vendor,
557 version,
558 category,
559 uid,
560 audio_inputs,
561 audio_outputs,
562 has_midi_input,
563 has_midi_output,
564 has_gui,
565 })
566 }
567}
568
569pub fn get_detailed_plugin_info(path: &Path) -> Result<DetailedPluginInfo> {
575 use vst3::Steinberg::Vst::BusDirections_::*;
576 use vst3::Steinberg::Vst::BusInfo as VstBusInfo;
577 use vst3::Steinberg::Vst::MediaTypes_::*;
578 use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
579
580 let module_info = read_module_info(path)?;
583
584 let info = get_plugin_info(path)?;
586
587 unsafe {
588 let host_app = crate::internal::com_implementations::create_host_application();
591 let host_ctx = host_app.to_com_ptr::<IHostApplication>();
592 let context = host_ctx
593 .as_ref()
594 .map(|p| p.as_ptr() as *mut FUnknown)
595 .unwrap_or(ptr::null_mut());
596
597 let module = crate::internal::module_loader::load_module(path)?;
598 let factory_ptr = module.get_factory()?;
599 let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
600 crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
601 })?;
602 if let Some(factory3) = factory.cast::<IPluginFactory3>() {
603 let result = factory3.setHostContext(context);
604 if result != kResultOk && result != kResultTrue {
605 log::warn!(
606 "IPluginFactory3::setHostContext failed during detailed discovery: \
607 {result:#x}"
608 );
609 }
610 }
611 let compatibility = match module_info.as_ref() {
612 Some(module_info) => module_info.compatibility.clone(),
613 None => crate::internal::module_info::read_factory_compatibility(&factory)?,
614 };
615
616 let mut fi: PFactoryInfo = std::mem::zeroed();
618 factory.getFactoryInfo(&mut fi);
619 let factory_info = FactoryInfo {
620 vendor: crate::internal::utils::c_str_to_string(&fi.vendor),
621 url: crate::internal::utils::c_str_to_string(&fi.url),
622 email: crate::internal::utils::c_str_to_string(&fi.email),
623 flags: fi.flags,
624 };
625
626 let num_classes = factory.countClasses();
628 let mut classes = Vec::new();
629 let mut audio_cid: Option<[std::os::raw::c_char; 16]> = None;
630 for i in 0..num_classes {
631 let mut ci: PClassInfo = std::mem::zeroed();
632 if factory.getClassInfo(i, &mut ci) == kResultOk {
633 let category = crate::internal::utils::c_str_to_string(&ci.category);
634 let class_id = crate::internal::utils::format_class_uid(&ci.cid);
635 if category.contains("Audio Module Class") && audio_cid.is_none() {
636 audio_cid = Some(ci.cid);
637 }
638 let mut name = crate::internal::utils::c_str_to_string(&ci.name);
639 let mut version = String::new();
640 if let Some(factory3) = factory.cast::<IPluginFactory3>() {
641 let mut info3: PClassInfoW = std::mem::zeroed();
642 if factory3.getClassInfoUnicode(i, &mut info3) == kResultOk {
643 let utf16 = |value: &[u16]| {
644 let end = value.iter().position(|&ch| ch == 0).unwrap_or(value.len());
645 String::from_utf16_lossy(&value[..end])
646 };
647 let unicode_name = utf16(&info3.name);
648 if !unicode_name.is_empty() {
649 name = unicode_name;
650 }
651 version = utf16(&info3.version);
652 }
653 } else if let Some(factory2) = factory.cast::<IPluginFactory2>() {
654 let mut info2: PClassInfo2 = std::mem::zeroed();
655 if factory2.getClassInfo2(i, &mut info2) == kResultOk {
656 version = crate::internal::utils::c_str_to_string(&info2.version);
657 }
658 }
659 classes.push(ClassInfo {
660 name,
661 category,
662 class_id,
663 cardinality: ci.cardinality,
664 version,
665 });
666 }
667 }
668
669 let mut buses = BusLayout::default();
671 if let Some(cid) = audio_cid {
672 let mut component_ptr: *mut IComponent = ptr::null_mut();
673 let result = factory.createInstance(
674 cid.as_ptr(),
675 IComponent::IID.as_ptr() as *const std::os::raw::c_char,
676 &mut component_ptr as *mut _ as *mut _,
677 );
678 if result == kResultOk && !component_ptr.is_null() {
679 if let Some(component) = ComPtr::<IComponent>::from_raw(component_ptr) {
680 component.initialize(context);
682
683 let collect = |media: i32, dir: i32| -> Vec<crate::discovery::BusInfo> {
684 let mut out = Vec::new();
685 let count = component.getBusCount(media, dir);
686 for i in 0..count {
687 let mut bi: VstBusInfo = std::mem::zeroed();
688 if component.getBusInfo(media, dir, i, &mut bi) == kResultOk {
689 out.push(crate::discovery::BusInfo {
690 name: crate::internal::utils::vst_string_to_string(&bi.name),
691 bus_type: bi.busType,
692 flags: bi.flags as i32,
693 channel_count: bi.channelCount,
694 });
695 }
696 }
697 out
698 };
699
700 buses.audio_inputs = collect(kAudio as i32, kInput as i32);
701 buses.audio_outputs = collect(kAudio as i32, kOutput as i32);
702 buses.event_inputs = collect(kEvent as i32, kInput as i32);
703 buses.event_outputs = collect(kEvent as i32, kOutput as i32);
704
705 component.terminate();
706 }
707 }
708 }
709
710 Ok(DetailedPluginInfo {
711 info,
712 factory: factory_info,
713 classes,
714 buses,
715 module_info,
716 compatibility,
717 })
718 }
719}
720
721#[derive(Debug, Clone)]
742pub enum SafeDiscoverySkip {
743 Crashed {
746 path: PathBuf,
748 detail: String,
750 },
751 TimedOut {
753 path: PathBuf,
755 },
756 Failed {
758 path: PathBuf,
760 detail: String,
762 },
763}
764
765impl SafeDiscoverySkip {
766 pub fn path(&self) -> &Path {
768 match self {
769 SafeDiscoverySkip::Crashed { path, .. }
770 | SafeDiscoverySkip::TimedOut { path }
771 | SafeDiscoverySkip::Failed { path, .. } => path,
772 }
773 }
774}
775
776#[derive(Debug, Default)]
779pub struct SafeDiscoveryReport {
780 pub plugins: Vec<DetailedPluginInfo>,
782 pub skipped: Vec<SafeDiscoverySkip>,
784 pub error: Option<String>,
789}
790
791impl SafeDiscoveryReport {
792 pub fn scan_ran(&self) -> bool {
795 self.error.is_none()
796 }
797}
798
799pub(crate) fn running_from_cargo_target(exe_dir: &Path) -> bool {
806 exe_dir.ancestors().any(|dir| {
807 matches!(
808 dir.file_name().and_then(|n| n.to_str()),
809 Some("debug") | Some("release")
810 ) && dir
811 .parent()
812 .and_then(|p| p.file_name())
813 .and_then(|n| n.to_str())
814 == Some("target")
815 })
816}
817
818fn find_probe_binary() -> std::result::Result<PathBuf, String> {
825 let probe_name = format!("vst3-host-probe{}", std::env::consts::EXE_SUFFIX);
826
827 if let Some(p) = std::env::var_os("VST3_HOST_PROBE_PATH").map(PathBuf::from) {
828 if p.exists() {
829 return Ok(p);
830 }
831 return Err(format!(
832 "VST3_HOST_PROBE_PATH does not exist: {}",
833 p.display()
834 ));
835 }
836
837 let exe_path =
838 std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
839 let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
840
841 let direct = exe_dir.join(&probe_name);
843 if direct.exists() {
844 return Ok(direct);
845 }
846
847 if exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
849 if let Some(parent) = exe_dir.parent() {
850 let p = parent.join(&probe_name);
851 if p.exists() {
852 return Ok(p);
853 }
854 }
855 }
856
857 if running_from_cargo_target(exe_dir) {
867 let mut current = exe_dir;
868 while let Some(parent) = current.parent() {
869 for profile in ["debug", "release"] {
870 let candidate = parent.join("target").join(profile).join(&probe_name);
871 if candidate.exists() {
872 return Ok(candidate);
873 }
874 }
875 if parent.join("Cargo.toml").exists() {
876 break;
877 }
878 current = parent;
879 }
880 }
881
882 Err(format!(
883 "Probe executable '{probe_name}' not found near {} or in target/{{debug,release}}. \
884 Build it with `cargo build --bin vst3-host-probe`, or set VST3_HOST_PROBE_PATH.",
885 exe_dir.display()
886 ))
887}
888
889enum ProbeOutcome {
891 Ok(Box<DetailedPluginInfo>),
893 Crashed(String),
895 TimedOut,
897 Failed(String),
899}
900
901const PROBE_OUTPUT_GRACE: Duration = Duration::from_millis(250);
904
905fn run_probe(probe: &Path, plugin: &Path, timeout: Duration) -> ProbeOutcome {
917 use std::process::{Command, Stdio};
918
919 let mut child = match Command::new(probe)
920 .arg(plugin)
921 .stdin(Stdio::null())
922 .stdout(Stdio::piped())
923 .stderr(Stdio::null())
924 .spawn()
925 {
926 Ok(c) => c,
927 Err(e) => return ProbeOutcome::Failed(format!("failed to spawn probe: {e}")),
928 };
929
930 let stdout = match child.stdout.take() {
932 Some(s) => s,
933 None => {
934 let _ = child.kill();
935 let _ = child.wait();
936 return ProbeOutcome::Failed("probe produced no stdout pipe".to_string());
937 }
938 };
939 let (tx, rx) = std::sync::mpsc::channel::<String>();
940 std::thread::spawn(move || {
941 use std::io::BufRead;
942 let mut line = String::new();
943 let mut reader = std::io::BufReader::new(stdout);
946 let _ = reader.read_line(&mut line);
947 let _ = tx.send(line);
949 });
950
951 fn remaining(deadline: std::time::Instant) -> Duration {
954 deadline
955 .saturating_duration_since(std::time::Instant::now())
956 .max(PROBE_OUTPUT_GRACE)
957 }
958
959 let deadline = std::time::Instant::now() + timeout;
960 loop {
961 match child.try_wait() {
962 Ok(Some(status)) => {
963 let output = rx.recv_timeout(remaining(deadline)).unwrap_or_default();
965 if status.success() {
966 let line = output.trim();
967 return match serde_json::from_str::<DetailedPluginInfo>(line) {
968 Ok(info) => ProbeOutcome::Ok(Box::new(info)),
969 Err(e) => ProbeOutcome::Failed(format!(
970 "probe succeeded but its output did not parse: {e}"
971 )),
972 };
973 }
974 return ProbeOutcome::Crashed(format!("probe exited with {status}"));
978 }
979 Ok(None) => {
980 if std::time::Instant::now() >= deadline {
981 let _ = child.kill();
982 let _ = child.wait();
983 return ProbeOutcome::TimedOut;
984 }
985 std::thread::sleep(Duration::from_millis(20));
986 }
987 Err(e) => {
988 let _ = child.kill();
989 let _ = child.wait();
990 return ProbeOutcome::Failed(format!("failed to wait on probe: {e}"));
991 }
992 }
993 }
994}
995
996pub fn probe_plugin_info_isolated(path: &Path, timeout: Duration) -> Result<DetailedPluginInfo> {
1005 let probe = find_probe_binary().map_err(crate::Error::Other)?;
1006 match run_probe(&probe, path, timeout) {
1007 ProbeOutcome::Ok(info) => Ok(*info),
1008 ProbeOutcome::Crashed(detail) => Err(crate::Error::PluginLoadFailed(format!(
1009 "probe crashed introspecting {}: {detail}",
1010 path.display()
1011 ))),
1012 ProbeOutcome::TimedOut => Err(crate::Error::PluginTimeout),
1013 ProbeOutcome::Failed(detail) => Err(crate::Error::PluginLoadFailed(detail)),
1014 }
1015}
1016
1017pub fn discover_plugins_safe(paths: &[PathBuf], timeout: Duration) -> SafeDiscoveryReport {
1035 let probe = match find_probe_binary() {
1036 Ok(p) => p,
1037 Err(e) => {
1038 log::warn!("Safe discovery unavailable: {e}");
1039 return SafeDiscoveryReport {
1040 error: Some(e),
1041 ..Default::default()
1042 };
1043 }
1044 };
1045
1046 let plugin_paths = scan_directories(paths).unwrap_or_default();
1047 let mut report = SafeDiscoveryReport::default();
1048
1049 for path in plugin_paths {
1050 match run_probe(&probe, &path, timeout) {
1051 ProbeOutcome::Ok(info) => report.plugins.push(*info),
1052 ProbeOutcome::Crashed(detail) => {
1053 log::warn!(
1054 "Skipping plugin that crashed the probe: {} ({detail})",
1055 path.display()
1056 );
1057 report
1058 .skipped
1059 .push(SafeDiscoverySkip::Crashed { path, detail });
1060 }
1061 ProbeOutcome::TimedOut => {
1062 log::warn!("Skipping plugin whose probe timed out: {}", path.display());
1063 report.skipped.push(SafeDiscoverySkip::TimedOut { path });
1064 }
1065 ProbeOutcome::Failed(detail) => {
1066 log::warn!(
1067 "Skipping plugin the probe could not introspect: {} ({detail})",
1068 path.display()
1069 );
1070 report
1071 .skipped
1072 .push(SafeDiscoverySkip::Failed { path, detail });
1073 }
1074 }
1075 }
1076
1077 report
1078}
1079
1080#[cfg(any(test, target_os = "windows"))]
1089fn windows_arch_folders(arch: &str, on_arm64_windows: bool) -> &'static [&'static str] {
1090 match arch {
1091 "x86" => &["x86-win"],
1092 "x86_64" if on_arm64_windows => &["x86_64-win", "arm64ec-win", "arm64x-win"],
1093 "x86_64" => &["x86_64-win"],
1094 "arm64ec" => &["arm64ec-win", "arm64x-win", "x86_64-win"],
1095 "aarch64" => &["arm64-win", "arm64x-win"],
1096 _ => &[],
1097 }
1098}
1099
1100#[cfg(any(test, target_os = "linux"))]
1103fn linux_arch_folders(arch: &str) -> &'static [&'static str] {
1104 match arch {
1105 "x86" => &["i386-linux"],
1106 "x86_64" => &["x86_64-linux"],
1107 "aarch64" => &["aarch64-linux"],
1108 _ => &[],
1109 }
1110}
1111
1112#[cfg(any(test, target_os = "windows", target_os = "linux"))]
1115fn find_arch_binary(bundle_path: &Path, folders: &[&str], extension: &str) -> Option<PathBuf> {
1116 let contents = bundle_path.join("Contents");
1117 folders.iter().find_map(|folder| {
1118 std::fs::read_dir(contents.join(folder))
1119 .ok()?
1120 .flatten()
1121 .map(|entry| entry.path())
1122 .find(|path| path.extension() == Some(std::ffi::OsStr::new(extension)))
1123 })
1124}
1125
1126#[cfg(any(target_os = "windows", target_os = "linux"))]
1129fn no_loadable_binary(bundle_path: &Path, folders: &[&str]) -> crate::Error {
1130 let searched = if folders.is_empty() {
1131 "none".to_owned()
1132 } else {
1133 folders.join(", ")
1134 };
1135 crate::Error::PluginLoadFailed(format!(
1136 "{} has no binary this {} process can load (Contents folders searched: {searched})",
1137 bundle_path.display(),
1138 std::env::consts::ARCH,
1139 ))
1140}
1141
1142pub fn get_vst3_binary_path(bundle_path: &Path) -> Result<PathBuf> {
1149 if bundle_path.is_file() {
1151 return Ok(bundle_path.to_path_buf());
1152 }
1153
1154 #[cfg(target_os = "macos")]
1156 {
1157 if bundle_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
1159 let contents_path = bundle_path.join("Contents").join("MacOS");
1160 if let Ok(entries) = std::fs::read_dir(&contents_path) {
1161 for entry in entries.flatten() {
1162 let file_path = entry.path();
1163 if file_path.is_file() {
1164 if let Some(name) = file_path.file_name() {
1165 if let Some(name_str) = name.to_str() {
1166 if !name_str.starts_with('.')
1168 && !name_str.ends_with(".plist")
1169 && !name_str.ends_with(".txt")
1170 {
1171 return Ok(file_path);
1172 }
1173 }
1174 }
1175 }
1176 }
1177 }
1178 }
1179 }
1180
1181 #[cfg(target_os = "windows")]
1182 {
1183 if bundle_path.is_dir() {
1185 let folders = windows_arch_folders(
1188 std::env::consts::ARCH,
1189 crate::internal::module_loader::windows::runs_on_arm64_windows(),
1190 );
1191 return find_arch_binary(bundle_path, folders, "vst3")
1192 .ok_or_else(|| no_loadable_binary(bundle_path, folders));
1193 }
1194 }
1195
1196 #[cfg(target_os = "linux")]
1197 {
1198 if bundle_path.is_dir() {
1200 let folders = linux_arch_folders(std::env::consts::ARCH);
1201 return find_arch_binary(bundle_path, folders, "so")
1202 .ok_or_else(|| no_loadable_binary(bundle_path, folders));
1203 }
1204 }
1205
1206 Err(crate::Error::PluginNotFound(format!(
1207 "Could not find VST3 binary in bundle: {}",
1208 bundle_path.display()
1209 )))
1210}
1211
1212#[cfg(test)]
1213mod bundle_binary_tests {
1214 use super::*;
1215
1216 struct TempBundle(PathBuf);
1219
1220 impl TempBundle {
1221 fn new(folders: &[&str], extension: &str) -> Self {
1222 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1223 let bundle = std::env::temp_dir().join(format!(
1224 "vst3-host-multi-arch-{}-{}.vst3",
1225 std::process::id(),
1226 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1227 ));
1228 let _ = std::fs::remove_dir_all(&bundle);
1230 for folder in folders {
1231 let dir = bundle.join("Contents").join(folder);
1232 std::fs::create_dir_all(&dir).unwrap();
1233 std::fs::write(dir.join(format!("Test.{extension}")), b"").unwrap();
1234 }
1235 Self(bundle)
1236 }
1237 }
1238
1239 impl Drop for TempBundle {
1240 fn drop(&mut self) {
1241 let _ = std::fs::remove_dir_all(&self.0);
1242 }
1243 }
1244
1245 #[test]
1248 fn a_windows_process_looks_only_in_folders_it_can_load_its_own_first() {
1249 let cases: &[(&str, bool, &[&str])] = &[
1251 ("x86", false, &["x86-win"]),
1252 ("x86_64", false, &["x86_64-win"]),
1253 ("x86_64", true, &["x86_64-win", "arm64ec-win", "arm64x-win"]),
1254 (
1255 "arm64ec",
1256 true,
1257 &["arm64ec-win", "arm64x-win", "x86_64-win"],
1258 ),
1259 ("aarch64", true, &["arm64-win", "arm64x-win"]),
1260 ];
1261 for &(arch, on_arm64, expected) in cases {
1262 assert_eq!(
1263 windows_arch_folders(arch, on_arm64),
1264 expected,
1265 "{arch} process, on Arm64 Windows: {on_arm64}"
1266 );
1267 }
1268 }
1269
1270 #[test]
1272 fn a_linux_process_looks_only_in_its_own_folder() {
1273 for (arch, expected) in [
1274 ("x86", "i386-linux"),
1275 ("x86_64", "x86_64-linux"),
1276 ("aarch64", "aarch64-linux"),
1277 ] {
1278 assert_eq!(linux_arch_folders(arch), [expected], "{arch} process");
1279 }
1280 }
1281
1282 #[test]
1285 fn a_missing_native_binary_falls_back_in_order_to_a_loadable_one() {
1286 let bundle = TempBundle::new(&["fallback-a", "fallback-b", "unloadable"], "vst3");
1287 let found_in = |folders: &[&str]| {
1288 let binary = find_arch_binary(&bundle.0, folders, "vst3")?;
1289 Some(binary.parent()?.file_name()?.to_str()?.to_owned())
1290 };
1291 let a_first = found_in(&["native", "fallback-a", "fallback-b"]);
1292 assert_eq!(a_first.as_deref(), Some("fallback-a"));
1293 let b_first = found_in(&["native", "fallback-b", "fallback-a"]);
1294 assert_eq!(b_first.as_deref(), Some("fallback-b"));
1295 assert_eq!(found_in(&["native"]), None);
1296 }
1297
1298 #[cfg(target_os = "windows")]
1299 const ALL_FOLDERS: &[&str] = &[
1300 "arm64-win",
1301 "arm64ec-win",
1302 "arm64x-win",
1303 "x86-win",
1304 "x86_64-win",
1305 ];
1306 #[cfg(target_os = "windows")]
1307 const EXTENSION: &str = "vst3";
1308
1309 #[cfg(target_os = "linux")]
1310 const ALL_FOLDERS: &[&str] = &["aarch64-linux", "i386-linux", "x86_64-linux"];
1311 #[cfg(target_os = "linux")]
1312 const EXTENSION: &str = "so";
1313
1314 #[cfg(any(target_os = "windows", target_os = "linux"))]
1317 fn own_and_unloadable_folders() -> (&'static str, &'static [&'static str]) {
1318 match (std::env::consts::OS, std::env::consts::ARCH) {
1319 ("windows", "x86") => (
1320 "x86-win",
1321 &["arm64-win", "arm64ec-win", "arm64x-win", "x86_64-win"],
1322 ),
1323 ("windows", "x86_64") => ("x86_64-win", &["arm64-win", "x86-win"]),
1324 ("windows", "arm64ec") => ("arm64ec-win", &["arm64-win", "x86-win"]),
1325 ("windows", "aarch64") => ("arm64-win", &["arm64ec-win", "x86-win", "x86_64-win"]),
1326 ("linux", "x86") => ("i386-linux", &["aarch64-linux", "x86_64-linux"]),
1327 ("linux", "x86_64") => ("x86_64-linux", &["aarch64-linux", "i386-linux"]),
1328 ("linux", "aarch64") => ("aarch64-linux", &["i386-linux", "x86_64-linux"]),
1329 (os, arch) => panic!("no VST3 bundle folder is known for {arch} {os}"),
1330 }
1331 }
1332
1333 #[cfg(any(target_os = "windows", target_os = "linux"))]
1336 #[test]
1337 fn a_multi_arch_bundle_resolves_to_this_processs_own_binary() {
1338 let (own, _) = own_and_unloadable_folders();
1339 let bundle = TempBundle::new(ALL_FOLDERS, EXTENSION);
1340 let found = get_vst3_binary_path(&bundle.0).unwrap();
1341 assert_eq!(
1342 found.parent().and_then(|folder| folder.file_name()),
1343 Some(std::ffi::OsStr::new(own)),
1344 "{}",
1345 found.display()
1346 );
1347 }
1348
1349 #[cfg(any(target_os = "windows", target_os = "linux"))]
1352 #[test]
1353 fn a_bundle_with_only_other_architectures_binaries_fails_to_load_saying_so() {
1354 let (_, unloadable) = own_and_unloadable_folders();
1355 let bundle = TempBundle::new(unloadable, EXTENSION);
1356 let Err(error) = crate::internal::module_loader::load_module(&bundle.0) else {
1357 panic!("loaded a bundle with only other architectures' binaries");
1358 };
1359 assert!(
1360 matches!(error, crate::Error::PluginLoadFailed(_)),
1361 "{error}"
1362 );
1363 let arch = std::env::consts::ARCH;
1364 assert!(
1365 error.to_string().contains(&format!("this {arch} process")),
1366 "{error}"
1367 );
1368 }
1369}
1370
1371#[cfg(test)]
1372mod report_tests {
1373 use super::*;
1374 use crate::plugin::PluginInfo;
1375
1376 #[test]
1377 fn plugin_report_serializes_and_round_trips() {
1378 let detail = DetailedPluginInfo {
1379 info: PluginInfo {
1380 path: std::path::PathBuf::from("/x/Dexed.vst3"),
1381 name: "Dexed".into(),
1382 vendor: "Digital Suburban".into(),
1383 version: "1.0.0".into(),
1384 category: "Instrument|Synth".into(),
1385 uid: "ABCD".into(),
1386 audio_inputs: 0,
1387 audio_outputs: 1,
1388 has_midi_input: true,
1389 has_midi_output: true,
1390 has_gui: true,
1391 },
1392 factory: FactoryInfo {
1393 vendor: "Digital Suburban".into(),
1394 ..Default::default()
1395 },
1396 classes: vec![ClassInfo {
1397 name: "Dexed".into(),
1398 ..Default::default()
1399 }],
1400 buses: BusLayout::default(),
1401 module_info: None,
1402 compatibility: Vec::new(),
1403 };
1404 let report = PluginReport::new(detail, Vec::new());
1405 let json = report.to_json().expect("to_json");
1406 let back: PluginReport = serde_json::from_str(&json).expect("round-trip");
1408 assert_eq!(back.detailed.info.name, "Dexed");
1409 assert_eq!(back.detailed.info.category, "Instrument|Synth");
1410 assert!(back.detailed.info.has_midi_output);
1411 assert_eq!(back.detailed.classes.len(), 1);
1412 }
1413}
1414
1415#[cfg(test)]
1416mod scan_tests {
1417 use super::*;
1418
1419 #[cfg(unix)]
1423 #[test]
1424 fn scan_terminates_on_a_symlink_cycle_and_does_not_duplicate() {
1425 use std::os::unix::fs::symlink;
1426
1427 let root = std::env::temp_dir().join(format!("vst3-scan-cycle-{}", std::process::id()));
1428 let _ = std::fs::remove_dir_all(&root);
1429 std::fs::create_dir_all(&root).expect("mk root");
1430 std::fs::create_dir_all(root.join("Real.vst3")).expect("mk bundle");
1431 for name in ["a", "b", "c"] {
1433 let sub = root.join(name);
1434 std::fs::create_dir_all(&sub).expect("mk sub");
1435 symlink(&root, sub.join("loop")).expect("symlink");
1436 }
1437
1438 let found = scan_directories(std::slice::from_ref(&root)).expect("scan");
1439
1440 let bundles: Vec<_> = found
1441 .iter()
1442 .filter(|p| p.file_name() == Some(std::ffi::OsStr::new("Real.vst3")))
1443 .collect();
1444 assert_eq!(
1445 bundles.len(),
1446 1,
1447 "the same bundle was reported {} times through symlink routes: {found:?}",
1448 bundles.len()
1449 );
1450
1451 let _ = std::fs::remove_dir_all(&root);
1452 }
1453}