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 const PROBE_NAME: &str = "vst3-host-probe";
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
1080pub fn get_vst3_binary_path(bundle_path: &Path) -> Result<PathBuf> {
1082 if bundle_path.is_file() {
1084 return Ok(bundle_path.to_path_buf());
1085 }
1086
1087 #[cfg(target_os = "macos")]
1089 {
1090 if bundle_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
1092 let contents_path = bundle_path.join("Contents").join("MacOS");
1093 if let Ok(entries) = std::fs::read_dir(&contents_path) {
1094 for entry in entries.flatten() {
1095 let file_path = entry.path();
1096 if file_path.is_file() {
1097 if let Some(name) = file_path.file_name() {
1098 if let Some(name_str) = name.to_str() {
1099 if !name_str.starts_with('.')
1101 && !name_str.ends_with(".plist")
1102 && !name_str.ends_with(".txt")
1103 {
1104 return Ok(file_path);
1105 }
1106 }
1107 }
1108 }
1109 }
1110 }
1111 }
1112 }
1113
1114 #[cfg(target_os = "windows")]
1115 {
1116 if bundle_path.is_dir() {
1118 let contents = bundle_path.join("Contents");
1121 let arm64_path = contents.join("arm64-win");
1122 let arm64ec_path = contents.join("arm64ec-win");
1123 let x64_path = contents.join("x86_64-win");
1124 let x86_path = contents.join("x86-win");
1125
1126 for contents_path in &[arm64_path, arm64ec_path, x64_path, x86_path] {
1127 if let Ok(entries) = std::fs::read_dir(contents_path) {
1128 for entry in entries.flatten() {
1129 let file_path = entry.path();
1130 if file_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
1131 return Ok(file_path);
1132 }
1133 }
1134 }
1135 }
1136 }
1137 }
1138
1139 #[cfg(target_os = "linux")]
1140 {
1141 if bundle_path.is_dir() {
1143 let contents_path = bundle_path.join("Contents");
1144 let arch_paths = [
1145 contents_path.join("aarch64-linux"),
1146 contents_path.join("x86_64-linux"),
1147 contents_path.join("i386-linux"),
1148 ];
1149
1150 for arch_path in &arch_paths {
1151 if let Ok(entries) = std::fs::read_dir(arch_path) {
1152 for entry in entries.flatten() {
1153 let file_path = entry.path();
1154 if file_path.extension() == Some(std::ffi::OsStr::new("so")) {
1155 return Ok(file_path);
1156 }
1157 }
1158 }
1159 }
1160 }
1161 }
1162
1163 Err(crate::Error::PluginNotFound(format!(
1164 "Could not find VST3 binary in bundle: {}",
1165 bundle_path.display()
1166 )))
1167}
1168
1169#[cfg(test)]
1170mod report_tests {
1171 use super::*;
1172 use crate::plugin::PluginInfo;
1173
1174 #[test]
1175 fn plugin_report_serializes_and_round_trips() {
1176 let detail = DetailedPluginInfo {
1177 info: PluginInfo {
1178 path: std::path::PathBuf::from("/x/Dexed.vst3"),
1179 name: "Dexed".into(),
1180 vendor: "Digital Suburban".into(),
1181 version: "1.0.0".into(),
1182 category: "Instrument|Synth".into(),
1183 uid: "ABCD".into(),
1184 audio_inputs: 0,
1185 audio_outputs: 1,
1186 has_midi_input: true,
1187 has_midi_output: true,
1188 has_gui: true,
1189 },
1190 factory: FactoryInfo {
1191 vendor: "Digital Suburban".into(),
1192 ..Default::default()
1193 },
1194 classes: vec![ClassInfo {
1195 name: "Dexed".into(),
1196 ..Default::default()
1197 }],
1198 buses: BusLayout::default(),
1199 module_info: None,
1200 compatibility: Vec::new(),
1201 };
1202 let report = PluginReport::new(detail, Vec::new());
1203 let json = report.to_json().expect("to_json");
1204 let back: PluginReport = serde_json::from_str(&json).expect("round-trip");
1206 assert_eq!(back.detailed.info.name, "Dexed");
1207 assert_eq!(back.detailed.info.category, "Instrument|Synth");
1208 assert!(back.detailed.info.has_midi_output);
1209 assert_eq!(back.detailed.classes.len(), 1);
1210 }
1211}
1212
1213#[cfg(test)]
1214mod scan_tests {
1215 use super::*;
1216
1217 #[cfg(unix)]
1221 #[test]
1222 fn scan_terminates_on_a_symlink_cycle_and_does_not_duplicate() {
1223 use std::os::unix::fs::symlink;
1224
1225 let root = std::env::temp_dir().join(format!("vst3-scan-cycle-{}", std::process::id()));
1226 let _ = std::fs::remove_dir_all(&root);
1227 std::fs::create_dir_all(&root).expect("mk root");
1228 std::fs::create_dir_all(root.join("Real.vst3")).expect("mk bundle");
1229 for name in ["a", "b", "c"] {
1231 let sub = root.join(name);
1232 std::fs::create_dir_all(&sub).expect("mk sub");
1233 symlink(&root, sub.join("loop")).expect("symlink");
1234 }
1235
1236 let found = scan_directories(std::slice::from_ref(&root)).expect("scan");
1237
1238 let bundles: Vec<_> = found
1239 .iter()
1240 .filter(|p| p.file_name() == Some(std::ffi::OsStr::new("Real.vst3")))
1241 .collect();
1242 assert_eq!(
1243 bundles.len(),
1244 1,
1245 "the same bundle was reported {} times through symlink routes: {found:?}",
1246 bundles.len()
1247 );
1248
1249 let _ = std::fs::remove_dir_all(&root);
1250 }
1251}