Skip to main content

vst3_host/
discovery.rs

1//! VST3 plugin discovery functionality
2
3use crate::{error::Result, plugin::PluginInfo};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::ptr;
7use std::time::Duration;
8
9/// Default time to wait for the discovery probe to introspect a single plugin before
10/// treating it as hung and killing the child process.
11pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
12
13/// Factory-level metadata (the plugin vendor's identity).
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct FactoryInfo {
16    /// Vendor / manufacturer name.
17    pub vendor: String,
18    /// Vendor URL.
19    pub url: String,
20    /// Vendor contact email.
21    pub email: String,
22    /// Raw factory flags.
23    pub flags: i32,
24}
25
26/// One class exported by a plugin's factory.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct ClassInfo {
29    /// Class display name.
30    pub name: String,
31    /// Class category (e.g. "Audio Module Class").
32    pub category: String,
33    /// Class id, hex-encoded.
34    pub class_id: String,
35    /// Instantiation cardinality.
36    pub cardinality: i32,
37    /// Version string (if available).
38    pub version: String,
39}
40
41/// One audio or event bus.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct BusInfo {
44    /// Bus display name.
45    pub name: String,
46    /// Bus type (Main = 0, Aux = 1).
47    pub bus_type: i32,
48    /// Raw bus flags.
49    pub flags: i32,
50    /// Number of channels on this bus.
51    pub channel_count: i32,
52}
53
54/// The plugin's full bus layout.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct BusLayout {
57    /// Audio input buses.
58    pub audio_inputs: Vec<BusInfo>,
59    /// Audio output buses.
60    pub audio_outputs: Vec<BusInfo>,
61    /// Event (MIDI) input buses.
62    pub event_inputs: Vec<BusInfo>,
63    /// Event (MIDI) output buses.
64    pub event_outputs: Vec<BusInfo>,
65}
66
67/// A deep introspection report for a VST3 plugin — factory, classes, and bus layout.
68/// This is the static metadata a plugin *inspector* UI needs, beyond the lightweight
69/// [`PluginInfo`]. For the parameter list, load the plugin and call
70/// [`crate::Plugin::get_parameters`] (which runs the full controller logic).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DetailedPluginInfo {
73    /// The basic metadata (also part of this report for convenience).
74    pub info: PluginInfo,
75    /// Factory / vendor identity.
76    pub factory: FactoryInfo,
77    /// All classes exported by the factory.
78    pub classes: Vec<ClassInfo>,
79    /// Full audio + event bus layout.
80    pub buses: BusLayout,
81}
82
83/// A complete, serializable report of a plugin: static introspection plus its parameter
84/// list. Build it after loading the plugin and serialize to JSON for export (e.g. the
85/// inspector's "Copy JSON", or feeding plugin metadata to other tools).
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PluginReport {
88    /// Static introspection: factory, classes, bus layout, basic info.
89    pub detailed: DetailedPluginInfo,
90    /// The plugin's parameters (normalized values + metadata).
91    pub parameters: Vec<crate::parameters::Parameter>,
92}
93
94impl PluginReport {
95    /// Bundle a [`DetailedPluginInfo`] with a parameter list (from
96    /// [`crate::Plugin::get_parameters`]).
97    pub fn new(
98        detailed: DetailedPluginInfo,
99        parameters: Vec<crate::parameters::Parameter>,
100    ) -> Self {
101        Self {
102            detailed,
103            parameters,
104        }
105    }
106
107    /// Serialize the report to pretty-printed JSON.
108    pub fn to_json(&self) -> serde_json::Result<String> {
109        serde_json::to_string_pretty(self)
110    }
111}
112
113/// Scan standard VST3 directories for plugins
114pub 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
146/// Scan directories for VST3 plugins
147pub 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    // Remove duplicates and sort
157    plugins.sort();
158    plugins.dedup();
159
160    Ok(plugins)
161}
162
163/// Recursively scan a directory for VST3 plugins
164fn 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            // Check if it's a VST3 bundle/file
170            if let Some(ext) = path.extension() {
171                if ext == "vst3" {
172                    plugins.push(path.clone());
173                }
174            }
175
176            // Recursively scan subdirectories (but not .vst3 bundles)
177            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
186/// Get metadata for a VST3 plugin without fully loading it
187pub 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        // Load the module using our VST3-compliant module loader
194        let module = crate::internal::module_loader::load_module(path)?;
195
196        // Get factory using the proper VST3 loading sequence
197        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        // Get factory info
204        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        // Find audio component
210        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                    // Real version + sub-categories via IPluginFactory2 (PClassInfo.category
230                    // is just "Audio Module Class"; the useful sub-categories live in
231                    // PClassInfo2.subCategories). Left empty rather than faked when absent.
232                    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                    // Convert UID to string
242                    // cid is an array of bytes, convert to hex string
243                    uid = class_info
244                        .cid
245                        .iter()
246                        .map(|b| format!("{:02X}", b))
247                        .collect::<String>();
248
249                    // Try to create component to get more info
250                    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                        // Initialize with a host context (null crashes u-he/Waves plugins).
264                        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                        // Get bus counts
274                        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                        // MIDI capability from event bus presence.
278                        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                        // GUI detection (lightweight). A plugin has an editor when it provides
282                        // an edit controller — either the component itself implements
283                        // IEditController (single-component) or it names a separate controller
284                        // class. The previous check only handled the single-component case, so
285                        // it wrongly reported "no GUI" for the common separate-component
286                        // plugins. A precise createView probe needs the plugin's full setup
287                        // (component handler + activation) that only the load path performs;
288                        // controller presence is the reliable fast signal here.
289                        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                        // Cleanup
295                        component.terminate();
296                    }
297
298                    break;
299                }
300            }
301        }
302
303        // If no audio component found, use first class
304        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
334/// Deep-introspect a VST3 plugin: factory identity, exported classes, and bus layout.
335///
336/// Heavier than [`get_plugin_info`] (it enumerates every class and bus) but still does
337/// not require driving audio. For the parameter list, load the plugin and call
338/// [`crate::Plugin::get_parameters`].
339pub 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    // Reuse the lightweight pass for the basic info.
346    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        // Factory identity.
356        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        // Exported classes + locate the audio component class id.
366        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(), // not available in PClassInfo
387                });
388            }
389        }
390
391        // Bus layout from the audio component.
392        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                    // Initialize with a host context (null crashes u-he/Waves plugins).
403                    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// ---------------------------------------------------------------------------
448// Crash-resistant ("safe") discovery via a probe subprocess.
449//
450// `get_plugin_info` / `get_detailed_plugin_info` INSTANTIATE each plugin in-process to
451// introspect it. Some installed plugins (licensed plugins that fail their auth check,
452// etc.) call `abort()` or trigger a pure-virtual call during instantiation — which kills
453// the whole host process. A Rust `catch_unwind` cannot help: an `abort()` terminates the
454// process, it does not unwind. The only robust isolation is to do the risky introspection
455// in a child process so the crash kills the child, not us.
456//
457// This path is independent of the run-time isolation IPC (`process_isolation` /
458// `vst3-host-helper`): it spawns a dedicated, minimal `vst3-host-probe` binary once per
459// plugin, reads one JSON line of `DetailedPluginInfo` from its stdout, and skips any
460// plugin whose probe crashed / timed out / exited non-zero. Correctness over speed: a
461// process spawn per plugin is slower than the in-process scan, which is the accepted
462// trade-off for a crash-proof scan.
463// ---------------------------------------------------------------------------
464
465/// Why a single plugin was skipped during a safe scan. Surfaced via
466/// [`SafeDiscoveryReport`] so callers can log or display *why* a plugin was omitted.
467#[derive(Debug, Clone)]
468pub enum SafeDiscoverySkip {
469    /// The probe process crashed (e.g. the plugin called `abort()` or made a
470    /// pure-virtual call) — exactly the case in-process scanning cannot survive.
471    Crashed {
472        /// The plugin path that was skipped.
473        path: PathBuf,
474        /// Human-readable detail (exit status / signal).
475        detail: String,
476    },
477    /// The probe did not finish within the timeout and was killed.
478    TimedOut {
479        /// The plugin path that was skipped.
480        path: PathBuf,
481    },
482    /// The probe ran but reported a (non-crash) failure introspecting the plugin.
483    Failed {
484        /// The plugin path that was skipped.
485        path: PathBuf,
486        /// Error detail from the probe (or this process).
487        detail: String,
488    },
489}
490
491impl SafeDiscoverySkip {
492    /// The plugin path that was skipped.
493    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/// Result of a crash-resistant scan: the plugins that introspected cleanly, plus a record
503/// of every plugin that was skipped and why.
504#[derive(Debug, Default)]
505pub struct SafeDiscoveryReport {
506    /// Plugins that introspected successfully.
507    pub plugins: Vec<DetailedPluginInfo>,
508    /// Plugins that were skipped (crashed / timed out / failed), with the reason.
509    pub skipped: Vec<SafeDiscoverySkip>,
510}
511
512/// Locate the `vst3-host-probe` binary that does the risky introspection out-of-process.
513///
514/// Mirrors the heuristic the isolation layer uses to find `vst3-host-helper` (same exe
515/// directory → examples parent → cargo `target/{debug,release}`), and honours an explicit
516/// override via the `VST3_HOST_PROBE_PATH` environment variable. Kept self-contained here
517/// rather than reusing the isolation module's resolver so the two stay decoupled.
518fn 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    // Same directory as the current executable.
536    let direct = exe_dir.join(PROBE_NAME);
537    if direct.exists() {
538        return Ok(direct);
539    }
540
541    // If we're in an examples/ directory, try the parent (where bins land).
542    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    // Walk up looking for a cargo target/{debug,release} that holds the probe.
552    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
573/// Outcome of probing a single plugin out-of-process.
574enum ProbeOutcome {
575    /// Introspection succeeded.
576    Ok(Box<DetailedPluginInfo>),
577    /// The probe process crashed (killed by a signal / non-graceful exit).
578    Crashed(String),
579    /// The probe exceeded the timeout and was killed.
580    TimedOut,
581    /// The probe ran but reported a (non-crash) failure.
582    Failed(String),
583}
584
585/// Run the probe binary against one plugin path with a timeout, returning the parsed
586/// outcome. The crash of a misbehaving plugin kills *the probe child*, surfacing here as
587/// [`ProbeOutcome::Crashed`] rather than taking down this process.
588fn 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    // Read stdout on a thread so we can enforce a wall-clock timeout on the child.
603    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                // Child exited; collect whatever it printed.
621                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                // Non-success exit. A signal-kill (segfault/abort) has no exit code on
633                // Unix; treat both signal deaths and explicit non-zero exits as a crash —
634                // the point of the safe path is that *neither* is fatal to us.
635                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
655/// Crash-resistantly introspect a single plugin out-of-process.
656///
657/// Spawns the `vst3-host-probe` binary to do the risky instantiation in a child process,
658/// so a plugin that `abort()`s or makes a pure-virtual call during init kills the child
659/// instead of this process. Returns `Ok(info)` on success; `Err` (with a descriptive
660/// message) if the probe crashed, timed out, failed, or could not be located — callers
661/// that want a "skip the bad one and keep going" scan should use
662/// [`discover_plugins_safe`] instead, which never returns an error for a single bad plugin.
663pub 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
676/// Crash-resistantly discover plugins in `paths`: introspect every `.vst3` bundle in a
677/// child process and **skip** any plugin whose probe crashes, hangs, or fails — the scan
678/// always completes and returns the plugins it could introspect.
679///
680/// This is the robust answer to "one bad plugin in the folder takes down the scan": an
681/// `abort()`/pure-virtual-call during instantiation kills the probe child, not the host.
682/// Each skipped plugin is logged (`log::warn!`) and recorded in
683/// [`SafeDiscoveryReport::skipped`].
684///
685/// Trade-off: this spawns one `vst3-host-probe` process per plugin, so it is slower than
686/// the in-process [`crate::Vst3Host::discover_plugins`]. Use it for a robust "safe scan"
687/// of an untrusted folder; keep the in-process path for speed when you trust the plugins.
688pub 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
731/// Platform-specific VST3 binary path resolution
732pub fn get_vst3_binary_path(bundle_path: &Path) -> Result<PathBuf> {
733    // If it's already pointing to the binary, use it
734    if bundle_path.is_file() {
735        return Ok(bundle_path.to_path_buf());
736    }
737
738    // Platform-specific VST3 bundle handling
739    #[cfg(target_os = "macos")]
740    {
741        // macOS: .vst3 bundle structure
742        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                                // Skip hidden files and common non-binary files
751                                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        // Windows: .vst3 file or folder structure
768        if bundle_path.is_dir() {
769            // Look for the .vst3 in the per-arch Contents folder. VST3 uses `arm64-win`
770            // (and `arm64ec-win`) for ARM64 — not `aarch64-win`. Native arch first.
771            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        // Linux: Similar to Windows
793        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        // The export round-trips and preserves the accurate metadata.
854        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}