diff --git a/src-tauri/src/audio/plugins/clap_backend.rs b/src-tauri/src/audio/plugins/clap_backend.rs index b49bea4..03176fb 100644 --- a/src-tauri/src/audio/plugins/clap_backend.rs +++ b/src-tauri/src/audio/plugins/clap_backend.rs @@ -53,19 +53,13 @@ impl PluginBackend for ClapBackend { dirs } - fn scan_bundle(&self, path: &Path) -> Vec { + fn scan_bundle(&self, path: &Path) -> Result, String> { // Loading a foreign dylib is inherently unsafe: a non-compliant bundle // can trigger any behavior just on load. Accepted per format design. - let entry = match unsafe { PluginEntry::load(path) } { - Ok(entry) => entry, - Err(err) => { - tracing::warn!("clap: failed to load {}: {err}", path.display()); - return Vec::new(); - } - }; - let Some(factory) = entry.get_plugin_factory() else { - return Vec::new(); - }; + let entry = unsafe { PluginEntry::load(path) }.map_err(|err| format!("clap: {err}"))?; + let factory = entry + .get_plugin_factory() + .ok_or("clap: no plugin factory")?; let path_str = path.to_string_lossy().into_owned(); let mut out = Vec::new(); for desc in factory.plugin_descriptors() { @@ -103,7 +97,7 @@ impl PluginBackend for ClapBackend { version, }); } - out + Ok(out) } } diff --git a/src-tauri/src/audio/plugins/mod.rs b/src-tauri/src/audio/plugins/mod.rs index 1003132..1c5c279 100644 --- a/src-tauri/src/audio/plugins/mod.rs +++ b/src-tauri/src/audio/plugins/mod.rs @@ -48,7 +48,7 @@ pub enum PluginFormat { /// One instantiable plugin found by a scan. `uid` is stable across scans so the /// graph can store a reference and re-resolve the plugin when the project loads. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginDescriptor { pub uid: String, @@ -70,9 +70,10 @@ pub trait PluginBackend { fn search_dirs(&self) -> Vec; /// Bundle / library extension that marks a plugin, without the leading dot. fn extension(&self) -> &'static str; - /// Enumerate the plugins inside one bundle / library path. - fn scan_bundle(&self, _path: &Path) -> Vec { - Vec::new() + /// Enumerate the plugins inside one bundle / library path. `Err` means the + /// bundle could not be read this time, as opposed to holding no effects. + fn scan_bundle(&self, _path: &Path) -> Result, String> { + Ok(Vec::new()) } /// Full discovery for this format. The default walks `search_dirs` for /// `extension` bundles; a format whose registry is not the filesystem @@ -80,9 +81,14 @@ pub trait PluginBackend { fn scan(&self) -> Vec { let mut out = Vec::new(); for dir in self.search_dirs() { - scan::walk(&dir, self.extension(), &mut |bundle| { - out.extend(self.scan_bundle(bundle)) - }); + scan::walk( + &dir, + self.extension(), + &mut |bundle| match self.scan_bundle(bundle) { + Ok(found) => out.extend(found), + Err(err) => tracing::warn!("{}: {err}", bundle.display()), + }, + ); } out } diff --git a/src-tauri/src/audio/plugins/scan.rs b/src-tauri/src/audio/plugins/scan.rs index a61ef9a..0bdac92 100644 --- a/src-tauri/src/audio/plugins/scan.rs +++ b/src-tauri/src/audio/plugins/scan.rs @@ -1,4 +1,10 @@ +use std::collections::{HashMap, HashSet}; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; +use std::sync::Mutex; +use std::time::{Duration, Instant, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; #[cfg(target_os = "macos")] use super::au_backend::AuBackend; @@ -6,20 +12,257 @@ use super::clap_backend::ClapBackend; use super::vst3_backend::Vst3Backend; use super::{PluginBackend, PluginDescriptor}; -/// Scan every registered format and return all plugins found. -pub fn scan_all() -> Vec { - let backends: Vec> = vec![ - Box::new(ClapBackend), - #[cfg(target_os = "macos")] - Box::new(AuBackend), - Box::new(Vst3Backend), - ]; - backends.iter().flat_map(|b| b.scan()).collect() +const CACHE_FILE: &str = "plugins.json"; +/// Names the bundle being opened, for as long as it is open. Found on the next +/// scan only when that bundle took the app down with it. +const OPENING_FILE: &str = "plugins.opening"; +/// How stale the saved cache may get during a long scan, so a crash midway +/// keeps the work done before it. +const SAVE_INTERVAL: Duration = Duration::from_secs(1); +/// A cache written by another release is dropped whole: that release may have +/// described or rejected bundles differently. +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// What a bundle looked like on disk when it was last opened. A bundle whose +/// stamp still matches is answered from the cache without loading its binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct Stamp { + modified_ms: u64, + size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedBundle { + stamp: Stamp, + // Empty for a bundle that holds no effects or crashed the app while being + // opened, so it is not reopened on every scan either. A bundle that failed + // to load is never cached. + plugins: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct Cache { + app_version: String, + bundles: HashMap, +} + +/// Loaded from disk on the first scan. Held for a whole scan, so concurrent +/// callers queue up and then find everything already cached. +static CACHE: Mutex> = Mutex::new(None); + +/// Every plugin of every registered format. File-based formats are answered from +/// the on-disk cache for bundles unchanged since they were last opened; `full` +/// reopens every bundle. Audio Units come from the system registry, which lists +/// them without loading them, so they are never cached. +pub fn scan_all(cache_dir: Option<&Path>, full: bool) -> Vec { + let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let cache = guard.get_or_insert_with(|| cache_dir.map(load).unwrap_or_default()); + if let Some(dir) = cache_dir { + if take_crashed(dir, cache) { + save(dir, cache); + } + } + let backends: [&dyn PluginBackend; 2] = [&ClapBackend, &Vst3Backend]; + let files = scan_files(cache, &backends, full, cache_dir); + drop(guard); + + #[cfg(target_os = "macos")] + return files.into_iter().chain(AuBackend.scan()).collect(); + #[cfg(not(target_os = "macos"))] + files +} + +/// Answers each bundle found under `backends`' search dirs from `cache` when +/// its stamp is unchanged and opens it otherwise. Bundles no longer on disk +/// leave the cache. With a `cache_dir`, progress is saved as it goes and each +/// opened bundle is announced in [`OPENING_FILE`] first. +fn scan_files( + cache: &mut Cache, + backends: &[&dyn PluginBackend], + full: bool, + cache_dir: Option<&Path>, +) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + let mut dirty = false; + let mut saved_at = Instant::now(); + // The opening marker is written before the first save would create the + // dir, and a marker that fails to land protects nothing. + if let Some(dir) = cache_dir { + let _ = std::fs::create_dir_all(dir); + } + for backend in backends { + for dir in backend.search_dirs() { + walk(&dir, backend.extension(), &mut |bundle| { + let key = bundle.to_string_lossy().into_owned(); + if !seen.insert(key.clone()) { + return; + } + let stamp = stamp(bundle); + if let Some(cached) = cache.bundles.get(&key) { + if !full && cached.stamp == stamp { + out.extend(cached.plugins.iter().cloned()); + return; + } + } + + let opening = cache_dir.map(|dir| dir.join(OPENING_FILE)); + if let Some(path) = &opening { + let _ = std::fs::write(path, &key); + } + let result = catch_unwind(AssertUnwindSafe(|| backend.scan_bundle(bundle))) + .unwrap_or_else(|_| Err("panicked while scanning".into())); + if let Some(path) = &opening { + let _ = std::fs::remove_file(path); + } + + match result { + Ok(plugins) => { + out.extend(plugins.iter().cloned()); + cache.bundles.insert(key, CachedBundle { stamp, plugins }); + dirty = true; + } + // Not cached, so the next scan tries the bundle again. + Err(err) => { + tracing::warn!("{}: {err}", bundle.display()); + dirty |= cache.bundles.remove(&key).is_some(); + } + } + if let Some(dir) = cache_dir { + if dirty && saved_at.elapsed() >= SAVE_INTERVAL { + save(dir, cache); + dirty = false; + saved_at = Instant::now(); + } + } + }); + } + } + let before = cache.bundles.len(); + cache.bundles.retain(|key, _| seen.contains(key)); + dirty |= cache.bundles.len() != before; + if let Some(dir) = cache_dir { + if dirty { + save(dir, cache); + } + } + out +} + +/// Marks the bundle a previous run died opening, if any, as crashed. Returns +/// whether one was found. +fn take_crashed(cache_dir: &Path, cache: &mut Cache) -> bool { + let marker = cache_dir.join(OPENING_FILE); + let Ok(key) = std::fs::read_to_string(&marker) else { + return false; + }; + let _ = std::fs::remove_file(&marker); + tracing::warn!("{key}: crashed the app while being scanned, skipped until it changes"); + cache.bundles.insert( + key.clone(), + CachedBundle { + stamp: stamp(Path::new(&key)), + plugins: Vec::new(), + }, + ); + true +} + +/// Latest modification time and total size of the files that make up the +/// bundle. Installers update a bundle's binary and Info.plist without touching +/// the bundle directory itself, so the directory's own mtime is not enough. +/// `Resources` is skipped: it can hold thousands of files and carries no code. +fn stamp(bundle: &Path) -> Stamp { + let mut acc = Stamp { + modified_ms: 0, + size: 0, + }; + // The bundle itself may be a symlink into another plugin folder; links + // inside it are not followed (see `stamp_into`). + if let Ok(meta) = std::fs::metadata(bundle) { + stamp_entry(bundle, &meta, &mut acc); + } + acc +} + +/// Links inside a bundle are skipped rather than followed: a framework's +/// `Versions/Current` points at a sibling already counted, and a link back up +/// the tree would recurse forever. +fn stamp_into(path: &Path, acc: &mut Stamp) { + let Ok(meta) = std::fs::symlink_metadata(path) else { + return; + }; + if meta.file_type().is_symlink() { + return; + } + stamp_entry(path, &meta, acc); +} + +fn stamp_entry(path: &Path, meta: &std::fs::Metadata, acc: &mut Stamp) { + if let Some(ms) = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + { + acc.modified_ms = acc.modified_ms.max(ms.as_millis() as u64); + } + if !meta.is_dir() { + acc.size += meta.len(); + return; + } + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name() != "Resources" { + stamp_into(&entry.path(), acc); + } + } +} + +fn load(cache_dir: &Path) -> Cache { + let Ok(bytes) = std::fs::read(cache_dir.join(CACHE_FILE)) else { + return Cache::default(); + }; + match serde_json::from_slice::(&bytes) { + Ok(cache) if cache.app_version == APP_VERSION => cache, + Ok(cache) => { + tracing::info!( + from = cache.app_version, + "plugin cache from another release, rebuilding" + ); + Cache::default() + } + Err(err) => { + tracing::warn!("plugin cache unreadable, rebuilding: {err}"); + Cache::default() + } + } +} + +fn save(cache_dir: &Path, cache: &mut Cache) { + cache.app_version = APP_VERSION.to_string(); + let bytes = match serde_json::to_vec(cache) { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!("plugin cache not serialized: {err}"); + return; + } + }; + let _ = std::fs::create_dir_all(cache_dir); + let path = cache_dir.join(CACHE_FILE); + // Written aside and renamed over, so a crash mid-write never leaves a + // truncated cache behind. + let tmp = path.with_extension("json.tmp"); + if let Err(err) = std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, &path)) { + tracing::warn!("plugin cache not saved to {}: {err}", path.display()); + } } /// Recurse `dir`, invoking `on_bundle` for every entry whose extension matches /// `ext`. A matched bundle is not descended into (on macOS a `.clap` is itself -/// a directory). +/// a directory). A linked bundle is still found, but linked directories are not +/// descended into: a link back up the tree would recurse forever. pub(super) fn walk(dir: &Path, ext: &str, on_bundle: &mut impl FnMut(&Path)) { let Ok(entries) = std::fs::read_dir(dir) else { return; @@ -28,8 +271,244 @@ pub(super) fn walk(dir: &Path, ext: &str, on_bundle: &mut impl FnMut(&Path)) { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some(ext) { on_bundle(&path); - } else if path.is_dir() { + } else if !path.is_symlink() && path.is_dir() { walk(&path, ext, on_bundle); } } } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::path::PathBuf; + + use super::super::PluginFormat; + use super::*; + + /// Describes every `.fake` bundle as one plugin and counts how many + /// bundles it had to open. + struct FakeBackend { + dir: PathBuf, + opened: Cell, + } + + impl PluginBackend for FakeBackend { + fn format(&self) -> PluginFormat { + PluginFormat::Clap + } + fn search_dirs(&self) -> Vec { + vec![self.dir.clone()] + } + fn extension(&self) -> &'static str { + "fake" + } + fn scan_bundle(&self, path: &Path) -> Result, String> { + self.opened.set(self.opened.get() + 1); + match std::fs::read(path).as_deref() { + Ok(b"broken") => return Err("unreadable".into()), + Ok(b"panics") => panic!("plugin panicked"), + // Fails unless the crash marker is on disk while it is open. + Ok(b"needs-marker") + if !path.with_file_name("cache").join(OPENING_FILE).exists() => + { + return Err("opened without a crash marker".into()) + } + _ => {} + } + let path = path.to_string_lossy().into_owned(); + Ok(vec![PluginDescriptor { + uid: format!("fake:{path}"), + format: PluginFormat::Clap, + path, + plugin_id: "id".into(), + name: "name".into(), + vendor: String::new(), + version: String::new(), + }]) + } + } + + fn fixture(name: &str) -> FakeBackend { + let dir = + std::env::temp_dir().join(format!("splitwave-scan-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for bundle in ["a.fake", "b.fake"] { + std::fs::write(dir.join(bundle), b"v1").unwrap(); + } + FakeBackend { + dir, + opened: Cell::new(0), + } + } + + fn paths(found: &[PluginDescriptor]) -> Vec { + let mut paths: Vec = found.iter().map(|p| p.path.clone()).collect(); + paths.sort(); + paths + } + + fn scan(backend: &FakeBackend, cache: &mut Cache, full: bool) -> Vec { + scan_files(cache, &[backend], full, None) + } + + #[test] + fn unchanged_bundles_are_not_reopened() { + let backend = fixture("unchanged"); + let mut cache = Cache::default(); + + let first = scan(&backend, &mut cache, false); + assert_eq!(backend.opened.get(), 2); + + let again = scan(&backend, &mut cache, false); + assert_eq!(backend.opened.get(), 2, "a cached bundle was reopened"); + assert_eq!(paths(&first), paths(&again)); + } + + #[test] + fn a_changed_bundle_is_reopened_alone() { + let backend = fixture("changed"); + let mut cache = Cache::default(); + scan(&backend, &mut cache, false); + + std::fs::write(backend.dir.join("a.fake"), b"v2 is longer").unwrap(); + scan(&backend, &mut cache, false); + assert_eq!(backend.opened.get(), 3); + } + + #[test] + fn added_and_removed_bundles_are_tracked() { + let backend = fixture("added-removed"); + let mut cache = Cache::default(); + scan(&backend, &mut cache, false); + + std::fs::remove_file(backend.dir.join("b.fake")).unwrap(); + std::fs::write(backend.dir.join("c.fake"), b"v1").unwrap(); + let found = scan(&backend, &mut cache, false); + assert_eq!(backend.opened.get(), 3, "only the new bundle is opened"); + assert_eq!(found.len(), 2); + assert!(found.iter().all(|p| !p.path.ends_with("b.fake"))); + assert!(!cache.bundles.keys().any(|k| k.ends_with("b.fake"))); + } + + #[test] + fn a_full_scan_reopens_everything() { + let backend = fixture("full"); + let mut cache = Cache::default(); + scan(&backend, &mut cache, false); + scan(&backend, &mut cache, true); + assert_eq!(backend.opened.get(), 4); + } + + #[test] + fn the_cache_round_trips_through_disk() { + let backend = fixture("disk"); + let mut cache = Cache::default(); + scan_files(&mut cache, &[&backend], false, Some(&backend.dir)); + + let mut loaded = load(&backend.dir); + scan(&backend, &mut loaded, false); + assert_eq!( + backend.opened.get(), + 2, + "a bundle cached on disk was reopened" + ); + } + + #[test] + fn a_cache_from_another_release_is_dropped() { + let backend = fixture("release"); + let mut cache = Cache::default(); + scan_files(&mut cache, &[&backend], false, Some(&backend.dir)); + + let path = backend.dir.join(CACHE_FILE); + let stored = std::fs::read_to_string(&path).unwrap(); + let older = stored.replace(APP_VERSION, "0.0.1"); + std::fs::write(&path, older).unwrap(); + assert!(load(&backend.dir).bundles.is_empty()); + } + + #[test] + fn a_failed_bundle_is_retried_not_cached() { + let backend = fixture("failed"); + std::fs::write(backend.dir.join("a.fake"), b"broken").unwrap(); + let mut cache = Cache::default(); + + let found = scan(&backend, &mut cache, false); + assert_eq!(found.len(), 1); + assert!(!cache.bundles.keys().any(|k| k.ends_with("a.fake"))); + + scan(&backend, &mut cache, false); + assert_eq!(backend.opened.get(), 3, "the failed bundle was not retried"); + } + + #[test] + fn a_panicking_bundle_does_not_end_the_scan() { + let backend = fixture("panics"); + std::fs::write(backend.dir.join("a.fake"), b"panics").unwrap(); + let mut cache = Cache::default(); + + let found = scan(&backend, &mut cache, false); + assert_eq!(paths(&found).len(), 1); + assert!(found[0].path.ends_with("b.fake")); + assert!(!cache.bundles.keys().any(|k| k.ends_with("a.fake"))); + } + + #[test] + fn a_bundle_that_crashed_the_app_is_skipped_until_a_full_scan() { + let backend = fixture("crashed"); + let crashed = backend.dir.join("a.fake").to_string_lossy().into_owned(); + // What a run that died inside `scan_bundle` leaves behind. + std::fs::write(backend.dir.join(OPENING_FILE), &crashed).unwrap(); + let mut cache = Cache::default(); + + assert!(take_crashed(&backend.dir, &mut cache)); + assert!(!backend.dir.join(OPENING_FILE).exists()); + let found = scan(&backend, &mut cache, false); + assert_eq!(backend.opened.get(), 1, "the crashed bundle was reopened"); + assert!(found.iter().all(|p| p.path != crashed)); + + let found = scan(&backend, &mut cache, true); + assert_eq!(backend.opened.get(), 3); + assert!(found.iter().any(|p| p.path == crashed)); + } + + #[test] + fn a_completed_scan_leaves_no_opening_marker() { + let backend = fixture("marker"); + let mut cache = Cache::default(); + scan_files(&mut cache, &[&backend], false, Some(&backend.dir)); + assert!(!backend.dir.join(OPENING_FILE).exists()); + } + + #[cfg(unix)] + #[test] + fn a_symlink_loop_inside_a_bundle_is_not_followed() { + let backend = fixture("symlink"); + let bundle = backend.dir.join("loop.fake"); + std::fs::create_dir_all(bundle.join("Versions")).unwrap(); + std::fs::write(bundle.join("binary"), b"code").unwrap(); + std::os::unix::fs::symlink(&bundle, bundle.join("Versions/Current")).unwrap(); + assert_eq!(stamp(&bundle).size, 4); + } + + #[cfg(unix)] + #[test] + fn a_symlink_loop_in_a_search_dir_is_not_followed() { + let backend = fixture("walk-loop"); + std::os::unix::fs::symlink(&backend.dir, backend.dir.join("loop")).unwrap(); + let found = scan(&backend, &mut Cache::default(), false); + assert_eq!(found.len(), 2); + } + + #[test] + fn the_crash_marker_lands_on_a_fresh_install() { + let backend = fixture("fresh-install"); + std::fs::write(backend.dir.join("a.fake"), b"needs-marker").unwrap(); + let cache_dir = backend.dir.join("cache"); + let mut cache = Cache::default(); + + let found = scan_files(&mut cache, &[&backend], false, Some(&cache_dir)); + assert_eq!(found.len(), 2, "a bundle was opened without a crash marker"); + } +} diff --git a/src-tauri/src/audio/plugins/vst3_backend.rs b/src-tauri/src/audio/plugins/vst3_backend.rs index 2623ecb..520b953 100644 --- a/src-tauri/src/audio/plugins/vst3_backend.rs +++ b/src-tauri/src/audio/plugins/vst3_backend.rs @@ -64,33 +64,27 @@ impl PluginBackend for Vst3Backend { dirs } - fn scan_bundle(&self, path: &Path) -> Vec { + fn scan_bundle(&self, path: &Path) -> Result, String> { let p = path.to_path_buf(); let scan_op = move || { - let module = match Vst3Module::open(&p) { - Ok(module) => module, - Err(err) => { - tracing::warn!("vst3: {err}"); - return Vec::new(); - } - }; - let descriptors = module.descriptors(); - // Drop module here on the main thread so ExitDll/bundleExit and dlclose/FreeLibrary - // execute on Thread 0. - drop(module); - descriptors + // This runs on the main thread, past the reach of the scanner's own + // `catch_unwind`; a panic escaping here would take the event loop down. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let module = Vst3Module::open(&p).map_err(|err| format!("vst3: {err}"))?; + let descriptors = module.descriptors(); + // Drop module here on the main thread so ExitDll/bundleExit and dlclose/FreeLibrary + // execute on Thread 0. + drop(module); + Ok(descriptors) + })) + .unwrap_or_else(|_| Err("vst3: panicked while scanning".into())) }; if crate::audio::plugins::main_thread::is_main_thread() || crate::app_handle().is_none() { scan_op() } else { - match crate::audio::plugins::main_thread::run(scan_op) { - Ok(descriptors) => descriptors, - Err(err) => { - tracing::warn!("vst3 {}: main thread scan failed: {err}", path.display()); - Vec::new() - } - } + crate::audio::plugins::main_thread::run(scan_op) + .map_err(|err| format!("vst3: main thread scan failed: {err}"))? } } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a33a451..780660a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,7 +2,7 @@ use std::sync::mpsc::{self, Sender}; use std::time::Duration; use serde_json::json; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tracing::{error, info}; use crate::audio::device::{self, DeviceInfo, DeviceKind, NativeDeviceInfo}; @@ -45,13 +45,25 @@ where .map_err(|_| AppError::Stream("audio request task failed".into()))? } -/// Scans standard install directories for hostable plugins. Loading foreign -/// dylibs blocks and can be slow, so it runs off the main thread. +/// Scans standard install directories for hostable plugins, opening only +/// bundles that are new or changed since the cached scan; `full` reopens all of +/// them. Loading foreign dylibs blocks and can be slow, so it runs off the main +/// thread. #[tauri::command] -pub async fn scan_plugins() -> AppResult> { - tauri::async_runtime::spawn_blocking(crate::audio::plugins::scan_all) - .await - .map_err(|_| AppError::Plugin("plugin scan task failed".into())) +pub async fn scan_plugins( + app: AppHandle, + full: bool, +) -> AppResult> { + let cache_dir = app + .path() + .app_cache_dir() + .inspect_err(|err| tracing::warn!("no cache dir, plugin scan is not cached: {err}")) + .ok(); + tauri::async_runtime::spawn_blocking(move || { + crate::audio::plugins::scan_all(cache_dir.as_deref(), full) + }) + .await + .map_err(|_| AppError::Plugin("plugin scan task failed".into())) } #[tauri::command] @@ -607,7 +619,6 @@ pub async fn check_for_updates( webview: tauri::Webview, endpoint: Option, ) -> Result, String> { - use tauri::Manager; let updater = configured_updater(webview.app_handle(), endpoint.as_deref())?; let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { return Ok(None); diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index 827ce13..5b975c9 100644 --- a/src/lib/modules/audio/methods.ts +++ b/src/lib/modules/audio/methods.ts @@ -22,7 +22,7 @@ const AUDIO_STATE_EVENT = 'audio://state'; const DEVICE_VOLUME_EVENT = 'audio://device_volume'; export const methods = { - scanPlugins: (): Promise => invoke('scan_plugins'), + scanPlugins: (full: boolean): Promise => invoke('scan_plugins', { full }), openPluginEditor: (nodeId: string, title: string): Promise => invoke('open_plugin_editor', { nodeId, title }), closePluginEditor: (nodeId: string): Promise => invoke('close_plugin_editor', { nodeId }), getPluginState: (nodeId: string): Promise => invoke('get_plugin_state', { nodeId }), diff --git a/src/lib/modules/audio/plugin_catalog.svelte.ts b/src/lib/modules/audio/plugin_catalog.svelte.ts new file mode 100644 index 0000000..bbf6da1 --- /dev/null +++ b/src/lib/modules/audio/plugin_catalog.svelte.ts @@ -0,0 +1,61 @@ +import { methods } from './methods'; +import type { PluginDescriptor } from './types'; + +/** + * The installed plugins, shared by every plugin node so a graph with many of + * them scans once rather than once per node. The backend answers unchanged + * bundles from its on-disk cache, so a refresh only pays for what changed. + */ +class PluginCatalog { + plugins = $state([]); + scanning = $state(false); + /** At least one scan has succeeded, so `plugins` reflects what is installed. */ + scanned = $state(false); + /** Why the last scan failed; the list then still holds the scan before it. */ + error = $state(null); + #pending: Promise | null = null; + #pendingFull = false; + + /** Picks up plugins added, changed or removed since the last scan. */ + refresh(): Promise { + return this.#scan(false); + } + + /** Scans once per session, for a node that must know whether its plugin is installed. */ + ensure(): Promise { + return this.scanned ? Promise.resolve() : this.#scan(false); + } + + /** Reopens every bundle, including ones that failed to load before. */ + rescan(): Promise { + return this.#scan(true); + } + + #scan(full: boolean): Promise { + // A scan in flight answers anything it covers. A refresh does not cover a + // rescan, which must reopen bundles the refresh trusted to the cache. + if (this.#pending) { + if (!full || this.#pendingFull) return this.#pending; + return this.#pending.then(() => this.#scan(true)); + } + this.scanning = true; + this.#pendingFull = full; + this.#pending = methods + .scanPlugins(full) + .then((found) => { + this.plugins = found; + this.scanned = true; + this.error = null; + }) + .catch((e: unknown) => { + this.error = String(e); + }) + .finally(() => { + this.scanning = false; + this.#pending = null; + }); + return this.#pending; + } +} + +export const pluginCatalog = new PluginCatalog(); diff --git a/src/lib/modules/flow/ui/effect/plugin.svelte b/src/lib/modules/flow/ui/effect/plugin.svelte index e1f89f6..389bee1 100644 --- a/src/lib/modules/flow/ui/effect/plugin.svelte +++ b/src/lib/modules/flow/ui/effect/plugin.svelte @@ -1,8 +1,9 @@ pluginCatalog.refresh()} onChange={select}> {#snippet footer()} - + pluginCatalog.rescan()} /> {/snippet} + {#if missing} +

Not installed, or failed to load

+ {/if} + {#if pluginCatalog.error} +

Plugin scan failed: {pluginCatalog.error}

+ {/if} {#if data.path} {#if loading}