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)]
28pub struct ClassInfo {
29 pub name: String,
31 pub category: String,
33 pub class_id: String,
35 pub cardinality: i32,
37 pub version: String,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct BusInfo {
44 pub name: String,
46 pub bus_type: i32,
48 pub flags: i32,
50 pub channel_count: i32,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct BusLayout {
57 pub audio_inputs: Vec<BusInfo>,
59 pub audio_outputs: Vec<BusInfo>,
61 pub event_inputs: Vec<BusInfo>,
63 pub event_outputs: Vec<BusInfo>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DetailedPluginInfo {
73 pub info: PluginInfo,
75 pub factory: FactoryInfo,
77 pub classes: Vec<ClassInfo>,
79 pub buses: BusLayout,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PluginReport {
88 pub detailed: DetailedPluginInfo,
90 pub parameters: Vec<crate::parameters::Parameter>,
92}
93
94impl PluginReport {
95 pub fn new(
98 detailed: DetailedPluginInfo,
99 parameters: Vec<crate::parameters::Parameter>,
100 ) -> Self {
101 Self {
102 detailed,
103 parameters,
104 }
105 }
106
107 pub fn to_json(&self) -> serde_json::Result<String> {
109 serde_json::to_string_pretty(self)
110 }
111}
112
113pub fn scan_standard_paths() -> Vec<PathBuf> {
115 let mut paths = Vec::new();
116
117 #[cfg(target_os = "macos")]
118 {
119 paths.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
120 if let Ok(home) = std::env::var("HOME") {
121 paths.push(PathBuf::from(format!(
122 "{}/Library/Audio/Plug-Ins/VST3",
123 home
124 )));
125 }
126 }
127
128 #[cfg(target_os = "windows")]
129 {
130 paths.push(PathBuf::from(r"C:\Program Files\Common Files\VST3"));
131 paths.push(PathBuf::from(r"C:\Program Files (x86)\Common Files\VST3"));
132 }
133
134 #[cfg(target_os = "linux")]
135 {
136 paths.push(PathBuf::from("/usr/lib/vst3"));
137 paths.push(PathBuf::from("/usr/local/lib/vst3"));
138 if let Ok(home) = std::env::var("HOME") {
139 paths.push(PathBuf::from(format!("{}/.vst3", home)));
140 }
141 }
142
143 paths
144}
145
146pub fn scan_directories(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
148 let mut plugins = Vec::new();
149
150 for path in paths {
151 if path.exists() {
152 scan_directory(path, &mut plugins)?;
153 }
154 }
155
156 plugins.sort();
158 plugins.dedup();
159
160 Ok(plugins)
161}
162
163fn scan_directory(dir: &Path, plugins: &mut Vec<PathBuf>) -> Result<()> {
165 if let Ok(entries) = std::fs::read_dir(dir) {
166 for entry in entries.flatten() {
167 let path = entry.path();
168
169 if let Some(ext) = path.extension() {
171 if ext == "vst3" {
172 plugins.push(path.clone());
173 }
174 }
175
176 if path.is_dir() && path.extension() != Some(std::ffi::OsStr::new("vst3")) {
178 scan_directory(&path, plugins)?;
179 }
180 }
181 }
182
183 Ok(())
184}
185
186pub fn get_plugin_info(path: &Path) -> Result<PluginInfo> {
188 use vst3::Steinberg::Vst::BusDirections_::*;
189 use vst3::Steinberg::Vst::MediaTypes_::*;
190 use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
191
192 unsafe {
193 let module = crate::internal::module_loader::load_module(path)?;
195
196 let factory_ptr = module.get_factory()?;
198
199 let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
200 crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
201 })?;
202
203 let mut factory_info: PFactoryInfo = std::mem::zeroed();
205 factory.getFactoryInfo(&mut factory_info);
206
207 let vendor = crate::internal::utils::c_str_to_string(&factory_info.vendor);
208
209 let num_classes = factory.countClasses();
211 let mut plugin_name = String::new();
212 let mut category = String::new();
213 let mut version = String::new();
214 let mut uid = String::new();
215 let mut has_midi_input = false;
216 let mut has_midi_output = false;
217 let mut audio_inputs = 0u32;
218 let mut audio_outputs = 0u32;
219 let mut has_gui = false;
220
221 for i in 0..num_classes {
222 let mut class_info: PClassInfo = std::mem::zeroed();
223 if factory.getClassInfo(i, &mut class_info) == kResultOk {
224 let class_category = crate::internal::utils::c_str_to_string(&class_info.category);
225
226 if class_category.contains("Audio Module Class") {
227 plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
228
229 if let Some(f2) = factory.cast::<IPluginFactory2>() {
233 let mut info2: PClassInfo2 = std::mem::zeroed();
234 if f2.getClassInfo2(i, &mut info2) == kResultOk {
235 version = crate::internal::utils::c_str_to_string(&info2.version);
236 category =
237 crate::internal::utils::c_str_to_string(&info2.subCategories);
238 }
239 }
240
241 uid = class_info
244 .cid
245 .iter()
246 .map(|b| format!("{:02X}", b))
247 .collect::<String>();
248
249 let mut component_ptr: *mut IComponent = ptr::null_mut();
251 let result = factory.createInstance(
252 class_info.cid.as_ptr() as *const std::os::raw::c_char,
253 IComponent::IID.as_ptr() as *const std::os::raw::c_char,
254 &mut component_ptr as *mut _ as *mut _,
255 );
256
257 if result == kResultOk && !component_ptr.is_null() {
258 let component =
259 ComPtr::<IComponent>::from_raw(component_ptr).ok_or_else(|| {
260 crate::error::Error::Other("Failed to wrap component".to_string())
261 })?;
262
263 let host_app =
265 crate::internal::com_implementations::create_host_application();
266 let host_ctx = host_app.to_com_ptr::<IHostApplication>();
267 let context = host_ctx
268 .as_ref()
269 .map(|p| p.as_ptr() as *mut FUnknown)
270 .unwrap_or(ptr::null_mut());
271 component.initialize(context);
272
273 audio_inputs = component.getBusCount(kAudio as i32, kInput as i32) as u32;
275 audio_outputs = component.getBusCount(kAudio as i32, kOutput as i32) as u32;
276
277 has_midi_input = component.getBusCount(kEvent as i32, kInput as i32) > 0;
279 has_midi_output = component.getBusCount(kEvent as i32, kOutput as i32) > 0;
280
281 has_gui = component.cast::<IEditController>().is_some() || {
290 let mut cid: [std::os::raw::c_char; 16] = [0; 16];
291 component.getControllerClassId(&mut cid) == kResultOk
292 };
293
294 component.terminate();
296 }
297
298 break;
299 }
300 }
301 }
302
303 if plugin_name.is_empty() && num_classes > 0 {
305 let mut class_info: PClassInfo = std::mem::zeroed();
306 if factory.getClassInfo(0, &mut class_info) == kResultOk {
307 plugin_name = crate::internal::utils::c_str_to_string(&class_info.name);
308 }
309 }
310
311 Ok(PluginInfo {
312 path: path.to_path_buf(),
313 name: if plugin_name.is_empty() {
314 path.file_stem()
315 .and_then(|s| s.to_str())
316 .unwrap_or("Unknown")
317 .to_string()
318 } else {
319 plugin_name
320 },
321 vendor,
322 version,
323 category,
324 uid,
325 audio_inputs,
326 audio_outputs,
327 has_midi_input,
328 has_midi_output,
329 has_gui,
330 })
331 }
332}
333
334pub fn get_detailed_plugin_info(path: &Path) -> Result<DetailedPluginInfo> {
340 use vst3::Steinberg::Vst::BusDirections_::*;
341 use vst3::Steinberg::Vst::BusInfo as VstBusInfo;
342 use vst3::Steinberg::Vst::MediaTypes_::*;
343 use vst3::{ComPtr, Interface, Steinberg::Vst::*, Steinberg::*};
344
345 let info = get_plugin_info(path)?;
347
348 unsafe {
349 let module = crate::internal::module_loader::load_module(path)?;
350 let factory_ptr = module.get_factory()?;
351 let factory = ComPtr::<IPluginFactory>::from_raw(factory_ptr).ok_or_else(|| {
352 crate::Error::PluginLoadFailed("Failed to create factory ComPtr".to_string())
353 })?;
354
355 let mut fi: PFactoryInfo = std::mem::zeroed();
357 factory.getFactoryInfo(&mut fi);
358 let factory_info = FactoryInfo {
359 vendor: crate::internal::utils::c_str_to_string(&fi.vendor),
360 url: crate::internal::utils::c_str_to_string(&fi.url),
361 email: crate::internal::utils::c_str_to_string(&fi.email),
362 flags: fi.flags,
363 };
364
365 let num_classes = factory.countClasses();
367 let mut classes = Vec::new();
368 let mut audio_cid: Option<[std::os::raw::c_char; 16]> = None;
369 for i in 0..num_classes {
370 let mut ci: PClassInfo = std::mem::zeroed();
371 if factory.getClassInfo(i, &mut ci) == kResultOk {
372 let category = crate::internal::utils::c_str_to_string(&ci.category);
373 let class_id = ci
374 .cid
375 .iter()
376 .map(|b| format!("{:02X}", b))
377 .collect::<String>();
378 if category.contains("Audio Module Class") && audio_cid.is_none() {
379 audio_cid = Some(ci.cid);
380 }
381 classes.push(ClassInfo {
382 name: crate::internal::utils::c_str_to_string(&ci.name),
383 category,
384 class_id,
385 cardinality: ci.cardinality,
386 version: String::new(), });
388 }
389 }
390
391 let mut buses = BusLayout::default();
393 if let Some(cid) = audio_cid {
394 let mut component_ptr: *mut IComponent = ptr::null_mut();
395 let result = factory.createInstance(
396 cid.as_ptr(),
397 IComponent::IID.as_ptr() as *const std::os::raw::c_char,
398 &mut component_ptr as *mut _ as *mut _,
399 );
400 if result == kResultOk && !component_ptr.is_null() {
401 if let Some(component) = ComPtr::<IComponent>::from_raw(component_ptr) {
402 let host_app = crate::internal::com_implementations::create_host_application();
404 let host_ctx = host_app.to_com_ptr::<IHostApplication>();
405 let context = host_ctx
406 .as_ref()
407 .map(|p| p.as_ptr() as *mut FUnknown)
408 .unwrap_or(ptr::null_mut());
409 component.initialize(context);
410
411 let collect = |media: i32, dir: i32| -> Vec<crate::discovery::BusInfo> {
412 let mut out = Vec::new();
413 let count = component.getBusCount(media, dir);
414 for i in 0..count {
415 let mut bi: VstBusInfo = std::mem::zeroed();
416 if component.getBusInfo(media, dir, i, &mut bi) == kResultOk {
417 out.push(crate::discovery::BusInfo {
418 name: crate::internal::utils::vst_string_to_string(&bi.name),
419 bus_type: bi.busType,
420 flags: bi.flags as i32,
421 channel_count: bi.channelCount,
422 });
423 }
424 }
425 out
426 };
427
428 buses.audio_inputs = collect(kAudio as i32, kInput as i32);
429 buses.audio_outputs = collect(kAudio as i32, kOutput as i32);
430 buses.event_inputs = collect(kEvent as i32, kInput as i32);
431 buses.event_outputs = collect(kEvent as i32, kOutput as i32);
432
433 component.terminate();
434 }
435 }
436 }
437
438 Ok(DetailedPluginInfo {
439 info,
440 factory: factory_info,
441 classes,
442 buses,
443 })
444 }
445}
446
447#[derive(Debug, Clone)]
468pub enum SafeDiscoverySkip {
469 Crashed {
472 path: PathBuf,
474 detail: String,
476 },
477 TimedOut {
479 path: PathBuf,
481 },
482 Failed {
484 path: PathBuf,
486 detail: String,
488 },
489}
490
491impl SafeDiscoverySkip {
492 pub fn path(&self) -> &Path {
494 match self {
495 SafeDiscoverySkip::Crashed { path, .. }
496 | SafeDiscoverySkip::TimedOut { path }
497 | SafeDiscoverySkip::Failed { path, .. } => path,
498 }
499 }
500}
501
502#[derive(Debug, Default)]
505pub struct SafeDiscoveryReport {
506 pub plugins: Vec<DetailedPluginInfo>,
508 pub skipped: Vec<SafeDiscoverySkip>,
510}
511
512fn find_probe_binary() -> std::result::Result<PathBuf, String> {
519 const PROBE_NAME: &str = "vst3-host-probe";
520
521 if let Some(p) = std::env::var_os("VST3_HOST_PROBE_PATH").map(PathBuf::from) {
522 if p.exists() {
523 return Ok(p);
524 }
525 return Err(format!(
526 "VST3_HOST_PROBE_PATH does not exist: {}",
527 p.display()
528 ));
529 }
530
531 let exe_path =
532 std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
533 let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
534
535 let direct = exe_dir.join(PROBE_NAME);
537 if direct.exists() {
538 return Ok(direct);
539 }
540
541 if exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
543 if let Some(parent) = exe_dir.parent() {
544 let p = parent.join(PROBE_NAME);
545 if p.exists() {
546 return Ok(p);
547 }
548 }
549 }
550
551 let mut current = exe_dir;
553 while let Some(parent) = current.parent() {
554 for profile in ["debug", "release"] {
555 let candidate = parent.join("target").join(profile).join(PROBE_NAME);
556 if candidate.exists() {
557 return Ok(candidate);
558 }
559 }
560 if parent.join("Cargo.toml").exists() {
561 break;
562 }
563 current = parent;
564 }
565
566 Err(format!(
567 "Probe executable '{PROBE_NAME}' not found near {} or in target/{{debug,release}}. \
568 Build it with `cargo build --bin vst3-host-probe`, or set VST3_HOST_PROBE_PATH.",
569 exe_dir.display()
570 ))
571}
572
573enum ProbeOutcome {
575 Ok(Box<DetailedPluginInfo>),
577 Crashed(String),
579 TimedOut,
581 Failed(String),
583}
584
585fn run_probe(probe: &Path, plugin: &Path, timeout: Duration) -> ProbeOutcome {
589 use std::process::{Command, Stdio};
590
591 let mut child = match Command::new(probe)
592 .arg(plugin)
593 .stdin(Stdio::null())
594 .stdout(Stdio::piped())
595 .stderr(Stdio::null())
596 .spawn()
597 {
598 Ok(c) => c,
599 Err(e) => return ProbeOutcome::Failed(format!("failed to spawn probe: {e}")),
600 };
601
602 let stdout = match child.stdout.take() {
604 Some(s) => s,
605 None => return ProbeOutcome::Failed("probe produced no stdout pipe".to_string()),
606 };
607 let (tx, rx) = std::sync::mpsc::channel::<String>();
608 let reader = std::thread::spawn(move || {
609 use std::io::Read;
610 let mut buf = String::new();
611 let mut stdout = stdout;
612 let _ = stdout.read_to_string(&mut buf);
613 let _ = tx.send(buf);
614 });
615
616 let deadline = std::time::Instant::now() + timeout;
617 loop {
618 match child.try_wait() {
619 Ok(Some(status)) => {
620 let output = rx.recv().unwrap_or_default();
622 let _ = reader.join();
623 if status.success() {
624 let line = output.trim();
625 return match serde_json::from_str::<DetailedPluginInfo>(line) {
626 Ok(info) => ProbeOutcome::Ok(Box::new(info)),
627 Err(e) => ProbeOutcome::Failed(format!(
628 "probe succeeded but its output did not parse: {e}"
629 )),
630 };
631 }
632 return ProbeOutcome::Crashed(format!("probe exited with {status}"));
636 }
637 Ok(None) => {
638 if std::time::Instant::now() >= deadline {
639 let _ = child.kill();
640 let _ = child.wait();
641 let _ = reader.join();
642 return ProbeOutcome::TimedOut;
643 }
644 std::thread::sleep(Duration::from_millis(20));
645 }
646 Err(e) => {
647 let _ = child.kill();
648 let _ = reader.join();
649 return ProbeOutcome::Failed(format!("failed to wait on probe: {e}"));
650 }
651 }
652 }
653}
654
655pub fn probe_plugin_info_isolated(path: &Path, timeout: Duration) -> Result<DetailedPluginInfo> {
664 let probe = find_probe_binary().map_err(crate::Error::Other)?;
665 match run_probe(&probe, path, timeout) {
666 ProbeOutcome::Ok(info) => Ok(*info),
667 ProbeOutcome::Crashed(detail) => Err(crate::Error::PluginLoadFailed(format!(
668 "probe crashed introspecting {}: {detail}",
669 path.display()
670 ))),
671 ProbeOutcome::TimedOut => Err(crate::Error::PluginTimeout),
672 ProbeOutcome::Failed(detail) => Err(crate::Error::PluginLoadFailed(detail)),
673 }
674}
675
676pub fn discover_plugins_safe(paths: &[PathBuf], timeout: Duration) -> SafeDiscoveryReport {
689 let probe = match find_probe_binary() {
690 Ok(p) => p,
691 Err(e) => {
692 log::warn!("Safe discovery unavailable: {e}");
693 return SafeDiscoveryReport::default();
694 }
695 };
696
697 let plugin_paths = scan_directories(paths).unwrap_or_default();
698 let mut report = SafeDiscoveryReport::default();
699
700 for path in plugin_paths {
701 match run_probe(&probe, &path, timeout) {
702 ProbeOutcome::Ok(info) => report.plugins.push(*info),
703 ProbeOutcome::Crashed(detail) => {
704 log::warn!(
705 "Skipping plugin that crashed the probe: {} ({detail})",
706 path.display()
707 );
708 report
709 .skipped
710 .push(SafeDiscoverySkip::Crashed { path, detail });
711 }
712 ProbeOutcome::TimedOut => {
713 log::warn!("Skipping plugin whose probe timed out: {}", path.display());
714 report.skipped.push(SafeDiscoverySkip::TimedOut { path });
715 }
716 ProbeOutcome::Failed(detail) => {
717 log::warn!(
718 "Skipping plugin the probe could not introspect: {} ({detail})",
719 path.display()
720 );
721 report
722 .skipped
723 .push(SafeDiscoverySkip::Failed { path, detail });
724 }
725 }
726 }
727
728 report
729}
730
731pub fn get_vst3_binary_path(bundle_path: &Path) -> Result<PathBuf> {
733 if bundle_path.is_file() {
735 return Ok(bundle_path.to_path_buf());
736 }
737
738 #[cfg(target_os = "macos")]
740 {
741 if bundle_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
743 let contents_path = bundle_path.join("Contents").join("MacOS");
744 if let Ok(entries) = std::fs::read_dir(&contents_path) {
745 for entry in entries.flatten() {
746 let file_path = entry.path();
747 if file_path.is_file() {
748 if let Some(name) = file_path.file_name() {
749 if let Some(name_str) = name.to_str() {
750 if !name_str.starts_with('.')
752 && !name_str.ends_with(".plist")
753 && !name_str.ends_with(".txt")
754 {
755 return Ok(file_path);
756 }
757 }
758 }
759 }
760 }
761 }
762 }
763 }
764
765 #[cfg(target_os = "windows")]
766 {
767 if bundle_path.is_dir() {
769 let contents = bundle_path.join("Contents");
772 let arm64_path = contents.join("arm64-win");
773 let arm64ec_path = contents.join("arm64ec-win");
774 let x64_path = contents.join("x86_64-win");
775 let x86_path = contents.join("x86-win");
776
777 for contents_path in &[arm64_path, arm64ec_path, x64_path, x86_path] {
778 if let Ok(entries) = std::fs::read_dir(contents_path) {
779 for entry in entries.flatten() {
780 let file_path = entry.path();
781 if file_path.extension() == Some(std::ffi::OsStr::new("vst3")) {
782 return Ok(file_path);
783 }
784 }
785 }
786 }
787 }
788 }
789
790 #[cfg(target_os = "linux")]
791 {
792 if bundle_path.is_dir() {
794 let contents_path = bundle_path.join("Contents");
795 let arch_paths = [
796 contents_path.join("aarch64-linux"),
797 contents_path.join("x86_64-linux"),
798 contents_path.join("i386-linux"),
799 ];
800
801 for arch_path in &arch_paths {
802 if let Ok(entries) = std::fs::read_dir(arch_path) {
803 for entry in entries.flatten() {
804 let file_path = entry.path();
805 if file_path.extension() == Some(std::ffi::OsStr::new("so")) {
806 return Ok(file_path);
807 }
808 }
809 }
810 }
811 }
812 }
813
814 Err(crate::Error::PluginNotFound(format!(
815 "Could not find VST3 binary in bundle: {}",
816 bundle_path.display()
817 )))
818}
819
820#[cfg(test)]
821mod report_tests {
822 use super::*;
823 use crate::plugin::PluginInfo;
824
825 #[test]
826 fn plugin_report_serializes_and_round_trips() {
827 let detail = DetailedPluginInfo {
828 info: PluginInfo {
829 path: std::path::PathBuf::from("/x/Dexed.vst3"),
830 name: "Dexed".into(),
831 vendor: "Digital Suburban".into(),
832 version: "1.0.0".into(),
833 category: "Instrument|Synth".into(),
834 uid: "ABCD".into(),
835 audio_inputs: 0,
836 audio_outputs: 1,
837 has_midi_input: true,
838 has_midi_output: true,
839 has_gui: true,
840 },
841 factory: FactoryInfo {
842 vendor: "Digital Suburban".into(),
843 ..Default::default()
844 },
845 classes: vec![ClassInfo {
846 name: "Dexed".into(),
847 ..Default::default()
848 }],
849 buses: BusLayout::default(),
850 };
851 let report = PluginReport::new(detail, Vec::new());
852 let json = report.to_json().expect("to_json");
853 let back: PluginReport = serde_json::from_str(&json).expect("round-trip");
855 assert_eq!(back.detailed.info.name, "Dexed");
856 assert_eq!(back.detailed.info.category, "Instrument|Synth");
857 assert!(back.detailed.info.has_midi_output);
858 assert_eq!(back.detailed.classes.len(), 1);
859 }
860}