diff --git a/Cargo.lock b/Cargo.lock index af20d999..0eee4e14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3083,6 +3083,7 @@ dependencies = [ name = "wrac_build" version = "0.1.0" dependencies = [ + "wrac_manifest", "zip 2.4.2", ] @@ -3109,7 +3110,6 @@ dependencies = [ "run_loop_timer", "serde", "serde_json", - "toml", "wrac_build", "wrac_clap_adapter", "wrac_log", @@ -3133,6 +3133,14 @@ dependencies = [ "time", ] +[[package]] +name = "wrac_manifest" +version = "0.1.0" +dependencies = [ + "serde", + "toml", +] + [[package]] name = "wrac_wxp_gui" version = "0.1.0" @@ -3166,6 +3174,7 @@ dependencies = [ "serde", "serde_json", "toml", + "wrac_manifest", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 37c17817..69a921d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/wrac_clap_adapter", "crates/wrac_host_context", "crates/wrac_log", + "crates/wrac_manifest", "crates/wrac_xtask", "crates/wrac_wxp_gui", ] @@ -16,6 +17,7 @@ clap-sys = "0.5.0" novonotes_run_loop = { git = "https://github.com/novonotes/wxp.git", package = "novonotes_run_loop", rev = "db0db1c0ba795fd434a981c903dcd882b638e5ed" } run_loop_timer = { git = "https://github.com/novonotes/wxp.git", package = "run_loop_timer", rev = "db0db1c0ba795fd434a981c903dcd882b638e5ed" } wrac_clap_adapter = { path = "crates/wrac_clap_adapter" } +wrac_manifest = { path = "crates/wrac_manifest" } wrac_host_context = { path = "crates/wrac_host_context" } wrac_log = { path = "crates/wrac_log" } wrac_wxp_gui = { path = "crates/wrac_wxp_gui" } diff --git a/README.md b/README.md index dbe96165..2b51916b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Supported plugin formats: | Windows | CLAP / VST3 / AAX | | Linux | CLAP / VST3 | -Default build, install, and validate targets come from `package.metadata.wrac.supported_formats`. +Default build, install, and validate targets come from `wrac-plugin.toml` `supported_formats`. Use `--target` to request a specific subset; explicit plugin-format targets must be listed in `supported_formats`. `cargo xtask build` also builds the development standalone app by default, and the build command accepts `standalone` as a development-only target. Use `--dry-run` on build/install/validate commands to inspect the task graph before running it. diff --git a/README_JA.md b/README_JA.md index b9a524c7..c03050ed 100644 --- a/README_JA.md +++ b/README_JA.md @@ -113,7 +113,7 @@ Standalone app は軽量な開発・smoke test 用 host です。リリース用 | Windows | CLAP / VST3 / AAX | | Linux | CLAP / VST3 | -既定の build / install / validate target は `package.metadata.wrac.supported_formats` から決まります。 +既定の build / install / validate target は `wrac-plugin.toml` の `supported_formats` から決まります。 `--target` を使うと特定の subset だけを指定できます。明示した plugin format target は `supported_formats` に含まれている必要があります。 `cargo xtask build` は既定で開発用 standalone app もビルドします。build コマンドでは、開発専用 target として `standalone` も指定できます。 build / install / validate では `--dry-run` を使って、実行前に task graph を確認できます。 diff --git a/clap_wrapper_builder/clap-wrapper/src/detail/auv2/auv2_base_classes.h b/clap_wrapper_builder/clap-wrapper/src/detail/auv2/auv2_base_classes.h index ee32e657..6c898d09 100644 --- a/clap_wrapper_builder/clap-wrapper/src/detail/auv2/auv2_base_classes.h +++ b/clap_wrapper_builder/clap-wrapper/src/detail/auv2/auv2_base_classes.h @@ -20,10 +20,12 @@ #include #include #include +#include #include "process.h" #include "parameter.h" #include "detail/shared/fixedqueue.h" +#include "detail/shared/spinlock.h" #include "detail/os/osutil.h" #include "detail/clap/automation.h" @@ -566,6 +568,11 @@ class WrapAsAUV2 : public ausdk::AUBase, std::atomic_bool _requestUICallback = false; std::atomic_bool _flushRequested = false; + std::atomic_bool _processEverCalled = false; + // CLAP requires params.flush() and process() to be mutually exclusive. AUv2 + // can run idle callbacks while GarageBand is rendering, so use the same + // process/flush exclusion model as the VST3 wrapper. + ClapWrapper::detail::shared::SpinLock _processOrFlushLock; // the queue from audiothread to UI thread ClapWrapper::detail::shared::fixedqueue _queueToUI; diff --git a/clap_wrapper_builder/clap-wrapper/src/wrapasauv2.cpp b/clap_wrapper_builder/clap-wrapper/src/wrapasauv2.cpp index 81542da2..2351e240 100644 --- a/clap_wrapper_builder/clap-wrapper/src/wrapasauv2.cpp +++ b/clap_wrapper_builder/clap-wrapper/src/wrapasauv2.cpp @@ -1046,6 +1046,7 @@ void WrapAsAUV2::activateCLAP() if (_plugin) { assert(!_initialized); + _processEverCalled = false; if (!_processAdapter) _processAdapter = std::make_unique(); auto maxSampleFrames = Base::GetMaxFramesPerSlice(); auto minSampleFrames = (maxSampleFrames >= 16) ? 16 : 1; @@ -1066,6 +1067,7 @@ void WrapAsAUV2::deactivateCLAP() if (_plugin) { _initialized = false; + _processEverCalled = false; _processAdapter.reset(); _plugin->stop_processing(); _plugin->deactivate(); @@ -1112,8 +1114,10 @@ OSStatus WrapAsAUV2::Render(AudioUnitRenderActionFlags &inFlags, const AudioTime // with an arbitrary number of output channels is mapped onto a // continuous array of float buffers for the VST process function + ClapWrapper::detail::shared::SpinLockGuard processOrFlushLock(_processOrFlushLock); auto it_is = _plugin->AlwaysAudioThread(); + _processEverCalled = true; _processAdapter->process(data); { @@ -1203,8 +1207,9 @@ void WrapAsAUV2::onIdle() if (!_plugin) return; if (_flushRequested.exchange(false)) { + ClapWrapper::detail::shared::SpinLockGuard processOrFlushLock(_processOrFlushLock); auto guarantee_mainthread = _plugin->AlwaysMainThread(); - if (_processAdapter) + if (_processAdapter && (!_initialized || !_processEverCalled)) { _processAdapter->flush(); } diff --git a/crates/wrac_build/Cargo.toml b/crates/wrac_build/Cargo.toml index 62393036..49c31563 100644 --- a/crates/wrac_build/Cargo.toml +++ b/crates/wrac_build/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/novonotes/wrac-plugin-template" publish = false [dependencies] +wrac_manifest = { workspace = true } zip = { version = "2.4.2", default-features = false, features = ["deflate"] } [lints.rust] diff --git a/crates/wrac_build/src/lib.rs b/crates/wrac_build/src/lib.rs index 736681bd..77acf63d 100644 --- a/crates/wrac_build/src/lib.rs +++ b/crates/wrac_build/src/lib.rs @@ -1,3 +1,10 @@ +//! Build-script helpers for WRAC plugin crates. +//! +//! Product crates call this crate from `build.rs` to turn `wrac-plugin.toml` +//! into generated Rust descriptors and, for release builds, to package the +//! frontend `dist` directory into an embedded bundle. Runtime plugin code should +//! not depend on this crate. + use std::{ env, fs::{self, File}, @@ -7,6 +14,20 @@ use std::{ use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; +pub struct PluginDescriptorCodegenConfig { + pub manifest_path: Option, + pub output_file_name: String, +} + +impl Default for PluginDescriptorCodegenConfig { + fn default() -> Self { + Self { + manifest_path: None, + output_file_name: "wrac_plugin_products.rs".to_string(), + } + } +} + pub struct FrontendBundleConfig<'a> { pub dist_dir: PathBuf, pub output_file_name: &'a str, @@ -14,6 +35,30 @@ pub struct FrontendBundleConfig<'a> { pub missing_dist_build_command: &'a str, } +pub fn generate_plugin_descriptors(config: PluginDescriptorCodegenConfig) -> io::Result { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|error| { + io::Error::new( + io::ErrorKind::NotFound, + format!("CARGO_MANIFEST_DIR is not available: {error}"), + ) + })?); + let manifest_path = config + .manifest_path + .unwrap_or_else(|| manifest_dir.join("wrac-plugin.toml")); + println!("cargo:rerun-if-changed={}", manifest_path.display()); + let metadata = + wrac_manifest::read_dedicated_manifest(&manifest_path).map_err(to_invalid_data)?; + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|error| { + io::Error::new( + io::ErrorKind::NotFound, + format!("OUT_DIR is not available: {error}"), + ) + })?); + let output = out_dir.join(config.output_file_name); + write_plugin_products(&metadata, &output)?; + Ok(output) +} + pub fn build_frontend_bundle(config: FrontendBundleConfig<'_>) -> io::Result> { for path in config.rerun_if_changed { println!("cargo:rerun-if-changed={path}"); @@ -45,6 +90,232 @@ pub fn build_frontend_bundle(config: FrontendBundleConfig<'_>) -> io::Result io::Result<()> { + let mut rust = + String::from("// Generated by wrac_build from wrac-plugin.toml. Do not edit by hand.\n"); + rust.push_str(&format!( + "pub(crate) const COMPANY_NAME: &str = {:?};\n", + metadata.company_name + )); + rust.push_str(&format!( + "pub(crate) const AUV2_MANUFACTURER_CODE: [u8; 4] = {};\n", + four_ascii_array_literal(&metadata.auv2_manufacturer_code)? + )); + if let Some(aax_manufacturer_id) = metadata.aax_manufacturer_id.as_ref() { + rust.push_str(&format!( + "pub(crate) const AAX_MANUFACTURER_ID: u32 = {};\n", + fourcc_literal(aax_manufacturer_id)? + )); + } + rust.push_str(&format!( + "pub(crate) const AAX_PACKAGE_VERSION: u32 = {};\n", + aax_package_version_literal()? + )); + for (index, plugin) in metadata.plugins.iter().enumerate() { + rust.push_str(&format!( + "const PLUGIN_{index}_FEATURES: &[PluginFeature] = &{};\n", + plugin_feature_array_literal(&plugin.clap_features)? + )); + write_aax_stem_config_statics(&mut rust, index, plugin)?; + rust.push_str(&format!( + "unsafe extern \"C\" fn plugin_{index}_aax_get_num_stem_configs() -> u32 {{\n" + )); + rust.push_str(&format!( + " PLUGIN_{index}_AAX_STEM_CONFIGS.len() as u32\n" + )); + rust.push_str("}\n"); + rust.push_str(&format!( + "unsafe extern \"C\" fn plugin_{index}_aax_get_stem_config(index: u32) -> *const AaxStemConfig {{\n" + )); + rust.push_str(&format!( + " PLUGIN_{index}_AAX_STEM_CONFIGS.get(index as usize).map_or(core::ptr::null(), |config| config)\n" + )); + rust.push_str("}\n"); + } + rust.push_str("pub(crate) const PLUGIN_DESCRIPTORS: &[PluginDescriptor] = &[\n"); + for (index, plugin) in metadata.plugins.iter().enumerate() { + rust.push_str(" PluginDescriptor {\n"); + rust.push_str(&format!(" id: {:?},\n", plugin.plugin_id)); + rust.push_str(&format!(" name: {:?},\n", plugin.plugin_name)); + rust.push_str(" vendor: COMPANY_NAME,\n"); + rust.push_str(&format!(" url: {:?},\n", metadata.homepage_url)); + rust.push_str(&format!(" manual_url: {:?},\n", metadata.manual_url)); + rust.push_str(&format!( + " support_url: {:?},\n", + metadata.support_url + )); + rust.push_str(" version: env!(\"CARGO_PKG_VERSION\"),\n"); + rust.push_str(&format!( + " description: {:?},\n", + metadata.description + )); + rust.push_str(&format!(" features: PLUGIN_{index}_FEATURES,\n")); + rust.push_str(" auv2: Some(Auv2Descriptor {\n"); + rust.push_str(" manufacturer_code: AUV2_MANUFACTURER_CODE,\n"); + rust.push_str(" manufacturer_name: COMPANY_NAME,\n"); + rust.push_str(&format!( + " plugin_type: {},\n", + four_ascii_array_literal(&plugin.auv2_type)? + )); + rust.push_str(&format!( + " plugin_subtype: {},\n", + four_ascii_array_literal(&plugin.auv2_subtype)? + )); + rust.push_str(" }),\n"); + rust.push_str(" vst3: Some(Vst3Descriptor {\n"); + rust.push_str(&format!( + " subcategories: {:?},\n", + plugin.vst3_subcategories + )); + rust.push_str(&format!( + " component_id: {:?},\n", + wrac_manifest::vst3_component_id_bytes(&plugin.vst3_component_id) + .map_err(to_invalid_data)? + )); + rust.push_str(" }),\n"); + if plugin.aax_categories.is_some() { + rust.push_str(" aax: Some(AaxDescriptor {\n"); + rust.push_str(&format!( + " package_name: {:?},\n", + metadata.bundle_name + )); + rust.push_str(" package_version: AAX_PACKAGE_VERSION,\n"); + rust.push_str(&format!( + " categories: {},\n", + aax_categories_literal(plugin.aax_categories.as_deref().unwrap_or_default())? + )); + rust.push_str(" manufacturer_id: AAX_MANUFACTURER_ID,\n"); + rust.push_str(&format!( + " product_id: {},\n", + fourcc_literal( + plugin + .aax_product_id + .as_deref() + .ok_or_else(|| io::Error::new( + io::ErrorKind::InvalidData, + "missing AAX product ID" + ))? + )? + )); + rust.push_str(&format!( + " get_num_stem_configs: plugin_{index}_aax_get_num_stem_configs,\n" + )); + rust.push_str(&format!( + " get_stem_config: plugin_{index}_aax_get_stem_config,\n" + )); + rust.push_str(" }),\n"); + } else { + rust.push_str(" aax: None,\n"); + } + rust.push_str(" },\n"); + } + rust.push_str("];\n"); + fs::write(output, rust) +} + +fn four_ascii_array_literal(value: &str) -> io::Result { + let bytes = wrac_manifest::four_ascii_bytes(value).map_err(to_invalid_data)?; + Ok(format!( + "[{}, {}, {}, {}]", + bytes[0], bytes[1], bytes[2], bytes[3] + )) +} + +fn fourcc_literal(value: &str) -> io::Result { + Ok(format!( + "0x{:08X}", + wrac_manifest::fourcc(value).map_err(to_invalid_data)? + )) +} + +fn plugin_feature_array_literal(features: &[String]) -> io::Result { + let items = features + .iter() + .map(|feature| { + wrac_manifest::clap_feature_variant(feature) + .map(|variant| format!("PluginFeature::{variant}")) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported CLAP feature value: {feature}"), + ) + }) + }) + .collect::>>()?; + Ok(format!("[{}]", items.join(", "))) +} + +fn aax_categories_literal(categories: &[String]) -> io::Result { + let values = categories + .iter() + .map(|category| { + wrac_manifest::aax_category_bits(category) + .map(|value| format!("0x{value:08X}")) + .map_err(to_invalid_data) + }) + .collect::>>()?; + Ok(values.join(" | ")) +} + +fn write_aax_stem_config_statics( + rust: &mut String, + plugin_index: usize, + plugin: &wrac_manifest::PluginProduct, +) -> io::Result<()> { + let mut items = Vec::new(); + for (stem_index, stem_config) in plugin.aax_stem_configs.iter().enumerate() { + rust.push_str(&format!( + "static PLUGIN_{plugin_index}_AAX_STEM_{stem_index}_NAME: &[u8] = &{:?};\n", + nul_terminated(&stem_config.name) + )); + items.push(format!( + "AaxStemConfig {{ name: PLUGIN_{plugin_index}_AAX_STEM_{stem_index}_NAME.as_ptr().cast(), format_in: {}, format_out: {}, plugin_id: {} }}", + wrac_manifest::aax_stem_format_value(&stem_config.input).map_err(to_invalid_data)?, + wrac_manifest::aax_stem_format_value(&stem_config.output).map_err(to_invalid_data)?, + fourcc_literal(&stem_config.plugin_id)? + )); + } + rust.push_str(&format!( + "static PLUGIN_{plugin_index}_AAX_STEM_CONFIGS: &[AaxStemConfig] = &[{}];\n", + items.join(", ") + )); + Ok(()) +} + +fn nul_terminated(value: &str) -> Vec { + let mut bytes = value.as_bytes().to_vec(); + bytes.push(0); + bytes +} + +fn aax_package_version_literal() -> io::Result { + let major = aax_version_component("CARGO_PKG_VERSION_MAJOR")?; + let minor = aax_version_component("CARGO_PKG_VERSION_MINOR")?; + let patch = aax_version_component("CARGO_PKG_VERSION_PATCH")?; + Ok(format!("0x{major:02X}{minor:02X}{patch:02X}00")) +} + +fn aax_version_component(name: &str) -> io::Result { + let value = env::var(name) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))? + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if value > 0xFF { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{name} must fit in one byte for AAX package_version: {value}"), + )); + } + Ok(value) +} + +fn to_invalid_data(error: Box) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error.to_string()) +} + fn create_zip(src_dir: &Path, out_zip: &Path) -> io::Result<()> { let file = File::create(out_zip)?; let mut zip = ZipWriter::new(file); @@ -84,3 +355,100 @@ fn add_directory_contents( Ok(()) } + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn plugin_descriptor_codegen_preserves_host_visible_ids() { + let _env_lock = ENV_LOCK.lock().expect("env lock must not be poisoned"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).unwrap(); + let manifest_path = temp_dir.join("wrac-plugin.toml"); + let output_path = temp_dir.join("wrac_plugin_products.rs"); + fs::write(&manifest_path, FIXTURE_MANIFEST).unwrap(); + + unsafe { + std::env::set_var("CARGO_PKG_VERSION_MAJOR", "1"); + std::env::set_var("CARGO_PKG_VERSION_MINOR", "2"); + std::env::set_var("CARGO_PKG_VERSION_PATCH", "3"); + } + + let manifest = wrac_manifest::read_dedicated_manifest(&manifest_path).unwrap(); + super::write_plugin_products(&manifest, &output_path).unwrap(); + let generated = fs::read_to_string(&output_path).unwrap(); + + assert!( + generated + .contains("pub(crate) const AUV2_MANUFACTURER_CODE: [u8; 4] = [89, 114, 67, 111];") + ); + assert!(generated.contains("pub(crate) const AAX_MANUFACTURER_ID: u32 = 0x5972436F;")); + assert!(generated.contains("pub(crate) const AAX_PACKAGE_VERSION: u32 = 0x01020300;")); + assert!(generated.contains("component_id: [202, 17, 32, 130, 236, 55, 239, 92, 146, 215, 236, 126, 103, 32, 113, 149],")); + assert!(generated.contains("product_id: 0x5774476E,")); + assert!(generated.contains("plugin_id: 0x5774474D")); + assert!(generated.contains("plugin_id: 0x57744753")); + + fs::remove_dir_all(temp_dir).unwrap(); + } + + fn unique_temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("wrac-build-test-{}-{nanos}", std::process::id())) + } + + const FIXTURE_MANIFEST: &str = r#" +schema_version = 1 + +[package] +version_source = "cargo" + +[bundle] +company_name = "Your Company" +auv2_manufacturer_code = "YrCo" +aax_manufacturer_id = "YrCo" +bundle_name = "WRAC Gain" +bundle_identifier = "com.your-company.wrac-gain" +homepage_url = "https://example.com/wrac-gain" +manual_url = "https://example.com/wrac-gain/manual" +support_url = "https://example.com/support" +description = "Simple gain plugin" +copyright = "Copyright 2026 Your Company" +supported_formats = ["clap", "vst3", "au", "aax"] + +[[plugins]] +plugin_id = "com.your-company.wrac-gain" +plugin_name = "WRAC Gain" +clap_features = ["audio-effect", "utility", "stereo"] +vst3_subcategories = "Fx|Tools" +vst3_component_id = "822011ca-37ec-5cef-92d7-ec7e67207195" +standalone_name = "WRAC Gain Standalone" +auv2_type = "aufx" +auv2_subtype = "WtGn" +aax_categories = ["effect"] +aax_product_id = "WtGn" + +[[plugins.aax_stem_configs]] +name = "Mono" +input = "mono" +output = "mono" +plugin_id = "WtGM" + +[[plugins.aax_stem_configs]] +name = "Stereo" +input = "stereo" +output = "stereo" +plugin_id = "WtGS" +"#; +} diff --git a/crates/wrac_clap_adapter/Cargo.toml b/crates/wrac_clap_adapter/Cargo.toml index b942af24..dfc9ea7e 100644 --- a/crates/wrac_clap_adapter/Cargo.toml +++ b/crates/wrac_clap_adapter/Cargo.toml @@ -15,5 +15,9 @@ parking_lot = "0.12" wrac_host_context = { workspace = true } wrac_log = { workspace = true } +[features] +default = [] +raw-clap-forwarding = [] + [lints.rust] unreachable_pub = "deny" diff --git a/crates/wrac_clap_adapter/README.md b/crates/wrac_clap_adapter/README.md index 51457c8e..8279ee9b 100644 --- a/crates/wrac_clap_adapter/README.md +++ b/crates/wrac_clap_adapter/README.md @@ -25,11 +25,13 @@ This crate, on the other hand, also targets VST3/AU/AAX hosts via `clap-wrapper` - `PluginEntry`: DSO-level lifecycle and typed factory provider - `PluginFactory`: CLAP `clap.plugin-factory` -- `PluginCore`: instance lifecycle and declaration of supported extensions +- `PluginInstance`: instance lifecycle and declaration of supported extensions +- `ActiveProcessor`: active audio processing and active `params.flush` +- `InactiveProcessor`: inactive `params.flush` - `PluginAudioPortsExtension`: CLAP `audio-ports` - `PluginConfigurableAudioPortsExtension`: CLAP `configurable-audio-ports` - `PluginNotePortsExtension`: CLAP `note-ports` -- `PluginParamsExtension`: CLAP `params` +- `PluginParamsQuery`: CLAP params query surface - `PluginStateExtension`: CLAP `state` - `PluginGuiExtension`: CLAP `gui` - `PluginRenderExtension`: CLAP `render` diff --git a/crates/wrac_clap_adapter/src/abi.rs b/crates/wrac_clap_adapter/src/abi.rs index d3dbd616..1233a6e1 100644 --- a/crates/wrac_clap_adapter/src/abi.rs +++ b/crates/wrac_clap_adapter/src/abi.rs @@ -1,4 +1,4 @@ -//! Module that binds the CLAP ABI to `PluginCore` instances. +//! Module that binds the CLAP ABI to `PluginInstance` instances. //! //! The public API is surfaced through re-exports in `lib.rs` and `export_clap_entry!`. //! This module is responsible only for C ABI callbacks and owning the adapter state. @@ -7,7 +7,8 @@ use std::cell::UnsafeCell; use std::ffi::{CStr, c_char, c_void}; use std::ptr; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::thread::ThreadId; use clap_sys::ext::audio_ports::CLAP_EXT_AUDIO_PORTS; use clap_sys::ext::configurable_audio_ports::{ @@ -57,14 +58,15 @@ use crate::factory::{ auv2_factory_ptr, auv2_factory_state, clap_factory_state, factory_ptr, main_thread_hook_ptr, main_thread_hook_state, vst3_factory_ptr, vst3_factory_state, }; -use crate::host_gui::HostGuiResizeRequest; -use crate::host_state::HostStateDirtyNotification; -use crate::params::ParameterEditQueue; +use crate::host_gui::HostGuiProxy; +use crate::host_state::HostStateProxy; +use crate::params::HostParamsProxy; use crate::{ - ActivateContext, PluginAudioPortsExtension, PluginConfigurableAudioPortsExtension, PluginCore, - PluginCoreContext, PluginGuiExtension, PluginLatencyExtension, PluginNotePortsExtension, - PluginParamsExtension, PluginRenderExtension, PluginStateExtension, PluginTailExtension, - ProcessContext, ProcessStatus, Processor, TransportEvent, + ActivateContext, ActiveProcessor, InactiveProcessor, PluginAudioPortsExtension, + PluginConfigurableAudioPortsExtension, PluginGuiExtension, PluginInstance, + PluginInstanceContext, PluginLatencyExtension, PluginNotePortsExtension, PluginParamsQuery, + PluginRenderExtension, PluginStateExtension, PluginTailExtension, ProcessContext, + ProcessStatus, TransportEvent, }; // clap-wrapper reads this draft factory when generating AUv2 metadata. Without a @@ -80,6 +82,21 @@ const CLAP_PLUGIN_AS_VST3: &CStr = c"clap.plugin-info-as-vst3/0"; const CLAP_PLUGIN_FACTORY_INFO_AAX: &CStr = c"clap.plugin-factory-info-as-aax/1"; const WRAC_PLUGIN_MAIN_THREAD_HOOK: &CStr = c"com.novonotes.wrac.plugin-main-thread-hook/0"; +pub(crate) struct RtDepthGuard<'a>(&'a AtomicU32); + +impl<'a> RtDepthGuard<'a> { + pub(crate) fn enter(depth: &'a AtomicU32) -> Self { + depth.fetch_add(1, Ordering::Relaxed); + Self(depth) + } +} + +impl Drop for RtDepthGuard<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + /// Synchronization boundary between a CLAP instance and the Rust core. /// /// Key design: separate the "lifecycle lock" from "capabilities read directly by @@ -88,18 +105,18 @@ const WRAC_PLUGIN_MAIN_THREAD_HOOK: &CStr = c"com.novonotes.wrac.plugin-main-thr /// instance creation. Without this separation, a wrapper that re-enters a query during /// `activate()` would fail to acquire the core lock and return "no parameters" or "state /// save failed" to the host — no crash, but project data and routing can be corrupted. -pub(crate) struct PluginInstance { +pub(crate) struct PluginInstanceState { plugin: clap_plugin, registration: &'static EntryRegistration, // Owner of the processor lifecycle; only activate/deactivate take this lock. - core: Mutex>, + core: Mutex>, // Capability presence is frozen at instance creation. Coupling it to runtime state // would make extensions appear to disappear transiently during queries. capabilities: PluginCapabilities, audio_ports: Option>, configurable_audio_ports: Option>, note_ports: Option>, - parameters: Option>, + parameters: Arc, state: Option>, gui: Option>, render: Option>, @@ -109,13 +126,22 @@ pub(crate) struct PluginInstance { // Re-entry guard for GUI mutation callbacks. Fails immediately on re-entry to avoid // deadlock (GUI query callbacks do not go through this guard). gui_callback_busy: Mutex<()>, - parameter_edits: Arc, + // Defensive owner check for CLAP GUI [main-thread] callbacks. The adapter cannot + // identify the OS UI thread portably here, but it can reject lifecycle callbacks + // that move between host threads within one GUI session. + gui_lifecycle_thread: Mutex>, + host_params: Arc, // To preserve soundness even when a wrapper violates thread/lifecycle annotations, // the RT path never takes a lock — only a callback that wins the atomic guard - // constructs a `&mut` to `Processor`. - processor: UnsafeCell>>, + // constructs a `&mut` to the active or inactive processor. + inactive_processor: UnsafeCell>>, + processor: UnsafeCell>>, processor_busy: AtomicBool, + processor_active: AtomicBool, lifecycle_busy: AtomicBool, + rt_process_depth: AtomicU32, + rt_flush_depth: AtomicU32, + rt_processor_contention: AtomicBool, } #[derive(Debug, Clone, Copy)] @@ -123,7 +149,6 @@ pub(crate) struct PluginCapabilities { audio_ports: bool, configurable_audio_ports: bool, note_ports: bool, - parameters: bool, state: bool, gui: bool, render: bool, @@ -133,10 +158,10 @@ pub(crate) struct PluginCapabilities { // Safety: CLAP shares the same opaque plugin pointer across callbacks. Adapter state is // shared via locks and atomics, so Rust aliasing rules are never violated even when the // host's thread annotations or callback ordering breaks down. -unsafe impl Send for PluginInstance {} -unsafe impl Sync for PluginInstance {} +unsafe impl Send for PluginInstanceState {} +unsafe impl Sync for PluginInstanceState {} -impl PluginInstance { +impl PluginInstanceState { fn new( registration: &'static EntryRegistration, descriptor_index: usize, @@ -145,16 +170,16 @@ impl PluginInstance { clap_host_name: Option, host_context: HostContext, ) -> Option> { - let parameter_edits = Arc::new(ParameterEditQueue::new(host)); + let host_params = Arc::new(HostParamsProxy::new(host)); // Pass as a safe proxy so product GUI code can hold it without knowing about // host pointers or CLAP event lifetimes. - let context = PluginCoreContext { - host_parameter_edit_notifier: parameter_edits.clone(), - host_state_dirty_notifier: Arc::new(HostStateDirtyNotification::new(host)), - host_gui_resize_requester: Arc::new(HostGuiResizeRequest::new(host)), + let context = PluginInstanceContext { + host_params: host_params.clone(), + host_state: Arc::new(HostStateProxy::new(host)), + host_gui: Arc::new(HostGuiProxy::new(host)), host_context: host_context.clone(), }; - let core = registration + let mut core = registration .entry .plugin_factory()? .create_plugin(plugin_id, context)?; @@ -174,6 +199,13 @@ impl PluginInstance { let configurable_audio_ports = core.configurable_audio_ports(); let note_ports = core.note_ports(); let parameters = core.params(); + let inactive_processor = match core.initialize_processor() { + Ok(processor) => processor, + Err(error) => { + log::warn!("factory.create_plugin: inactive processor creation failed: {error}"); + return None; + } + }; let state = core.state(); let gui = core.gui(); let render = core.render(); @@ -183,7 +215,6 @@ impl PluginInstance { audio_ports: audio_ports.is_some(), configurable_audio_ports: configurable_audio_ports.is_some(), note_ports: note_ports.is_some(), - parameters: parameters.is_some(), state: state.is_some(), gui: gui.is_some(), render: render.is_some(), @@ -220,10 +251,16 @@ impl PluginInstance { latency, host_context, gui_callback_busy: Mutex::new(()), - parameter_edits, + gui_lifecycle_thread: Mutex::new(None), + host_params, + inactive_processor: UnsafeCell::new(Some(inactive_processor)), processor: UnsafeCell::new(None), processor_busy: AtomicBool::new(false), + processor_active: AtomicBool::new(false), lifecycle_busy: AtomicBool::new(false), + rt_process_depth: AtomicU32::new(0), + rt_flush_depth: AtomicU32::new(0), + rt_processor_contention: AtomicBool::new(false), })) } @@ -240,32 +277,105 @@ impl PluginInstance { fn with_processor_mut( &self, - f: impl FnOnce(Option<&mut Box>) -> R, + f: impl FnOnce(Option<&mut Box>) -> R, ) -> Option { if self .processor_busy .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { + self.rt_processor_contention.store(true, Ordering::Release); return None; } - struct ProcessorBusyGuard<'a>(&'a AtomicBool); + struct ProcessorBusyGuard<'a> { + busy: &'a AtomicBool, + contention: &'a AtomicBool, + process_depth: &'a AtomicU32, + flush_depth: &'a AtomicU32, + } impl Drop for ProcessorBusyGuard<'_> { fn drop(&mut self) { - self.0.store(false, Ordering::Release); + self.busy.store(false, Ordering::Release); + if self.contention.swap(false, Ordering::AcqRel) { + let process_depth = self.process_depth.load(Ordering::Relaxed); + let flush_depth = self.flush_depth.load(Ordering::Relaxed); + wrac_log::rtdebug!( + "processor.busy clear pd={} fd={}", + process_depth, + flush_depth + ); + } } } - let _guard = ProcessorBusyGuard(&self.processor_busy); + let _guard = ProcessorBusyGuard { + busy: &self.processor_busy, + contention: &self.rt_processor_contention, + process_depth: &self.rt_process_depth, + flush_depth: &self.rt_flush_depth, + }; Some(f(unsafe { &mut *self.processor.get() }.as_mut())) } - fn try_take_processor(&self) -> Option>> { - self.with_processor_mut(|_| unsafe { &mut *self.processor.get() }.take()) + fn try_take_processor(&self) -> Option>> { + self.with_processor_mut(|_| { + let processor = unsafe { &mut *self.processor.get() }.take(); + if processor.is_some() { + self.processor_active.store(false, Ordering::Release); + } + processor + }) } - fn put_processor_blocking(&self, processor: Box) { + fn try_take_inactive_processor(&self) -> Option>> { + self.with_processor_mut(|_| unsafe { &mut *self.inactive_processor.get() }.take()) + } + + fn with_inactive_processor_mut( + &self, + f: impl FnOnce(Option<&mut Box>) -> R, + ) -> Option { + if self + .processor_busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + self.rt_processor_contention.store(true, Ordering::Release); + return None; + } + + struct ProcessorBusyGuard<'a> { + busy: &'a AtomicBool, + contention: &'a AtomicBool, + process_depth: &'a AtomicU32, + flush_depth: &'a AtomicU32, + } + impl Drop for ProcessorBusyGuard<'_> { + fn drop(&mut self) { + self.busy.store(false, Ordering::Release); + if self.contention.swap(false, Ordering::AcqRel) { + let process_depth = self.process_depth.load(Ordering::Relaxed); + let flush_depth = self.flush_depth.load(Ordering::Relaxed); + wrac_log::rtdebug!( + "processor.busy clear pd={} fd={}", + process_depth, + flush_depth + ); + } + } + } + + let _guard = ProcessorBusyGuard { + busy: &self.processor_busy, + contention: &self.rt_processor_contention, + process_depth: &self.rt_process_depth, + flush_depth: &self.rt_flush_depth, + }; + Some(f(unsafe { &mut *self.inactive_processor.get() }.as_mut())) + } + + fn put_processor_blocking(&self, processor: Box) { let mut processor = Some(processor); loop { if self @@ -282,6 +392,7 @@ impl PluginInstance { let _guard = ProcessorBusyGuard(&self.processor_busy); let storage = unsafe { &mut *self.processor.get() }; let old = storage.replace(processor.take().expect("stored once")); + self.processor_active.store(true, Ordering::Release); drop(old); return; } @@ -291,14 +402,51 @@ impl PluginInstance { } } - fn take_processor_blocking(&self) -> Option> { + fn put_inactive_processor_blocking(&self, processor: Box) { + let mut processor = Some(processor); + loop { + if self + .processor_busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + struct ProcessorBusyGuard<'a>(&'a AtomicBool); + impl Drop for ProcessorBusyGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } + } + let _guard = ProcessorBusyGuard(&self.processor_busy); + let storage = unsafe { &mut *self.inactive_processor.get() }; + let old = storage.replace(processor.take().expect("stored once")); + drop(old); + return; + } + std::thread::yield_now(); + } + } + + pub(crate) fn is_processor_active(&self) -> bool { + self.processor_active.load(Ordering::Acquire) + } + + fn take_processor_blocking(&self) -> Option> { loop { if let Some(processor) = self.try_take_processor() { return processor; } // deactivate/destroy are non-realtime lifecycle callbacks. Waiting here // ensures that even a wrapper which races lifecycle against audio never - // frees the instance while process() holds a temporary Processor borrow. + // frees the instance while process() holds a temporary ActiveProcessor borrow. + std::thread::yield_now(); + } + } + + fn take_inactive_processor_blocking(&self) -> Option> { + loop { + if let Some(processor) = self.try_take_inactive_processor() { + return processor; + } std::thread::yield_now(); } } @@ -326,6 +474,28 @@ impl PluginInstance { std::thread::yield_now(); } } + + pub(crate) fn enter_gui_lifecycle_thread(&self, callback_name: &'static str) -> bool { + let current = std::thread::current().id(); + let mut owner = self.gui_lifecycle_thread.lock(); + match *owner { + Some(expected) if expected != current => { + log::error!( + "rejecting CLAP GUI main-thread callback from a different thread: callback={callback_name} expected={expected:?} current={current:?}" + ); + false + } + Some(_) => true, + None => { + *owner = Some(current); + true + } + } + } + + pub(crate) fn clear_gui_lifecycle_thread(&self) { + *self.gui_lifecycle_thread.lock() = None; + } } unsafe fn clap_host_name(host: *const clap_host) -> Option { @@ -612,7 +782,7 @@ pub(crate) unsafe extern "C" fn factory_create_plugin( registration.entry.attach_main_thread(); } - let Some(mut instance) = PluginInstance::new( + let Some(mut instance) = PluginInstanceState::new( registration, descriptor_index, plugin_id, @@ -626,7 +796,7 @@ pub(crate) unsafe extern "C" fn factory_create_plugin( log::warn!("factory.create_plugin: product factory returned no plugin core"); return ptr::null(); }; - let instance_ptr = (&mut *instance) as *mut PluginInstance; + let instance_ptr = (&mut *instance) as *mut PluginInstanceState; instance.plugin.plugin_data = instance_ptr.cast(); let plugin_ptr = &instance.plugin as *const clap_plugin; let _ = Box::into_raw(instance); @@ -636,7 +806,7 @@ pub(crate) unsafe extern "C" fn factory_create_plugin( unsafe extern "C" fn plugin_init(plugin: *const clap_plugin) -> bool { ffi_bool(|| { - let initialized = unsafe { PluginInstance::from_plugin(plugin).is_some() }; + let initialized = unsafe { PluginInstanceState::from_plugin(plugin).is_some() }; if !initialized { log::warn!("plugin.init: missing plugin instance"); } @@ -646,7 +816,7 @@ unsafe extern "C" fn plugin_init(plugin: *const clap_plugin) -> bool { unsafe extern "C" fn plugin_destroy(plugin: *const clap_plugin) { ffi_unit(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("plugin.destroy: missing plugin instance"); return; }; @@ -656,7 +826,10 @@ unsafe extern "C" fn plugin_destroy(plugin: *const clap_plugin) { if let Some(gui) = &instance.gui { if let Some(_gui_callback) = instance.gui_callback_busy.try_lock() { - gui.destroy(); + if instance.enter_gui_lifecycle_thread("destroy") { + gui.main_thread().destroy(); + instance.clear_gui_lifecycle_thread(); + } } else { log::error!( "skipping GUI destroy during plugin destruction because another GUI callback is active" @@ -665,13 +838,16 @@ unsafe extern "C" fn plugin_destroy(plugin: *const clap_plugin) { } if let Some(processor) = instance.take_processor_blocking() { - if let Err(error) = instance.core.lock().deactivate(processor) { - log::warn!("plugin.destroy: plugin deactivate failed: {error}"); + match instance.core.lock().deactivate(processor) { + Ok(inactive) => drop(inactive), + Err(error) => log::warn!("plugin.destroy: plugin deactivate failed: {error}"), } + } else if let Some(inactive) = instance.take_inactive_processor_blocking() { + drop(inactive); } drop(guard); - let data = unsafe { (*plugin).plugin_data } as *mut PluginInstance; + let data = unsafe { (*plugin).plugin_data } as *mut PluginInstanceState; unsafe { drop(Box::from_raw(data)); } @@ -688,7 +864,7 @@ unsafe extern "C" fn plugin_activate( max_frames_count: u32, ) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("plugin.activate: missing plugin instance"); return false; }; @@ -701,14 +877,29 @@ unsafe extern "C" fn plugin_activate( return false; } - let processor = match instance.core.lock().activate(ActivateContext { - sample_rate, - min_frames_count, - max_frames_count, - }) { + let Some(inactive_processor) = instance.take_inactive_processor_blocking() else { + log::warn!("plugin.activate: inactive processor is unavailable"); + return false; + }; + + let mut core = instance.core.lock(); + let processor = match core.activate( + ActivateContext { + sample_rate, + min_frames_count, + max_frames_count, + }, + inactive_processor, + ) { Ok(processor) => processor, Err(error) => { log::warn!("plugin.activate: plugin activate failed: {error}"); + match core.initialize_processor() { + Ok(inactive) => instance.put_inactive_processor_blocking(inactive), + Err(error) => log::warn!( + "plugin.activate: inactive processor recreation failed after activation error: {error}" + ), + } return false; } }; @@ -720,17 +911,20 @@ unsafe extern "C" fn plugin_activate( unsafe extern "C" fn plugin_deactivate(plugin: *const clap_plugin) { ffi_unit(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("plugin.deactivate: missing plugin instance"); return; }; - // deactivate is a cleanup callback that must reclaim the Processor before + // deactivate is a cleanup callback that must reclaim the ActiveProcessor before // returning completion to the host. Even if a wrapper runs lifecycle callbacks // concurrently, wait here to avoid missing the teardown. let _guard = instance.enter_lifecycle_blocking(); if let Some(processor) = instance.take_processor_blocking() { - if let Err(error) = instance.core.lock().deactivate(processor) { - log::warn!("plugin.deactivate: plugin deactivate failed: {error}"); + match instance.core.lock().deactivate(processor) { + Ok(inactive) => instance.put_inactive_processor_blocking(inactive), + Err(error) => { + log::warn!("plugin.deactivate: plugin deactivate failed: {error}"); + } } } }); @@ -738,14 +932,14 @@ unsafe extern "C" fn plugin_deactivate(plugin: *const clap_plugin) { unsafe extern "C" fn plugin_start_processing(plugin: *const clap_plugin) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!("plugin.start_processing: missing plugin instance"); return false; }; // In wrapper formats, `start_processing` / `stop_processing` may not be // synchronized with the VST3/AU activate. A dedicated flag would become a // failure point that stops audio at the host's discretion, so whether processing - // is possible is determined solely by the presence of a Processor. + // is possible is determined solely by the presence of an ActiveProcessor. let can_process = instance.has_processor_or_busy(); if !can_process { wrac_log::rtwarn!("plugin.start_processing: no processor is available"); @@ -760,7 +954,7 @@ unsafe extern "C" fn plugin_stop_processing(_plugin: *const clap_plugin) { unsafe extern "C" fn plugin_reset(plugin: *const clap_plugin) { ffi_unit(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!("plugin.reset: missing plugin instance"); return; }; @@ -782,7 +976,7 @@ unsafe extern "C" fn plugin_process( process: *const clap_process, ) -> clap_process_status { ffi_status(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rterror!("plugin.process: missing plugin instance"); return CLAP_PROCESS_ERROR; }; @@ -791,12 +985,9 @@ unsafe extern "C" fn plugin_process( wrac_log::rtwarn!("plugin.process: null process pointer"); return CLAP_PROCESS_SLEEP; } + let _process_depth_guard = RtDepthGuard::enter(&instance.rt_process_depth); let process = unsafe { &*process }; - let mut events = - unsafe { crate::ProcessEvents::from_raw(process.in_events, process.out_events) }; - instance - .parameter_edits - .drain_output_parameter_events(&mut events.output); + let events = unsafe { crate::EventLists::from_raw(process.in_events, process.out_events) }; let audio = match unsafe { audio_buffers(process) } { Ok(audio) => audio, Err(error) => { @@ -805,8 +996,8 @@ unsafe extern "C" fn plugin_process( } }; - // The audio callback never takes the `PluginCore` lock. Whether processing is - // possible is determined by the actual presence of a `Processor`, not a separate + // The audio callback never takes the `PluginInstance` lock. Whether processing is + // possible is determined by the actual presence of a `ActiveProcessor`, not a separate // flag. If a wrapper violates lifecycle ordering, the RT path falls through to // sleep/error without waiting. let Some(result) = instance.with_processor_mut(|processor| { @@ -820,6 +1011,8 @@ unsafe extern "C" fn plugin_process( audio, events, transport: unsafe { process.transport.as_ref() }.map(TransportEvent::from_raw), + #[cfg(feature = "raw-clap-forwarding")] + raw: unsafe { crate::RawProcessContext::from_raw(process) }, }) { Ok(ProcessStatus::Continue) => CLAP_PROCESS_CONTINUE, Ok(ProcessStatus::ContinueIfNotQuiet) => CLAP_PROCESS_CONTINUE_IF_NOT_QUIET, @@ -831,6 +1024,8 @@ unsafe extern "C" fn plugin_process( } } }) else { + let flush_depth = instance.rt_flush_depth.load(Ordering::Relaxed); + wrac_log::rtdebug!("plugin.process busy fd={}", flush_depth); wrac_log::rtwarn!("plugin.process: processor is busy"); return CLAP_PROCESS_SLEEP; }; @@ -848,7 +1043,7 @@ unsafe extern "C" fn plugin_get_extension( return ptr::null(); } let id = unsafe { CStr::from_ptr(id) }; - let Some(instance) = (unsafe { PluginInstance::from_plugin(_plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(_plugin) }) else { wrac_log::rtwarn!("plugin.get_extension: missing plugin instance"); return ptr::null(); }; @@ -861,7 +1056,7 @@ unsafe extern "C" fn plugin_get_extension( &configurable_audio_ports::CONFIGURABLE_AUDIO_PORTS as *const _ as *const c_void } else if id == CLAP_EXT_NOTE_PORTS && instance.capabilities.note_ports { ¬e_ports::NOTE_PORTS as *const _ as *const c_void - } else if id == CLAP_EXT_PARAMS && instance.capabilities.parameters { + } else if id == CLAP_EXT_PARAMS { ¶ms_extension::PARAMS as *const _ as *const c_void } else if id == CLAP_EXT_STATE && instance.capabilities.state { &state_extension::STATE as *const _ as *const c_void diff --git a/crates/wrac_clap_adapter/src/abi/audio_ports.rs b/crates/wrac_clap_adapter/src/abi/audio_ports.rs index fc8bd2e5..6f3eb469 100644 --- a/crates/wrac_clap_adapter/src/abi/audio_ports.rs +++ b/crates/wrac_clap_adapter/src/abi/audio_ports.rs @@ -8,7 +8,7 @@ use clap_sys::ext::audio_ports::{ }; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::{ffi_bool, ffi_u32, fill_c_char_array}; use crate::{AudioPortFlags, AudioPortType}; @@ -19,7 +19,7 @@ pub(super) static AUDIO_PORTS: clap_plugin_audio_ports = clap_plugin_audio_ports unsafe extern "C" fn audio_ports_count(plugin: *const clap_plugin, is_input: bool) -> u32 { ffi_u32(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!("audio_ports.count: missing plugin instance is_input={is_input}"); return 0; }; @@ -44,7 +44,7 @@ unsafe extern "C" fn audio_ports_get( ); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!( "audio_ports.get: missing plugin instance index={index} is_input={is_input}" ); diff --git a/crates/wrac_clap_adapter/src/abi/configurable_audio_ports.rs b/crates/wrac_clap_adapter/src/abi/configurable_audio_ports.rs index d9841eb4..2cbd3174 100644 --- a/crates/wrac_clap_adapter/src/abi/configurable_audio_ports.rs +++ b/crates/wrac_clap_adapter/src/abi/configurable_audio_ports.rs @@ -8,7 +8,7 @@ use clap_sys::ext::configurable_audio_ports::{ }; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::ffi_bool; use crate::{AudioPortConfigRequest, AudioPortType}; @@ -24,14 +24,14 @@ unsafe extern "C" fn configurable_audio_ports_can_apply_configuration( request_count: u32, ) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("configurable_audio_ports.can_apply: missing plugin instance"); return false; }; - // Layout changes invalidate the Processor's buffer view contract, so reject while active. - // Activity is determined solely by whether a Processor exists and whether lifecycle is busy + // Layout changes invalidate the ActiveProcessor's buffer view contract, so reject while active. + // Activity is determined solely by whether an ActiveProcessor exists and whether lifecycle is busy // (wrappers may omit or delay start/stop_processing, so they are not the source of truth). - // This lets the plugin assume the layout is stable for the lifetime of any Processor. + // This lets the plugin assume the layout is stable for the lifetime of any ActiveProcessor. if instance.has_processor_or_busy() || instance.lifecycle_busy.load(Ordering::Acquire) { log::warn!( "configurable_audio_ports.can_apply: rejected while processor/lifecycle is busy" @@ -65,7 +65,7 @@ unsafe extern "C" fn configurable_audio_ports_apply_configuration( request_count: u32, ) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("configurable_audio_ports.apply: missing plugin instance"); return false; }; diff --git a/crates/wrac_clap_adapter/src/abi/gui_extension.rs b/crates/wrac_clap_adapter/src/abi/gui_extension.rs index 6441e8fd..e1a00cd9 100644 --- a/crates/wrac_clap_adapter/src/abi/gui_extension.rs +++ b/crates/wrac_clap_adapter/src/abi/gui_extension.rs @@ -9,7 +9,7 @@ use clap_sys::ext::gui::{ use clap_sys::plugin::clap_plugin; use parking_lot::MutexGuard; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::{ffi_bool, ffi_unit}; use crate::{GuiApi, GuiConfig, GuiSize, HostWindow, PluginGuiExtension}; @@ -47,7 +47,7 @@ unsafe extern "C" fn gui_is_api_supported( log::warn!("gui.is_api_supported: invalid API pointer"); return false; }; - gui.is_api_supported(api, is_floating) + gui.query().is_api_supported(api, is_floating) }) } @@ -68,7 +68,7 @@ unsafe extern "C" fn gui_get_preferred_api( let Some(gui) = (unsafe { plugin_gui_query(plugin) }) else { return false; }; - let Some(configuration) = gui.preferred_api() else { + let Some(configuration) = gui.query().preferred_api() else { log::debug!("gui.get_preferred_api: plugin has no preferred API"); return false; }; @@ -94,7 +94,7 @@ unsafe extern "C" fn gui_create( log::warn!("gui.create: invalid API pointer"); return false; }; - match gui.create(GuiConfig { api, is_floating }) { + match gui.main_thread().create(GuiConfig { api, is_floating }) { Ok(()) => true, Err(error) => { log::warn!("gui.create: plugin create failed: {error}"); @@ -109,7 +109,8 @@ unsafe extern "C" fn gui_destroy(plugin: *const clap_plugin) { let Some(gui) = (unsafe { plugin_gui_mutation(plugin, "destroy") }) else { return; }; - gui.destroy(); + gui.main_thread().destroy(); + gui.clear_lifecycle_thread(); }); } @@ -118,7 +119,7 @@ unsafe extern "C" fn gui_set_scale(plugin: *const clap_plugin, scale: f64) -> bo let Some(gui) = (unsafe { plugin_gui_mutation(plugin, "set_scale") }) else { return false; }; - match gui.set_scale(scale) { + match gui.main_thread().set_scale(scale) { Ok(()) => true, Err(error) => { log::warn!("gui.set_scale: plugin set_scale failed: {error}"); @@ -145,7 +146,7 @@ unsafe extern "C" fn gui_get_size( let Some(gui) = (unsafe { plugin_gui_query(plugin) }) else { return false; }; - let size = match gui.get_size() { + let size = match gui.query().get_size() { Ok(size) => size, Err(error) => { log::warn!("gui.get_size: plugin get_size failed: {error}"); @@ -165,7 +166,7 @@ unsafe extern "C" fn gui_can_resize(plugin: *const clap_plugin) -> bool { let Some(gui) = (unsafe { plugin_gui_query(plugin) }) else { return false; }; - gui.can_resize() + gui.query().can_resize() }) } @@ -181,7 +182,7 @@ unsafe extern "C" fn gui_get_resize_hints( let Some(gui) = (unsafe { plugin_gui_query(plugin) }) else { return false; }; - let Some(resize_hints) = gui.resize_hints() else { + let Some(resize_hints) = gui.query().resize_hints() else { log::debug!("gui.get_resize_hints: plugin has no resize hints"); return false; }; @@ -219,7 +220,7 @@ unsafe extern "C" fn gui_adjust_size( height: *height, } }; - let adjusted = match gui.adjust_size(requested) { + let adjusted = match gui.query().adjust_size(requested) { Ok(adjusted) => adjusted, Err(error) => { log::warn!("gui.adjust_size: plugin adjust_size failed: {error}"); @@ -239,7 +240,7 @@ unsafe extern "C" fn gui_set_size(plugin: *const clap_plugin, width: u32, height let Some(gui) = (unsafe { plugin_gui_mutation(plugin, "set_size") }) else { return false; }; - match gui.set_size(GuiSize { width, height }) { + match gui.main_thread().set_size(GuiSize { width, height }) { Ok(()) => true, Err(error) => { log::warn!("gui.set_size: plugin set_size failed: {error}"); @@ -265,7 +266,7 @@ unsafe extern "C" fn gui_set_parent( log::warn!("gui.set_parent: unsupported or invalid window API"); return false; }; - match gui.set_parent(parent) { + match gui.main_thread().set_parent(parent) { Ok(()) => true, Err(error) => { log::warn!("gui.set_parent: plugin set_parent failed: {error}"); @@ -298,7 +299,7 @@ unsafe extern "C" fn gui_show(plugin: *const clap_plugin) -> bool { let Some(gui) = (unsafe { plugin_gui_mutation(plugin, "show") }) else { return false; }; - match gui.show() { + match gui.main_thread().show() { Ok(()) => true, Err(error) => { log::warn!("gui.show: plugin show failed: {error}"); @@ -313,7 +314,7 @@ unsafe extern "C" fn gui_hide(plugin: *const clap_plugin) -> bool { let Some(gui) = (unsafe { plugin_gui_mutation(plugin, "hide") }) else { return false; }; - match gui.hide() { + match gui.main_thread().hide() { Ok(()) => true, Err(error) => { log::warn!("gui.hide: plugin hide failed: {error}"); @@ -324,7 +325,7 @@ unsafe extern "C" fn gui_hide(plugin: *const clap_plugin) -> bool { } unsafe fn plugin_gui_query(plugin: *const clap_plugin) -> Option> { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("gui.query: missing plugin instance"); return None; }; @@ -336,6 +337,7 @@ unsafe fn plugin_gui_query(plugin: *const clap_plugin) -> Option, _guard: MutexGuard<'static, ()>, } @@ -348,11 +350,17 @@ impl Deref for GuiMutationAccess { } } +impl GuiMutationAccess { + fn clear_lifecycle_thread(&self) { + self.instance.clear_gui_lifecycle_thread(); + } +} + unsafe fn plugin_gui_mutation( plugin: *const clap_plugin, callback_name: &'static str, ) -> Option { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("gui.{callback_name}: missing plugin instance"); return None; }; @@ -361,11 +369,19 @@ unsafe fn plugin_gui_mutation( return None; }; // Extract only the capability handle to avoid coupling GUI callbacks to the - // `PluginCore` lifecycle/state lock (the GUI runtime has its own thread rules). - instance - .gui - .clone() - .map(|gui| GuiMutationAccess { gui, _guard: guard }) + // `PluginInstance` lifecycle/state lock (the GUI runtime has its own thread rules). + let Some(gui) = instance.gui.clone() else { + log::debug!("gui.{callback_name}: plugin has no GUI"); + return None; + }; + if !instance.enter_gui_lifecycle_thread(callback_name) { + return None; + } + Some(GuiMutationAccess { + instance, + gui, + _guard: guard, + }) } unsafe fn clap_window_to_rust(window: &clap_window) -> Option { diff --git a/crates/wrac_clap_adapter/src/abi/latency_extension.rs b/crates/wrac_clap_adapter/src/abi/latency_extension.rs index 4d822559..438cde31 100644 --- a/crates/wrac_clap_adapter/src/abi/latency_extension.rs +++ b/crates/wrac_clap_adapter/src/abi/latency_extension.rs @@ -1,7 +1,7 @@ use clap_sys::ext::latency::clap_plugin_latency; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::ffi_u32; pub(super) static LATENCY: clap_plugin_latency = clap_plugin_latency { @@ -10,7 +10,7 @@ pub(super) static LATENCY: clap_plugin_latency = clap_plugin_latency { unsafe extern "C" fn latency_get(plugin: *const clap_plugin) -> u32 { ffi_u32(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("latency.get: missing plugin instance"); return 0; }; diff --git a/crates/wrac_clap_adapter/src/abi/note_ports.rs b/crates/wrac_clap_adapter/src/abi/note_ports.rs index 5d9cf3db..2f3f6091 100644 --- a/crates/wrac_clap_adapter/src/abi/note_ports.rs +++ b/crates/wrac_clap_adapter/src/abi/note_ports.rs @@ -1,7 +1,7 @@ use clap_sys::ext::note_ports::{clap_note_port_info, clap_plugin_note_ports}; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::{ffi_bool, ffi_u32, fill_c_char_array}; pub(super) static NOTE_PORTS: clap_plugin_note_ports = clap_plugin_note_ports { @@ -11,7 +11,7 @@ pub(super) static NOTE_PORTS: clap_plugin_note_ports = clap_plugin_note_ports { unsafe extern "C" fn note_ports_count(plugin: *const clap_plugin, is_input: bool) -> u32 { ffi_u32(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!("note_ports.count: missing plugin instance is_input={is_input}"); return 0; }; @@ -35,7 +35,7 @@ unsafe extern "C" fn note_ports_get( ); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!( "note_ports.get: missing plugin instance index={index} is_input={is_input}" ); diff --git a/crates/wrac_clap_adapter/src/abi/params_extension.rs b/crates/wrac_clap_adapter/src/abi/params_extension.rs index d0cf47c3..7e181602 100644 --- a/crates/wrac_clap_adapter/src/abi/params_extension.rs +++ b/crates/wrac_clap_adapter/src/abi/params_extension.rs @@ -13,8 +13,8 @@ use clap_sys::ext::params::{ }; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; use super::ffi::{ffi_bool, ffi_u32, ffi_unit, fill_c_char_array, write_c_str_buffer}; +use super::{PluginInstanceState, RtDepthGuard}; use crate::ParamFlags; use wrac_host_context::PluginFormat; @@ -35,15 +35,11 @@ pub(super) static PARAMS: clap_plugin_params = clap_plugin_params { // GUI/runtime ownership or lifecycle mutation. unsafe extern "C" fn params_count(plugin: *const clap_plugin) -> u32 { ffi_u32(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("params.count: missing plugin instance"); return 0; }; - let Some(parameters) = instance.parameters.as_ref() else { - log::warn!("params.count: plugin has no parameters"); - return 0; - }; - let count = parameters.param_count(); + let count = instance.parameters.count(); log::debug!( "params.count: count={count} thread={:?}", std::thread::current().id() @@ -62,15 +58,11 @@ unsafe extern "C" fn params_get_info( log::warn!("params.get_info: null output pointer index={param_index}"); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("params.get_info: missing plugin instance index={param_index}"); return false; }; - let Some(parameters) = instance.parameters.as_ref() else { - log::warn!("params.get_info: plugin has no parameters index={param_index}"); - return false; - }; - let Some(info) = parameters.param_info(param_index) else { + let Some(info) = instance.parameters.get_info(param_index) else { log::warn!("params.get_info: invalid index={param_index}"); return false; }; @@ -125,15 +117,11 @@ unsafe extern "C" fn params_get_value( log::warn!("params.get_value: null output pointer param_id={param_id}"); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("params.get_value: missing plugin instance param_id={param_id}"); return false; }; - let Some(parameters) = instance.parameters.as_ref() else { - log::warn!("params.get_value: plugin has no parameters param_id={param_id}"); - return false; - }; - let Ok(value) = parameters.param_value(param_id) else { + let Ok(value) = instance.parameters.get_value(param_id) else { log::warn!("params.get_value: invalid param_id={param_id}"); return false; }; @@ -156,15 +144,11 @@ unsafe extern "C" fn params_value_to_text( out_buffer_capacity: u32, ) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("params.value_to_text: missing plugin instance param_id={param_id}"); return false; }; - let Some(parameters) = instance.parameters.as_ref() else { - log::warn!("params.value_to_text: plugin has no parameters param_id={param_id}"); - return false; - }; - let Ok(text) = parameters.value_to_text(param_id, value) else { + let Ok(text) = instance.parameters.value_to_text(param_id, value) else { log::warn!("params.value_to_text: invalid param_id={param_id} value={value}"); return false; }; @@ -187,7 +171,7 @@ unsafe extern "C" fn params_text_to_value( log::warn!("params.text_to_value: null pointer param_id={param_id}"); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("params.text_to_value: missing plugin instance param_id={param_id}"); return false; }; @@ -195,11 +179,7 @@ unsafe extern "C" fn params_text_to_value( log::warn!("params.text_to_value: invalid utf8 param_id={param_id}"); return false; }; - let Some(parameters) = instance.parameters.as_ref() else { - log::warn!("params.text_to_value: plugin has no parameters param_id={param_id}"); - return false; - }; - let Ok(value) = parameters.text_to_value(param_id, text) else { + let Ok(value) = instance.parameters.text_to_value(param_id, text) else { log::warn!("params.text_to_value: invalid param_id={param_id} text={text}"); return false; }; @@ -220,25 +200,95 @@ unsafe extern "C" fn params_flush( out_events: *const clap_output_events, ) { ffi_unit(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { return; }; - unsafe { - let mut events = crate::ProcessEvents::from_raw(in_events, out_events); - if let Some(parameters) = instance.parameters.as_ref() { - instance - .parameter_edits - .apply_input_parameter_events(parameters.as_ref(), &events.input); + let _flush_depth_guard = RtDepthGuard::enter(&instance.rt_flush_depth); + let process_depth = instance + .rt_process_depth + .load(std::sync::atomic::Ordering::Relaxed); + if process_depth > 0 { + wrac_log::rtdebug!("params.flush enter pd={}", process_depth); + } + let result = if instance.is_processor_active() { + let Some(result) = instance.with_processor_mut(|active| { + let Some(active) = active else { + wrac_log::rtwarn!("params.flush: active processor state is inconsistent"); + return Ok(()); + }; + active.flush_params(param_flush_context(in_events, out_events)) + }) else { + let flush_depth = instance + .rt_flush_depth + .load(std::sync::atomic::Ordering::Relaxed); + wrac_log::rtdebug!( + "params.flush active busy pd={} fd={}", + process_depth, + flush_depth + ); + wrac_log::rtwarn!("params.flush: active processor is busy"); + return; + }; + result + } else { + let Some(_guard) = instance.try_enter_lifecycle() else { + wrac_log::rtwarn!("params.flush: lifecycle is busy"); + return; + }; + if instance.is_processor_active() { + drop(_guard); + let Some(result) = instance.with_processor_mut(|active| { + let Some(active) = active else { + wrac_log::rtwarn!("params.flush: active processor state is inconsistent"); + return Ok(()); + }; + active.flush_params(param_flush_context(in_events, out_events)) + }) else { + let flush_depth = instance + .rt_flush_depth + .load(std::sync::atomic::Ordering::Relaxed); + wrac_log::rtdebug!( + "params.flush active busy pd={} fd={}", + process_depth, + flush_depth + ); + wrac_log::rtwarn!("params.flush: active processor is busy"); + return; + }; + result } else { - wrac_log::rtwarn!("params.flush: plugin has no parameters"); + let Some(result) = instance.with_inactive_processor_mut(|inactive| { + let Some(inactive) = inactive else { + wrac_log::rtwarn!( + "params.flush: no active or inactive processor is available" + ); + return Ok(()); + }; + inactive.flush_params(param_flush_context(in_events, out_events)) + }) else { + wrac_log::rtwarn!("params.flush: inactive processor is busy"); + return; + }; + result } - instance - .parameter_edits - .drain_output_parameter_events(&mut events.output); + }; + if let Err(error) = result { + wrac_log::rtwarn!("params.flush: processor failed: {error}"); } }); } +fn param_flush_context<'a>( + in_events: *const clap_input_events, + out_events: *const clap_output_events, +) -> crate::ParamFlushContext<'a> { + crate::ParamFlushContext { + events: unsafe { crate::EventLists::from_raw(in_events, out_events) }, + #[cfg(feature = "raw-clap-forwarding")] + raw: unsafe { crate::RawParamFlushContext::from_raw(in_events, out_events) }, + } +} + fn parameter_flags(flags: ParamFlags) -> u32 { let mut raw = 0; if flags.is_stepped { diff --git a/crates/wrac_clap_adapter/src/abi/render_extension.rs b/crates/wrac_clap_adapter/src/abi/render_extension.rs index cf9a22d7..ce53758e 100644 --- a/crates/wrac_clap_adapter/src/abi/render_extension.rs +++ b/crates/wrac_clap_adapter/src/abi/render_extension.rs @@ -3,7 +3,7 @@ use clap_sys::ext::render::{ }; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::ffi_bool; use crate::PluginRenderMode; @@ -14,7 +14,7 @@ pub(super) static RENDER: clap_plugin_render = clap_plugin_render { unsafe extern "C" fn render_has_hard_realtime_requirement(plugin: *const clap_plugin) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("render.has_hard_realtime_requirement: missing plugin instance"); return false; }; @@ -28,7 +28,7 @@ unsafe extern "C" fn render_has_hard_realtime_requirement(plugin: *const clap_pl unsafe extern "C" fn render_set(plugin: *const clap_plugin, mode: clap_plugin_render_mode) -> bool { ffi_bool(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("render.set: missing plugin instance mode={mode}"); return false; }; diff --git a/crates/wrac_clap_adapter/src/abi/state_extension.rs b/crates/wrac_clap_adapter/src/abi/state_extension.rs index 38bb5807..880ae3c5 100644 --- a/crates/wrac_clap_adapter/src/abi/state_extension.rs +++ b/crates/wrac_clap_adapter/src/abi/state_extension.rs @@ -2,7 +2,7 @@ use clap_sys::ext::state::clap_plugin_state; use clap_sys::plugin::clap_plugin; use clap_sys::stream::{clap_istream, clap_ostream}; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::{ffi_bool, read_stream_to_end, write_stream}; use crate::State; @@ -14,7 +14,7 @@ pub(super) static STATE: clap_plugin_state = clap_plugin_state { const MAX_STATE_BYTES: usize = 64 * 1024 * 1024; // State callbacks may arrive while the plugin is active, depending on the host format. -// Waiting for or giving up on the `PluginCore` write lock here could silently drop a project save, +// Waiting for or giving up on the `PluginInstance` write lock here could silently drop a project save, // so only the thread-safe state capability fixed at instance creation is called. unsafe extern "C" fn state_save(plugin: *const clap_plugin, stream: *const clap_ostream) -> bool { ffi_bool(|| { @@ -22,7 +22,7 @@ unsafe extern "C" fn state_save(plugin: *const clap_plugin, stream: *const clap_ log::warn!("state.save: null stream"); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("state.save: missing plugin instance"); return false; }; @@ -56,7 +56,7 @@ unsafe extern "C" fn state_load(plugin: *const clap_plugin, stream: *const clap_ log::warn!("state.load: null stream"); return false; } - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { log::warn!("state.load: missing plugin instance"); return false; }; @@ -74,7 +74,7 @@ unsafe extern "C" fn state_load(plugin: *const clap_plugin, stream: *const clap_ log::warn!("state.load: plugin restore_state failed: {error}"); return false; } - instance.parameter_edits.rescan_values(); + instance.host_params.rescan_values(); log::debug!("state.load: restored byte_len={byte_len}"); true }) diff --git a/crates/wrac_clap_adapter/src/abi/tail_extension.rs b/crates/wrac_clap_adapter/src/abi/tail_extension.rs index 3ebdf489..5f3a5997 100644 --- a/crates/wrac_clap_adapter/src/abi/tail_extension.rs +++ b/crates/wrac_clap_adapter/src/abi/tail_extension.rs @@ -1,7 +1,7 @@ use clap_sys::ext::tail::clap_plugin_tail; use clap_sys::plugin::clap_plugin; -use super::PluginInstance; +use super::PluginInstanceState; use super::ffi::ffi_u32; pub(super) static TAIL: clap_plugin_tail = clap_plugin_tail { @@ -10,7 +10,7 @@ pub(super) static TAIL: clap_plugin_tail = clap_plugin_tail { unsafe extern "C" fn tail_get(plugin: *const clap_plugin) -> u32 { ffi_u32(|| { - let Some(instance) = (unsafe { PluginInstance::from_plugin(plugin) }) else { + let Some(instance) = (unsafe { PluginInstanceState::from_plugin(plugin) }) else { wrac_log::rtwarn!("tail.get: missing plugin instance"); return 0; }; diff --git a/crates/wrac_clap_adapter/src/api.rs b/crates/wrac_clap_adapter/src/api.rs index 380ed186..9feb0212 100644 --- a/crates/wrac_clap_adapter/src/api.rs +++ b/crates/wrac_clap_adapter/src/api.rs @@ -1,5 +1,15 @@ //! Safe interface between product implementations and the adapter. //! +//! This public API is a thin, safe facade over the CLAP C ABI. Its traits +//! should express existing CLAP entry points, factories, lifecycle callbacks, +//! extensions, event/buffer views, and host callbacks with Rust ownership and +//! defensive thread/call-order handling. Do not add extra abstraction, +//! high-level plugin APIs, or product/domain meaning here. Format conversion is +//! delegated to CLAP plus `clap-wrapper`; this crate must not become a +//! VST3/AU/AAX abstraction layer or plugin framework. +//! The API follows CLAP closely, but may choose pragmatic Rust surfaces over a +//! strict one-to-one mapping when that keeps the adapter thinner and harder to misuse. +//! //! Method docs use thread annotations for the Rust trait call contract: //! - `[main-thread]`: native CLAP/UI main thread. Non-realtime and serialized. //! - `[control-thread]`: non-realtime host/adapter control work. This includes the @@ -24,18 +34,24 @@ mod core; mod error; mod extensions; mod host; +mod params; mod process; mod types; -pub use core::{ActivateContext, PluginCore, PluginCoreContext}; +pub use core::{ActivateContext, PluginInstance, PluginInstanceContext}; pub use error::{PluginError, PluginResult}; pub use extensions::{ PluginAudioPortsExtension, PluginConfigurableAudioPortsExtension, PluginGuiExtension, - PluginLatencyExtension, PluginNotePortsExtension, PluginParamsExtension, PluginRenderExtension, - PluginStateExtension, PluginTailExtension, + PluginGuiMainThreadExtension, PluginGuiQueryExtension, PluginLatencyExtension, + PluginNotePortsExtension, PluginRenderExtension, PluginStateExtension, PluginTailExtension, +}; +pub use host::{HostGui, HostParams, HostState}; +pub use params::PluginParamsQuery; +pub use process::{ + ActiveProcessor, InactiveProcessor, ParamFlushContext, ProcessContext, ProcessStatus, }; -pub use host::{HostGuiResizeRequester, HostParamsEditNotifier, HostStateDirtyNotifier}; -pub use process::{ProcessContext, ProcessStatus, Processor}; +#[cfg(feature = "raw-clap-forwarding")] +pub use process::{RawParamFlushContext, RawProcessContext}; pub use types::{ AudioPortConfigRequest, AudioPortFlags, AudioPortInfo, AudioPortType, GuiApi, GuiConfig, GuiResizeHints, GuiSize, HostWindow, NoteDialects, NotePortInfo, ParamFlags, ParamInfo, diff --git a/crates/wrac_clap_adapter/src/api/core.rs b/crates/wrac_clap_adapter/src/api/core.rs index 2d214db4..73ac18a5 100644 --- a/crates/wrac_clap_adapter/src/api/core.rs +++ b/crates/wrac_clap_adapter/src/api/core.rs @@ -1,10 +1,10 @@ use std::sync::Arc; use crate::{ - HostGuiResizeRequester, HostParamsEditNotifier, HostStateDirtyNotifier, - PluginAudioPortsExtension, PluginConfigurableAudioPortsExtension, PluginGuiExtension, - PluginLatencyExtension, PluginNotePortsExtension, PluginParamsExtension, PluginRenderExtension, - PluginResult, PluginStateExtension, PluginTailExtension, Processor, + ActiveProcessor, HostGui, HostParams, HostState, InactiveProcessor, PluginAudioPortsExtension, + PluginConfigurableAudioPortsExtension, PluginGuiExtension, PluginLatencyExtension, + PluginNotePortsExtension, PluginParamsQuery, PluginRenderExtension, PluginResult, + PluginStateExtension, PluginTailExtension, }; use wrac_host_context::HostContext; @@ -15,29 +15,48 @@ pub struct ActivateContext { pub max_frames_count: u32, } -/// Per-instance environment passed from the adapter to the product core. +/// Per-instance environment passed from the adapter to the product instance. /// /// Contains only adapter proxies that the product can hold safely, not raw FFI pointers. #[derive(Clone)] -pub struct PluginCoreContext { - pub host_parameter_edit_notifier: Arc, - pub host_state_dirty_notifier: Arc, - pub host_gui_resize_requester: Arc, +pub struct PluginInstanceContext { + pub host_params: Arc, + pub host_state: Arc, + pub host_gui: Arc, pub host_context: HostContext, } -/// Entry point for a single plugin instance's lifecycle and capabilities. +/// Entry point for a single CLAP plugin instance's lifecycle and capabilities. /// /// Do not concentrate all state here. Placing `&mut self` `activate`/`deactivate` and /// concurrently-called parameter/state/GUI queries in the same mutable state would make /// it impossible to answer one while the other is running. Split each capability into /// its own thread-safe store and return it as `Arc` from this trait. -pub trait PluginCore: Send + 'static { +pub trait PluginInstance: Send + 'static { + /// Initializes the processor lifecycle in its inactive state. + /// + /// The adapter calls this once during plugin instance creation. It may call + /// it again after `activate` returns an error, because `activate` consumes + /// the previous inactive processor before attempting to create an active one. + /// Implementations must therefore return a fresh inactive processor each time. + /// + /// The returned processor may receive `params.flush` while the plugin is + /// inactive, and is later consumed by `activate`. + /// `[control-thread]` + fn initialize_processor(&mut self) -> PluginResult>; + /// Called from the plugin activation callback. `[control-thread]` - fn activate(&mut self, context: ActivateContext) -> PluginResult>; + fn activate( + &mut self, + context: ActivateContext, + processor: Box, + ) -> PluginResult>; /// Called from the plugin deactivation or destruction callback. `[control-thread]` - fn deactivate(&mut self, processor: Box) -> PluginResult<()>; + fn deactivate( + &mut self, + processor: Box, + ) -> PluginResult>; /// Returns the CLAP audio-ports extension during plugin instance creation. /// @@ -60,12 +79,11 @@ pub trait PluginCore: Send + 'static { None } - /// Returns the CLAP params extension during plugin instance creation. + /// Returns the parameter query surface during plugin instance creation. /// - /// Called once before CLAP callbacks are exposed to the host. - fn params(&self) -> Option> { - None - } + /// Called once before CLAP callbacks are exposed to the host. Plugins without + /// parameters return a query object whose count is zero. + fn params(&self) -> Arc; /// Returns the CLAP state extension during plugin instance creation. /// diff --git a/crates/wrac_clap_adapter/src/api/extensions.rs b/crates/wrac_clap_adapter/src/api/extensions.rs index 0cc4c32a..7a04ee9d 100644 --- a/crates/wrac_clap_adapter/src/api/extensions.rs +++ b/crates/wrac_clap_adapter/src/api/extensions.rs @@ -3,17 +3,15 @@ mod configurable_audio_ports; mod gui; mod latency; mod note_ports; -mod params; mod render; mod state; mod tail; pub use audio_ports::PluginAudioPortsExtension; pub use configurable_audio_ports::PluginConfigurableAudioPortsExtension; -pub use gui::PluginGuiExtension; +pub use gui::{PluginGuiExtension, PluginGuiMainThreadExtension, PluginGuiQueryExtension}; pub use latency::PluginLatencyExtension; pub use note_ports::PluginNotePortsExtension; -pub use params::PluginParamsExtension; pub use render::PluginRenderExtension; pub use state::PluginStateExtension; pub use tail::PluginTailExtension; diff --git a/crates/wrac_clap_adapter/src/api/extensions/gui.rs b/crates/wrac_clap_adapter/src/api/extensions/gui.rs index 9afb6c56..d2007f39 100644 --- a/crates/wrac_clap_adapter/src/api/extensions/gui.rs +++ b/crates/wrac_clap_adapter/src/api/extensions/gui.rs @@ -1,43 +1,89 @@ use crate::{GuiApi, GuiConfig, GuiResizeHints, GuiSize, HostWindow, PluginResult}; -/// CLAP gui extension for embedded host-owned editor windows. -pub trait PluginGuiExtension: Send + Sync + 'static { - /// Called from CLAP `gui.is_api_supported`. `[main-thread]` +/// CLAP GUI query surface. +/// +/// These methods must be safe to call from non-audio control threads. They must +/// not touch thread-affine native UI objects directly. +pub trait PluginGuiQueryExtension: Send + Sync + 'static { + /// Called from CLAP `gui.is_api_supported`. + /// + /// `[thread-safe & control-thread]` fn is_api_supported(&self, api: GuiApi, is_floating: bool) -> bool; - /// Called from CLAP `gui.get_preferred_api`. `[main-thread]` + /// Called from CLAP `gui.get_preferred_api`. + /// + /// `[thread-safe & control-thread]` fn preferred_api(&self) -> Option; - /// Called from CLAP `gui.create`. `[main-thread]` - fn create(&self, configuration: GuiConfig) -> PluginResult<()>; - - /// Called from CLAP `gui.destroy` or plugin destruction. `[main-thread]` - fn destroy(&self); - - /// Called from CLAP `gui.set_scale`. `[main-thread]` - fn set_scale(&self, scale: f64) -> PluginResult<()>; - - /// Called from CLAP `gui.get_size`. `[main-thread]` + /// Called from CLAP `gui.get_size`. + /// + /// `[thread-safe & control-thread]` fn get_size(&self) -> PluginResult; - /// Called from CLAP `gui.can_resize`. `[main-thread]` + /// Called from CLAP `gui.can_resize`. + /// + /// `[thread-safe & control-thread]` fn can_resize(&self) -> bool; - /// Called from CLAP `gui.get_resize_hints`. `[main-thread]` + /// Called from CLAP `gui.get_resize_hints`. + /// + /// `[thread-safe & control-thread]` fn resize_hints(&self) -> Option; - /// Called from CLAP `gui.adjust_size`. `[main-thread]` + /// Called from CLAP `gui.adjust_size`. + /// + /// `[thread-safe & control-thread]` fn adjust_size(&self, size: GuiSize) -> PluginResult; +} + +/// CLAP GUI lifecycle / native UI surface. +/// +/// Host/wrapper code is responsible for calling these methods from the main thread. +/// Product code may assume the main-thread contract and may touch thread-affine UI +/// objects here. +pub trait PluginGuiMainThreadExtension: 'static { + /// Called from CLAP `gui.create`. + /// + /// `[main-thread]` + fn create(&self, configuration: GuiConfig) -> PluginResult<()>; + + /// Called from CLAP `gui.destroy` or plugin destruction. + /// + /// `[main-thread]` + fn destroy(&self); + + /// Called from CLAP `gui.set_scale`. + /// + /// `[main-thread]` + fn set_scale(&self, scale: f64) -> PluginResult<()>; - /// Called from CLAP `gui.set_size`. `[main-thread]` + /// Called from CLAP `gui.set_size`. + /// + /// `[main-thread]` fn set_size(&self, size: GuiSize) -> PluginResult<()>; - /// Called from CLAP `gui.set_parent`. `[main-thread]` + /// Called from CLAP `gui.set_parent`. + /// + /// `[main-thread]` fn set_parent(&self, window: HostWindow) -> PluginResult<()>; - /// Called from CLAP `gui.show`. `[main-thread]` + /// Called from CLAP `gui.show`. + /// + /// `[main-thread]` fn show(&self) -> PluginResult<()>; - /// Called from CLAP `gui.hide`. `[main-thread]` + /// Called from CLAP `gui.hide`. + /// + /// `[main-thread]` fn hide(&self) -> PluginResult<()>; } + +/// CLAP GUI extension exposed to the adapter. +/// +/// Query methods and main-thread lifecycle methods are split so product code can +/// implement thread-safe host queries separately from native UI operations. +pub trait PluginGuiExtension: Send + Sync + 'static { + fn query(&self) -> &(dyn PluginGuiQueryExtension + Send + Sync); + + fn main_thread(&self) -> &dyn PluginGuiMainThreadExtension; +} diff --git a/crates/wrac_clap_adapter/src/api/extensions/params.rs b/crates/wrac_clap_adapter/src/api/extensions/params.rs deleted file mode 100644 index 4c6e39c9..00000000 --- a/crates/wrac_clap_adapter/src/api/extensions/params.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{ParamInfo, ParamInputEvents, PluginResult}; - -/// CLAP params extension. -/// -/// Native CLAP marks parameter queries `[main-thread]`, but wrappers may query them -/// from control or realtime workers. Query methods must be realtime-safe. -pub trait PluginParamsExtension: Send + Sync + 'static { - /// Called from CLAP `params.count`. `[thread-safe]` - fn param_count(&self) -> u32; - - /// Called from CLAP `params.get_info`. `[thread-safe]` - fn param_info(&self, index: u32) -> Option; - - /// Called from CLAP `params.get_value`. `[thread-safe]` - fn param_value(&self, param_id: u32) -> PluginResult; - - /// Called from CLAP `params.flush` input parameter events. - /// `[control-thread,audio-thread]` - /// - /// CLAP may call `params.flush` on the audio thread while active, but not - /// concurrently with `plugin.process`. The event-list boundary is preserved - /// so implementations can handle ordered parameter updates as one callback. - /// Parameter events delivered to `plugin.process` are handled by - /// `Processor::process`, not this method. - fn apply_param_events(&self, events: ParamInputEvents<'_>) -> PluginResult<()>; - - /// Called from CLAP `params.value_to_text`. `[thread-safe & control-thread]` - fn value_to_text(&self, param_id: u32, value: f64) -> PluginResult; - - /// Called from CLAP `params.text_to_value`. `[thread-safe & control-thread]` - fn text_to_value(&self, param_id: u32, text: &str) -> PluginResult; -} diff --git a/crates/wrac_clap_adapter/src/api/host.rs b/crates/wrac_clap_adapter/src/api/host.rs index 0cdf9e28..bf01161f 100644 --- a/crates/wrac_clap_adapter/src/api/host.rs +++ b/crates/wrac_clap_adapter/src/api/host.rs @@ -1,26 +1,21 @@ use crate::{GuiSize, PluginResult}; -/// Notifies the host automation lane of a parameter edit triggered by the GUI or other -/// product-side action. +/// Requests the host to schedule CLAP params synchronization. /// -/// This is not an API to update the source of truth. The product updates its own store -/// first, then calls this to report the edit back to the host -/// (begin -> update -> end forms one undo unit). -/// -/// `Send + Sync` allows GUI or control callbacks to share the notifier. It is not -/// realtime-safe; do not call it from `Processor::process()`. -pub trait HostParamsEditNotifier: Send + Sync { - /// Queues a CLAP `PARAM_GESTURE_BEGIN` event and requests `host_params.request_flush`. - /// `[thread-safe & control-thread]` - fn begin_edit(&self, param_id: u32); - - /// Queues a CLAP `PARAM_VALUE` event and requests `host_params.request_flush`. - /// `[thread-safe & control-thread]` - fn update_edit(&self, param_id: u32, value: f64); - - /// Queues a CLAP `PARAM_GESTURE_END` event and requests `host_params.request_flush`. - /// `[thread-safe & control-thread]` - fn end_edit(&self, param_id: u32); +/// This maps directly to `clap_host_params.request_flush`. It does not carry +/// parameter values; plugins emit those as output events from `process` or +/// `flush_params`. +pub trait HostParams: Send + Sync { + /// Calls CLAP `host_params.rescan`. `[main-thread]` + fn rescan(&self, _flags: u32) {} + + /// Calls CLAP `host_params.clear`. `[main-thread]` + fn clear(&self, _param_id: u32, _flags: u32) {} + + /// Calls CLAP `host_params.request_flush`. `[thread-safe & control-thread]` + /// + /// CLAP marks this callback `!audio-thread`; do not call it from realtime code. + fn request_flush(&self); } /// Notifies the host that non-parameter project state changed and should be saved. @@ -30,8 +25,8 @@ pub trait HostParamsEditNotifier: Send + Sync { /// /// CLAP requires this notification to be sent from the main thread. The adapter /// does not marshal calls, so call this from the product's GUI/control path, not -/// directly from `Processor::process()` or a background worker. -pub trait HostStateDirtyNotifier: Send + Sync { +/// directly from `ActiveProcessor::process()` or a background worker. +pub trait HostState: Send + Sync { /// Calls CLAP `host_state.mark_dirty`. `[main-thread]` fn mark_dirty(&self); } @@ -41,9 +36,25 @@ pub trait HostStateDirtyNotifier: Send + Sync { /// This trait is `Send + Sync` because it is stored inside the shared plugin context, /// not because every method is meaningful from every thread. Call `request_resize` only /// from the product's GUI event path. -pub trait HostGuiResizeRequester: Send + Sync { +pub trait HostGui: Send + Sync { + /// Calls CLAP `host_gui.resize_hints_changed`. `[main-thread]` + fn resize_hints_changed(&self) {} + /// Calls CLAP `host_gui.request_resize`. `[thread-safe & control-thread]` /// /// Product code should normally call this from its GUI event path. fn request_resize(&self, size: GuiSize) -> PluginResult<()>; + + /// Calls CLAP `host_gui.request_show`. `[thread-safe]` + fn request_show(&self) -> bool { + false + } + + /// Calls CLAP `host_gui.request_hide`. `[thread-safe]` + fn request_hide(&self) -> bool { + false + } + + /// Calls CLAP `host_gui.closed`. `[main-thread]` + fn closed(&self, _was_destroyed: bool) {} } diff --git a/crates/wrac_clap_adapter/src/api/params.rs b/crates/wrac_clap_adapter/src/api/params.rs new file mode 100644 index 00000000..1f60a156 --- /dev/null +++ b/crates/wrac_clap_adapter/src/api/params.rs @@ -0,0 +1,28 @@ +use crate::{ParamInfo, PluginResult}; + +/// Host-queryable parameter metadata, values, and text conversion. +/// +/// CLAP defines params as an optional extension, but WRAC treats this query surface +/// as basic adapter API because params are synchronized through `process` and +/// `flush_params`. Plugins without parameters return `0` from `count` and make param +/// flush no-op. +/// +/// This trait has no setter: parameter changes flow as CLAP events through +/// `ProcessContext` and `ParamFlushContext`. Keep GUI workflows, smoothing, +/// automation policy, and product-domain abstractions out of this trait. +pub trait PluginParamsQuery: Send + Sync + 'static { + /// Called from CLAP `params.count`. `[thread-safe]` + fn count(&self) -> u32; + + /// Called from CLAP `params.get_info`. `[thread-safe]` + fn get_info(&self, index: u32) -> Option; + + /// Called from CLAP `params.get_value`. `[thread-safe]` + fn get_value(&self, param_id: u32) -> PluginResult; + + /// Called from CLAP `params.value_to_text`. `[thread-safe & control-thread]` + fn value_to_text(&self, param_id: u32, value: f64) -> PluginResult; + + /// Called from CLAP `params.text_to_value`. `[thread-safe & control-thread]` + fn text_to_value(&self, param_id: u32, text: &str) -> PluginResult; +} diff --git a/crates/wrac_clap_adapter/src/api/process.rs b/crates/wrac_clap_adapter/src/api/process.rs index 37745049..cc6899b7 100644 --- a/crates/wrac_clap_adapter/src/api/process.rs +++ b/crates/wrac_clap_adapter/src/api/process.rs @@ -1,17 +1,22 @@ use std::any::Any; +#[cfg(feature = "raw-clap-forwarding")] +use std::{marker::PhantomData, rc::Rc}; use crate::PluginResult; -use crate::events::{ProcessEvents, TransportEvent}; +use crate::events::{EventLists, TransportEvent}; use crate::process_buffer::AudioProcessBuffer; +#[cfg(feature = "raw-clap-forwarding")] +use clap_sys::{ + events::{clap_input_events, clap_output_events}, + process::clap_process, +}; -/// Processing object that runs on the audio thread. +/// Processing object used while the CLAP plugin is active. /// -/// Kept separate from `PluginCore` to decouple the audio callback from the core's write -/// lock and from GUI/project state. State passed in must be either an immutable snapshot -/// copied at activate time, or atomic/lock-free shared state the audio thread never -/// waits on. -pub trait Processor: Send { - /// Consumes the processor for typed recovery during deactivation. `[control-thread]` +/// State passed in must be either an immutable snapshot copied at activate time, or +/// atomic/lock-free shared state the audio thread never waits on. +pub trait ActiveProcessor: Send { + /// Converts to `Any` so `deactivate` can recover owned state. `[control-thread]` fn into_any(self: Box) -> Box; /// Called from CLAP `plugin.reset`. `[audio-thread]` @@ -19,13 +24,35 @@ pub trait Processor: Send { /// Called from CLAP `plugin.process`. `[audio-thread]` fn process(&mut self, context: ProcessContext<'_>) -> PluginResult; + + /// Called from CLAP `params.flush` while active. `[audio-thread]` + /// + /// This has the same realtime constraints as `process`. + fn flush_params(&mut self, context: ParamFlushContext<'_>) -> PluginResult<()>; +} + +/// Processing state used while the CLAP plugin is inactive. +pub trait InactiveProcessor: Send { + /// Converts to `Any` so `activate` can recover owned state. `[control-thread]` + fn into_any(self: Box) -> Box; + + /// Called from CLAP `params.flush` while inactive. `[control-thread]` + fn flush_params(&mut self, context: ParamFlushContext<'_>) -> PluginResult<()>; } pub struct ProcessContext<'a> { pub frames_count: u32, pub audio: AudioProcessBuffer<'a>, - pub events: ProcessEvents<'a>, + pub events: EventLists<'a>, pub transport: Option, + #[cfg(feature = "raw-clap-forwarding")] + pub(crate) raw: RawProcessContext<'a>, +} + +pub struct ParamFlushContext<'a> { + pub events: EventLists<'a>, + #[cfg(feature = "raw-clap-forwarding")] + pub(crate) raw: RawParamFlushContext<'a>, } #[derive(Debug, Clone, Copy)] @@ -35,3 +62,80 @@ pub enum ProcessStatus { Tail, Sleep, } + +#[cfg(feature = "raw-clap-forwarding")] +impl<'a> ProcessContext<'a> { + /// Returns the exact CLAP process pointer received by the WRAC adapter. + /// + /// This is intentionally available only behind `raw-clap-forwarding`. It exists for + /// CLAP-to-CLAP proxy products that must synchronously forward process data without + /// re-encoding events or buffers. Do not store the returned view beyond the callback. + pub fn raw_forwarding(&self) -> RawProcessContext<'a> { + self.raw + } +} + +#[cfg(feature = "raw-clap-forwarding")] +impl<'a> ParamFlushContext<'a> { + /// Returns the exact CLAP params.flush event lists received by the WRAC adapter. + /// + /// The view is callback-lifetime bound and must only be used for synchronous + /// forwarding into another CLAP plugin instance. + pub fn raw_forwarding(&self) -> RawParamFlushContext<'a> { + self.raw + } +} + +#[cfg(feature = "raw-clap-forwarding")] +#[derive(Clone, Copy)] +pub struct RawProcessContext<'a> { + process: *const clap_process, + _marker: PhantomData<(&'a clap_process, Rc<()>)>, +} + +#[cfg(feature = "raw-clap-forwarding")] +impl<'a> RawProcessContext<'a> { + pub(crate) unsafe fn from_raw(process: *const clap_process) -> Self { + Self { + process, + _marker: PhantomData, + } + } + + /// Raw CLAP `clap_process` pointer valid only for the current process callback. + pub fn as_ptr(self) -> *const clap_process { + self.process + } +} + +#[cfg(feature = "raw-clap-forwarding")] +#[derive(Clone, Copy)] +pub struct RawParamFlushContext<'a> { + input_events: *const clap_input_events, + output_events: *const clap_output_events, + _marker: PhantomData<(&'a clap_input_events, &'a mut clap_output_events, Rc<()>)>, +} + +#[cfg(feature = "raw-clap-forwarding")] +impl<'a> RawParamFlushContext<'a> { + pub(crate) unsafe fn from_raw( + input_events: *const clap_input_events, + output_events: *const clap_output_events, + ) -> Self { + Self { + input_events, + output_events, + _marker: PhantomData, + } + } + + /// Raw CLAP `clap_input_events` pointer valid only for the current flush callback. + pub fn input_events(self) -> *const clap_input_events { + self.input_events + } + + /// Raw CLAP `clap_output_events` pointer valid only for the current flush callback. + pub fn output_events(self) -> *const clap_output_events { + self.output_events + } +} diff --git a/crates/wrac_clap_adapter/src/entry.rs b/crates/wrac_clap_adapter/src/entry.rs index bb6ee471..02b1a0e2 100644 --- a/crates/wrac_clap_adapter/src/entry.rs +++ b/crates/wrac_clap_adapter/src/entry.rs @@ -1,7 +1,7 @@ use std::sync::{Mutex, OnceLock}; use crate::factory::PluginRegistrationStorage; -use crate::{PluginCore, PluginCoreContext, PluginDescriptor, PluginResult}; +use crate::{PluginDescriptor, PluginInstance, PluginInstanceContext, PluginResult}; pub struct EntryContext<'a> { pub plugin_path: Option<&'a str>, @@ -27,8 +27,8 @@ pub trait PluginFactory: Send + Sync + 'static { fn create_plugin( &self, plugin_id: &str, - context: PluginCoreContext, - ) -> Option>; + context: PluginInstanceContext, + ) -> Option>; } /// Static owner for the safe Rust entry and ABI-facing factory storage. diff --git a/crates/wrac_clap_adapter/src/events.rs b/crates/wrac_clap_adapter/src/events.rs index dee938de..2eebaaf1 100644 --- a/crates/wrac_clap_adapter/src/events.rs +++ b/crates/wrac_clap_adapter/src/events.rs @@ -22,17 +22,17 @@ const TRANSPORT_HAS_SECONDS_TIMELINE: u32 = 1 << 2; const TRANSPORT_HAS_TIME_SIGNATURE: u32 = 1 << 3; const TRANSPORT_IS_PLAYING: u32 = 1 << 4; -/// View that confines the CLAP event lists from `process()`/`flush()` to the callback lifetime. +/// View that confines CLAP input/output event lists to the callback lifetime. /// /// The underlying data is owned by the host and is invalid after the callback returns. /// Raw pointers are not exposed to the product; events are converted to typed enums so -/// they can only be handled within the audio callback. -pub struct ProcessEvents<'a> { +/// they can only be handled within the current CLAP callback. +pub struct EventLists<'a> { pub input: InputEvents<'a>, pub output: OutputEvents<'a>, } -impl<'a> ProcessEvents<'a> { +impl<'a> EventLists<'a> { pub(crate) unsafe fn from_raw( input: *const clap_input_events, output: *const clap_output_events, diff --git a/crates/wrac_clap_adapter/src/host_gui.rs b/crates/wrac_clap_adapter/src/host_gui.rs index 76ecde16..d4d54e7c 100644 --- a/crates/wrac_clap_adapter/src/host_gui.rs +++ b/crates/wrac_clap_adapter/src/host_gui.rs @@ -1,13 +1,13 @@ use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui}; use clap_sys::host::clap_host; -use crate::{GuiSize, HostGuiResizeRequester, PluginError, PluginResult}; +use crate::{GuiSize, HostGui, PluginError, PluginResult}; -pub(crate) struct HostGuiResizeRequest { - host_gui: Option, +pub(crate) struct HostGuiProxy { + host_gui: Option, } -impl HostGuiResizeRequest { +impl HostGuiProxy { pub(crate) fn new(host: *const clap_host) -> Self { Self { host_gui: host_gui_request_resize(host), @@ -15,7 +15,24 @@ impl HostGuiResizeRequest { } } -impl HostGuiResizeRequester for HostGuiResizeRequest { +impl HostGui for HostGuiProxy { + fn resize_hints_changed(&self) { + let Some(host_gui) = self.host_gui else { + log::debug!("host_gui.resize_hints_changed: host GUI extension unavailable"); + return; + }; + + if let Some(resize_hints_changed) = host_gui.resize_hints_changed { + unsafe { + resize_hints_changed(host_gui.host); + } + } else { + log::debug!( + "host_gui.resize_hints_changed: host resize_hints_changed callback unavailable" + ); + } + } + fn request_resize(&self, size: GuiSize) -> PluginResult<()> { let Some(host_gui) = self.host_gui else { return Err(PluginError::Message( @@ -30,21 +47,68 @@ impl HostGuiResizeRequester for HostGuiResizeRequest { Err(PluginError::Message("host rejected GUI resize request")) } } + + fn request_show(&self) -> bool { + let Some(host_gui) = self.host_gui else { + log::debug!("host_gui.request_show: host GUI extension unavailable"); + return false; + }; + + let Some(request_show) = host_gui.request_show else { + log::debug!("host_gui.request_show: host request_show callback unavailable"); + return false; + }; + + unsafe { request_show(host_gui.host) } + } + + fn request_hide(&self) -> bool { + let Some(host_gui) = self.host_gui else { + log::debug!("host_gui.request_hide: host GUI extension unavailable"); + return false; + }; + + let Some(request_hide) = host_gui.request_hide else { + log::debug!("host_gui.request_hide: host request_hide callback unavailable"); + return false; + }; + + unsafe { request_hide(host_gui.host) } + } + + fn closed(&self, was_destroyed: bool) { + let Some(host_gui) = self.host_gui else { + log::debug!("host_gui.closed: host GUI extension unavailable"); + return; + }; + + if let Some(closed) = host_gui.closed { + unsafe { + closed(host_gui.host, was_destroyed); + } + } else { + log::debug!("host_gui.closed: host closed callback unavailable"); + } + } } #[derive(Clone, Copy)] -struct HostGuiRequestResize { +struct HostGuiCallbacks { host: *const clap_host, + resize_hints_changed: Option, request_resize: unsafe extern "C" fn(host: *const clap_host, width: u32, height: u32) -> bool, + request_show: Option bool>, + request_hide: Option bool>, + closed: Option, } // The instance lifetime of the host pointer is the minimal unavoidable assumption of the // CLAP ABI. This handle is Send/Sync only so it can live in shared plugin context; callers // must still use request_resize from the GUI event path, not from audio/background work. -unsafe impl Send for HostGuiRequestResize {} -unsafe impl Sync for HostGuiRequestResize {} +unsafe impl Send for HostGuiCallbacks {} +unsafe impl Sync for HostGuiCallbacks {} -fn host_gui_request_resize(host: *const clap_host) -> Option { +fn host_gui_request_resize(host: *const clap_host) -> Option { if host.is_null() { return None; } @@ -56,9 +120,13 @@ fn host_gui_request_resize(host: *const clap_host) -> Option, } -impl HostStateDirtyNotification { +impl HostStateProxy { pub(crate) fn new(host: *const clap_host) -> Self { Self { host_state: host_state_mark_dirty(host), @@ -15,7 +15,7 @@ impl HostStateDirtyNotification { } } -impl HostStateDirtyNotifier for HostStateDirtyNotification { +impl HostState for HostStateProxy { fn mark_dirty(&self) { let Some(host_state) = self.host_state else { log::debug!("host_state.mark_dirty: host state extension unavailable"); diff --git a/crates/wrac_clap_adapter/src/lib.rs b/crates/wrac_clap_adapter/src/lib.rs index c19798b5..e4c70cfd 100644 --- a/crates/wrac_clap_adapter/src/lib.rs +++ b/crates/wrac_clap_adapter/src/lib.rs @@ -17,24 +17,26 @@ mod params; mod process_buffer; pub use api::{ - ActivateContext, AudioPortConfigRequest, AudioPortFlags, AudioPortInfo, AudioPortType, - DetectedHost, GuiApi, GuiConfig, GuiResizeHints, GuiSize, HostContext, HostFamily, - HostGuiResizeRequester, HostParamsEditNotifier, HostStateDirtyNotifier, HostVersion, - HostWindow, NoteDialects, NotePortInfo, ParamFlags, ParamInfo, ParamValueEvent, - PluginAudioPortsExtension, PluginConfigurableAudioPortsExtension, PluginCore, - PluginCoreContext, PluginError, PluginFormat, PluginGuiExtension, PluginLatencyExtension, - PluginNotePortsExtension, PluginParamsExtension, PluginRenderExtension, PluginRenderMode, - PluginResult, PluginStateExtension, PluginTailExtension, ProcessContext, ProcessStatus, - Processor, State, SystemContext, + ActivateContext, ActiveProcessor, AudioPortConfigRequest, AudioPortFlags, AudioPortInfo, + AudioPortType, DetectedHost, GuiApi, GuiConfig, GuiResizeHints, GuiSize, HostContext, + HostFamily, HostGui, HostParams, HostState, HostVersion, HostWindow, InactiveProcessor, + NoteDialects, NotePortInfo, ParamFlags, ParamFlushContext, ParamInfo, ParamValueEvent, + PluginAudioPortsExtension, PluginConfigurableAudioPortsExtension, PluginError, PluginFormat, + PluginGuiExtension, PluginGuiMainThreadExtension, PluginGuiQueryExtension, PluginInstance, + PluginInstanceContext, PluginLatencyExtension, PluginNotePortsExtension, PluginParamsQuery, + PluginRenderExtension, PluginRenderMode, PluginResult, PluginStateExtension, + PluginTailExtension, ProcessContext, ProcessStatus, State, SystemContext, }; +#[cfg(feature = "raw-clap-forwarding")] +pub use api::{RawParamFlushContext, RawProcessContext}; pub use descriptor::{ AaxDescriptor, AaxStemConfig, Auv2Descriptor, PluginDescriptor, PluginFeature, Vst3Descriptor, }; pub use entry::{EntryContext, PluginEntry, PluginFactory}; pub use events::{ - InputEvent, InputEvents, Midi2Event, MidiEvent, MidiSysexEvent, NoteEvent, NoteExpressionEvent, - OutputEvent, OutputEvents, ParamGestureEvent, ParamInputEvents, ParamModEvent, ProcessEvents, - TransportEvent, TransportFlags, UnknownEvent, + EventLists, InputEvent, InputEvents, Midi2Event, MidiEvent, MidiSysexEvent, NoteEvent, + NoteExpressionEvent, OutputEvent, OutputEvents, ParamGestureEvent, ParamInputEvents, + ParamModEvent, TransportEvent, TransportFlags, UnknownEvent, }; pub use process_buffer::{ AudioBufferError, AudioChannelPair, AudioPairedChannels, AudioPortChannels, AudioPortPair, diff --git a/crates/wrac_clap_adapter/src/params.rs b/crates/wrac_clap_adapter/src/params.rs index de89536c..f864e38e 100644 --- a/crates/wrac_clap_adapter/src/params.rs +++ b/crates/wrac_clap_adapter/src/params.rs @@ -1,157 +1,93 @@ -use std::collections::VecDeque; - -use clap_sys::ext::params::{CLAP_EXT_PARAMS, CLAP_PARAM_RESCAN_VALUES, clap_host_params}; +use clap_sys::ext::params::{ + CLAP_EXT_PARAMS, CLAP_PARAM_RESCAN_VALUES, clap_host_params, clap_param_clear_flags, + clap_param_rescan_flags, +}; use clap_sys::host::clap_host; -use parking_lot::Mutex; -use crate::{ - HostParamsEditNotifier, InputEvents, OutputEvent, OutputEvents, ParamGestureEvent, - ParamValueEvent, PluginParamsExtension, -}; +use crate::HostParams; -/// Queue that holds UI-originated parameter edits until the host can receive them. -/// -/// The CLAP output queue only exists during `flush()`/`process()` callbacks. Letting -/// the GUI construct CLAP events directly would mean holding pointers beyond the -/// callback lifetime, so the adapter stores only semantic information and converts to -/// CLAP events when the output queue becomes available. -pub(crate) struct ParameterEditQueue { - pending: Mutex>, - host_params: Option, +/// Thin proxy for the CLAP host params extension. +pub(crate) struct HostParamsProxy { + host_params: Option, } -impl ParameterEditQueue { +impl HostParamsProxy { pub(crate) fn new(host: *const clap_host) -> Self { Self { - pending: Mutex::new(VecDeque::new()), host_params: host_params(host), } } - pub(crate) unsafe fn apply_input_parameter_events( - &self, - parameters: &dyn PluginParamsExtension, - events: &InputEvents<'_>, - ) { - if let Err(error) = parameters.apply_param_events(events.param_events()) { - wrac_log::rtwarn!("parameter_edits.apply_input: parameter apply failed: {error}"); - } - } - - pub(crate) fn drain_output_parameter_events(&self, events: &mut OutputEvents<'_>) { - // Avoid waiting on the UI thread from the audio callback. If the queue is - // momentarily busy, defer to the next flush/process. request_flush to the host - // was already issued when the edit was enqueued. - let Some(mut pending) = self.pending.try_lock() else { - wrac_log::rtdebug!( - "parameter_edits.drain: pending queue try_lock failed; retrying later" - ); + pub(crate) fn rescan_values(&self) { + let Some(params) = self.host_params else { + log::debug!("host_params.rescan_values: host params extension unavailable"); return; }; - while let Some(event) = pending.pop_front() { - if !push_parameter_edit(events, event) { - // The CLAP output queue is host-owned and may reject events when full - // or during a no-buffer flush. Discarding an unsent edit would drop an - // automation gesture, so preserve ordering and defer to the next - // flush/process. - pending.push_front(event); - break; + if let Some(rescan) = params.rescan { + unsafe { + rescan(params.host, CLAP_PARAM_RESCAN_VALUES); } + } else { + log::debug!("host_params.rescan_values: host rescan callback unavailable"); } } +} - fn push(&self, event: ParameterEditEvent) { - self.pending.lock().push_back(event); - // Issue request_flush after enqueuing. Some hosts will not call `flush()` - // without this notification, causing UI edits to never reach the automation lane. - self.request_flush(); - } - - fn request_flush(&self) { +impl HostParams for HostParamsProxy { + fn rescan(&self, flags: u32) { let Some(params) = self.host_params else { - log::debug!("parameter_edits.request_flush: host params extension unavailable"); + log::debug!("host_params.rescan: host params extension unavailable"); return; }; - if let Some(request_flush) = params.request_flush { + if let Some(rescan) = params.rescan { unsafe { - request_flush(params.host); + rescan(params.host, flags); } } else { - log::debug!("parameter_edits.request_flush: host request_flush callback unavailable"); + log::debug!("host_params.rescan: host rescan callback unavailable"); } } - pub(crate) fn rescan_values(&self) { + fn clear(&self, param_id: u32, flags: u32) { let Some(params) = self.host_params else { - log::debug!("parameter_edits.rescan_values: host params extension unavailable"); + log::debug!("host_params.clear: host params extension unavailable"); return; }; - if let Some(rescan) = params.rescan { + if let Some(clear) = params.clear { unsafe { - rescan(params.host, CLAP_PARAM_RESCAN_VALUES); + clear(params.host, param_id, flags); } } else { - log::debug!("parameter_edits.rescan_values: host rescan callback unavailable"); + log::debug!("host_params.clear: host clear callback unavailable"); } } -} - -impl HostParamsEditNotifier for ParameterEditQueue { - fn begin_edit(&self, param_id: u32) { - self.push(ParameterEditEvent::Begin { param_id }); - } - fn update_edit(&self, param_id: u32, value: f64) { - self.push(ParameterEditEvent::Update { param_id, value }); - } - - fn end_edit(&self, param_id: u32) { - self.push(ParameterEditEvent::End { param_id }); - } -} - -#[derive(Clone, Copy)] -enum ParameterEditEvent { - Begin { param_id: u32 }, - Update { param_id: u32, value: f64 }, - End { param_id: u32 }, -} + fn request_flush(&self) { + let Some(params) = self.host_params else { + log::debug!("host_params.request_flush: host params extension unavailable"); + return; + }; -fn push_parameter_edit(events: &mut OutputEvents<'_>, event: ParameterEditEvent) -> bool { - match event { - ParameterEditEvent::Begin { param_id } => { - events.try_push(OutputEvent::ParamGestureBegin(ParamGestureEvent { - time: 0, - param_id, - })) - } - ParameterEditEvent::Update { param_id, value } => { - events.try_push(OutputEvent::ParamValue(ParamValueEvent { - time: 0, - param_id, - value, - note_id: -1, - port_index: -1, - channel: -1, - key: -1, - })) - } - ParameterEditEvent::End { param_id } => { - events.try_push(OutputEvent::ParamGestureEnd(ParamGestureEvent { - time: 0, - param_id, - })) + if let Some(request_flush) = params.request_flush { + unsafe { + request_flush(params.host); + } + } else { + log::debug!("host_params.request_flush: host request_flush callback unavailable"); } } } #[derive(Clone, Copy)] -struct HostParams { +struct HostParamsCallbacks { host: *const clap_host, - rescan: Option, + rescan: Option, + clear: Option< + unsafe extern "C" fn(host: *const clap_host, param_id: u32, flags: clap_param_clear_flags), + >, request_flush: Option, } @@ -159,10 +95,10 @@ struct HostParams { // CLAP ABI. Product-facing usage is limited to `request_flush()`; adapter-internal // `rescan_values()` is called only after state load, where CLAP gives the callback a // main-thread contract. -unsafe impl Send for HostParams {} -unsafe impl Sync for HostParams {} +unsafe impl Send for HostParamsCallbacks {} +unsafe impl Sync for HostParamsCallbacks {} -fn host_params(host: *const clap_host) -> Option { +fn host_params(host: *const clap_host) -> Option { if host.is_null() { return None; } @@ -173,9 +109,10 @@ fn host_params(host: *const clap_host) -> Option { if params.is_null() { return None; } - Some(HostParams { + Some(HostParamsCallbacks { host, rescan: (*params).rescan, + clear: (*params).clear, request_flush: (*params).request_flush, }) } diff --git a/crates/wrac_host_context/src/lib.rs b/crates/wrac_host_context/src/lib.rs index 0b8f2b54..6dd4f822 100644 --- a/crates/wrac_host_context/src/lib.rs +++ b/crates/wrac_host_context/src/lib.rs @@ -1,3 +1,10 @@ +//! Host and wrapper-format detection for WRAC plugins. +//! +//! This crate keeps process-name, operating-system, and CLAP-wrapper format +//! detection in one place so product code can receive a typed [`HostContext`] +//! instead of duplicating host-specific checks. The result is diagnostic context; +//! each caller still owns the policy decision for any workaround. + use std::{path::Path, sync::OnceLock}; #[cfg(target_os = "macos")] @@ -26,7 +33,7 @@ impl HostContext { /// Keeps validation / scanner process classification as the WRAC-owned source of truth. /// - /// Product code should consume this result from the `PluginCoreContext::host_context` + /// Product code should consume this result from the `PluginInstanceContext::host_context` /// passed during plugin instance creation instead of duplicating process-name checks. pub fn is_validation_or_scan_host(&self) -> bool { self.host.is_validation_or_scan_host() diff --git a/crates/wrac_log/src/file_logger.rs b/crates/wrac_log/src/file_logger.rs index 7df59879..80c73b14 100644 --- a/crates/wrac_log/src/file_logger.rs +++ b/crates/wrac_log/src/file_logger.rs @@ -234,10 +234,17 @@ fn archive_existing_latest_log(latest_log_file: &Path, file_stem: &str) -> std:: let Some(log_dir) = latest_log_file.parent() else { return Ok(()); }; - std::fs::rename( + match std::fs::rename( latest_log_file, unique_archived_log_file_path(log_dir, file_stem)?, - ) + ) { + Ok(()) => Ok(()), + // Validators and plugin scanners can create multiple short-lived plugin + // processes at once. Another process may archive the same Latest log after + // our exists check, which is already a successful outcome for this session. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } } fn unique_archived_log_file_path(log_dir: &Path, file_stem: &str) -> std::io::Result { diff --git a/crates/wrac_manifest/Cargo.toml b/crates/wrac_manifest/Cargo.toml new file mode 100644 index 00000000..2f733176 --- /dev/null +++ b/crates/wrac_manifest/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "wrac_manifest" +version = "0.1.0" +edition = "2024" +description = "Manifest parser and conversion helpers for WRAC plugins" +license = "MIT" +repository = "https://github.com/novonotes/wrac-plugin-template" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +[lints.rust] +unreachable_pub = "deny" diff --git a/crates/wrac_manifest/src/lib.rs b/crates/wrac_manifest/src/lib.rs new file mode 100644 index 00000000..91a8fc1c --- /dev/null +++ b/crates/wrac_manifest/src/lib.rs @@ -0,0 +1,634 @@ +//! Parser and validator for WRAC plugin manifests. +//! +//! `wrac-plugin.toml` is the product-owned manifest for host-visible metadata: +//! bundle identifiers, plugin IDs, wrapper descriptors, supported formats, and +//! validation exceptions. This crate reads that file into typed Rust structures +//! for build scripts and xtask code; it does not perform plugin builds itself. + +use std::{ + collections::{HashMap, HashSet}, + error::Error, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +pub type Result = std::result::Result>; + +#[derive(Debug, Clone, Default)] +pub struct ManifestPackageInfo { + pub package_name: Option, + pub version: Option, + pub repository: Option, +} + +#[derive(Debug, Clone)] +pub struct PluginManifest { + pub package: ManifestPackageInfo, + pub company_name: String, + pub auv2_manufacturer_code: String, + pub aax_manufacturer_id: Option, + pub bundle_name: String, + pub bundle_identifier: String, + pub homepage_url: String, + pub manual_url: String, + pub support_url: String, + pub description: String, + pub copyright: String, + pub supported_formats: Vec, + pub plugins: Vec, + pub validation: ValidationMetadata, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PluginFormat { + Clap, + Vst3, + Au, + Aax, +} + +impl PluginFormat { + pub fn display(self) -> &'static str { + match self { + Self::Clap => "CLAP", + Self::Vst3 => "VST3", + Self::Au => "AU", + Self::Aax => "AAX", + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ValidationMetadata { + #[serde(default)] + pub disabled_rules: HashMap, + #[serde(default)] + pub clap_validator: ClapValidatorMetadata, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DisabledValidationRule { + pub reason: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ClapValidatorMetadata { + pub skip_test_filter: Option, + pub skip_reason: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginProduct { + pub plugin_id: String, + pub plugin_name: String, + pub clap_features: Vec, + pub vst3_subcategories: String, + pub vst3_component_id: String, + pub standalone_name: String, + pub auv2_type: String, + pub auv2_subtype: String, + pub aax_categories: Option>, + pub aax_product_id: Option, + #[serde(default)] + pub aax_stem_configs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AaxStemConfig { + pub name: String, + pub input: String, + pub output: String, + pub plugin_id: String, +} + +#[derive(Debug, Clone)] +pub enum ManifestSource { + Dedicated(PathBuf), + LegacyCargoMetadata(PathBuf), +} + +pub fn discover_manifest( + package_manifest_path: &Path, + plugin_root: &Path, +) -> Result { + let package_dir = package_manifest_path.parent().ok_or_else(|| { + format!( + "failed to derive package dir from {}", + package_manifest_path.display() + ) + })?; + let package_manifest = package_dir.join("wrac-plugin.toml"); + if package_manifest.exists() { + return Ok(ManifestSource::Dedicated(package_manifest)); + } + let plugin_root_manifest = plugin_root.join("wrac-plugin.toml"); + if plugin_root_manifest.exists() { + return Ok(ManifestSource::Dedicated(plugin_root_manifest)); + } + if let Some(relative) = legacy_manifest_reference(package_manifest_path)? { + return Ok(ManifestSource::Dedicated(package_dir.join(relative))); + } + Ok(ManifestSource::LegacyCargoMetadata( + package_manifest_path.to_path_buf(), + )) +} + +pub fn read_manifest(source: &ManifestSource) -> Result { + match source { + ManifestSource::Dedicated(path) => read_dedicated_manifest(path), + ManifestSource::LegacyCargoMetadata(path) => read_legacy_cargo_metadata(path), + } +} + +pub fn read_dedicated_manifest(path: &Path) -> Result { + let manifest = fs::read_to_string(path)?; + let dedicated: DedicatedManifest = toml::from_str(&manifest)?; + let metadata = PluginManifest { + package: dedicated.package.unwrap_or_default(), + company_name: dedicated.bundle.company_name, + auv2_manufacturer_code: dedicated.bundle.auv2_manufacturer_code, + aax_manufacturer_id: dedicated.bundle.aax_manufacturer_id, + bundle_name: dedicated.bundle.bundle_name, + bundle_identifier: dedicated.bundle.bundle_identifier, + homepage_url: dedicated.bundle.homepage_url, + manual_url: dedicated.bundle.manual_url, + support_url: dedicated.bundle.support_url, + description: dedicated.bundle.description, + copyright: dedicated.bundle.copyright, + supported_formats: dedicated.bundle.supported_formats, + plugins: dedicated.plugins, + validation: dedicated.validation.unwrap_or_default(), + }; + metadata.validate("wrac-plugin.toml")?; + Ok(metadata) +} + +pub fn read_legacy_cargo_metadata(path: &Path) -> Result { + let manifest = fs::read_to_string(path)?; + let cargo_manifest: CargoManifest = toml::from_str(&manifest)?; + let wrac = cargo_manifest + .package + .metadata + .wrac + .ok_or_else(|| format!("missing package.metadata.wrac in {}", path.display()))?; + if wrac.manifest.is_some() { + return Err( + "package.metadata.wrac.manifest must be resolved before reading legacy metadata".into(), + ); + } + let metadata = PluginManifest { + package: ManifestPackageInfo { + package_name: Some(cargo_manifest.package.name), + version: Some(cargo_manifest.package.version), + repository: cargo_manifest.package.repository, + }, + company_name: wrac + .company_name + .ok_or("missing package.metadata.wrac.company_name")?, + auv2_manufacturer_code: wrac + .auv2_manufacturer_code + .ok_or("missing package.metadata.wrac.auv2_manufacturer_code")?, + aax_manufacturer_id: wrac.aax_manufacturer_id, + bundle_name: wrac + .bundle_name + .ok_or("missing package.metadata.wrac.bundle_name")?, + bundle_identifier: wrac + .bundle_identifier + .ok_or("missing package.metadata.wrac.bundle_identifier")?, + homepage_url: wrac + .homepage_url + .ok_or("missing package.metadata.wrac.homepage_url")?, + manual_url: wrac + .manual_url + .ok_or("missing package.metadata.wrac.manual_url")?, + support_url: wrac + .support_url + .ok_or("missing package.metadata.wrac.support_url")?, + description: wrac + .description + .ok_or("missing package.metadata.wrac.description")?, + copyright: wrac + .copyright + .ok_or("missing package.metadata.wrac.copyright")?, + supported_formats: wrac.supported_formats.unwrap_or_default(), + plugins: wrac.plugins, + validation: wrac.validation.unwrap_or_default(), + }; + metadata.validate("package.metadata.wrac")?; + Ok(metadata) +} + +pub fn legacy_manifest_reference(path: &Path) -> Result> { + let manifest = fs::read_to_string(path)?; + let cargo_manifest: CargoManifest = toml::from_str(&manifest)?; + Ok(cargo_manifest + .package + .metadata + .wrac + .and_then(|wrac| wrac.manifest)) +} + +pub fn read_cargo_package_info(path: &Path) -> Result { + let manifest = fs::read_to_string(path)?; + let cargo_manifest: CargoManifest = toml::from_str(&manifest)?; + Ok(ManifestPackageInfo { + package_name: Some(cargo_manifest.package.name), + version: Some(cargo_manifest.package.version), + repository: cargo_manifest.package.repository, + }) +} + +impl PluginManifest { + pub fn validate(&self, label: &str) -> Result<()> { + validate_required(&format!("{label}.company_name"), &self.company_name)?; + validate_four_ascii( + &format!("{label}.auv2_manufacturer_code"), + &self.auv2_manufacturer_code, + )?; + validate_required(&format!("{label}.bundle_name"), &self.bundle_name)?; + validate_required( + &format!("{label}.bundle_identifier"), + &self.bundle_identifier, + )?; + validate_required(&format!("{label}.homepage_url"), &self.homepage_url)?; + validate_required(&format!("{label}.manual_url"), &self.manual_url)?; + validate_required(&format!("{label}.support_url"), &self.support_url)?; + validate_required(&format!("{label}.description"), &self.description)?; + validate_required(&format!("{label}.copyright"), &self.copyright)?; + if self.supported_formats.is_empty() { + return Err(format!("{label}.supported_formats must not be empty").into()); + } + let mut supported_formats = HashSet::new(); + for format in &self.supported_formats { + if !supported_formats.insert(*format) { + return Err(format!( + "duplicate {label}.supported_formats entry: {}", + format.display() + ) + .into()); + } + } + let supports_aax = supported_formats.contains(&PluginFormat::Aax); + if supports_aax { + let Some(aax_manufacturer_id) = self.aax_manufacturer_id.as_ref() else { + return Err(format!( + "{label}.aax_manufacturer_id is required when supported_formats contains aax" + ) + .into()); + }; + validate_four_ascii(&format!("{label}.aax_manufacturer_id"), aax_manufacturer_id)?; + } + if self.plugins.is_empty() { + return Err(format!("{label}.plugins must contain at least one plugin").into()); + } + let mut plugin_ids = HashSet::new(); + let mut standalone_names = HashSet::new(); + let mut auv2_ids = HashSet::new(); + for plugin in &self.plugins { + validate_required(&format!("{label}.plugins.plugin_id"), &plugin.plugin_id)?; + validate_required(&format!("{label}.plugins.plugin_name"), &plugin.plugin_name)?; + if plugin.clap_features.is_empty() { + return Err(format!("{label}.plugins.clap_features must not be empty").into()); + } + for feature in &plugin.clap_features { + validate_clap_feature(feature).map_err(|_| { + format!("unsupported {label}.plugins.clap_features value: {feature}") + })?; + } + validate_required( + &format!("{label}.plugins.vst3_subcategories"), + &plugin.vst3_subcategories, + )?; + vst3_component_id_bytes(&plugin.vst3_component_id)?; + validate_required( + &format!("{label}.plugins.standalone_name"), + &plugin.standalone_name, + )?; + validate_four_ascii(&format!("{label}.plugins.auv2_type"), &plugin.auv2_type)?; + validate_four_ascii( + &format!("{label}.plugins.auv2_subtype"), + &plugin.auv2_subtype, + )?; + if supports_aax { + let Some(aax_categories) = plugin.aax_categories.as_ref() else { + return Err(format!("{label}.plugins.aax_categories is required when supported_formats contains aax").into()); + }; + if aax_categories.is_empty() { + return Err(format!("{label}.plugins.aax_categories must not be empty").into()); + } + for category in aax_categories { + aax_category_bits(category)?; + } + let Some(aax_product_id) = plugin.aax_product_id.as_ref() else { + return Err(format!("{label}.plugins.aax_product_id is required when supported_formats contains aax").into()); + }; + validate_four_ascii(&format!("{label}.plugins.aax_product_id"), aax_product_id)?; + if plugin.aax_stem_configs.is_empty() { + return Err( + format!("{label}.plugins.aax_stem_configs must not be empty").into(), + ); + } + } + let mut aax_plugin_ids = HashSet::new(); + for stem_config in &plugin.aax_stem_configs { + validate_required( + &format!("{label}.plugins.aax_stem_configs.name"), + &stem_config.name, + )?; + aax_stem_format_value(&stem_config.input)?; + aax_stem_format_value(&stem_config.output)?; + validate_four_ascii( + &format!("{label}.plugins.aax_stem_configs.plugin_id"), + &stem_config.plugin_id, + )?; + if !aax_plugin_ids.insert(stem_config.plugin_id.as_str()) { + return Err(format!( + "duplicate {label}.plugins.aax_stem_configs plugin_id: {}", + stem_config.plugin_id + ) + .into()); + } + } + if !plugin_ids.insert(plugin.plugin_id.as_str()) { + return Err( + format!("duplicate {label}.plugins plugin_id: {}", plugin.plugin_id).into(), + ); + } + if !standalone_names.insert(plugin.standalone_name.as_str()) { + return Err(format!( + "duplicate {label}.plugins standalone_name: {}", + plugin.standalone_name + ) + .into()); + } + if !auv2_ids.insert((plugin.auv2_type.as_str(), plugin.auv2_subtype.as_str())) { + return Err(format!( + "duplicate {label}.plugins AUv2 type/subtype: {}/{}", + plugin.auv2_type, plugin.auv2_subtype + ) + .into()); + } + } + for (rule_id, disabled) in &self.validation.disabled_rules { + validate_required( + &format!("{label}.validation.disabled_rules.{rule_id}.reason"), + disabled.reason.trim(), + )?; + } + if let Some(filter) = self.validation.clap_validator.skip_test_filter.as_deref() { + validate_required( + &format!("{label}.validation.clap_validator.skip_test_filter"), + filter.trim(), + )?; + validate_required( + &format!("{label}.validation.clap_validator.skip_reason"), + self.validation + .clap_validator + .skip_reason + .as_deref() + .unwrap_or_default() + .trim(), + )?; + } + Ok(()) + } +} + +pub fn clap_feature_variant(feature: &str) -> Option<&'static str> { + Some(match feature { + "audio-effect" => "AudioEffect", + "analyzer" => "Analyzer", + "ambisonic" => "Ambisonic", + "chorus" => "Chorus", + "compressor" => "Compressor", + "de-esser" => "DeEsser", + "delay" => "Delay", + "instrument" => "Instrument", + "note-effect" => "NoteEffect", + "note-detector" => "NoteDetector", + "drum" => "Drum", + "drum-machine" => "DrumMachine", + "equalizer" => "Equalizer", + "expander" => "Expander", + "filter" => "Filter", + "flanger" => "Flanger", + "frequency-shifter" => "FrequencyShifter", + "gate" => "Gate", + "glitch" => "Glitch", + "granular" => "Granular", + "distortion" => "Distortion", + "limiter" => "Limiter", + "mastering" => "Mastering", + "mixing" => "Mixing", + "mono" => "Mono", + "multi-effects" => "MultiEffects", + "phaser" => "Phaser", + "phase-vocoder" => "PhaseVocoder", + "pitch-correction" => "PitchCorrection", + "pitch-shifter" => "PitchShifter", + "restoration" => "Restoration", + "reverb" => "Reverb", + "sampler" => "Sampler", + "stereo" => "Stereo", + "surround" => "Surround", + "synthesizer" => "Synthesizer", + "transient-shaper" => "TransientShaper", + "tremolo" => "Tremolo", + "utility" => "Utility", + _ => return None, + }) +} + +pub fn validate_clap_feature(feature: &str) -> Result<()> { + clap_feature_variant(feature) + .map(|_| ()) + .ok_or_else(|| format!("unsupported CLAP feature value: {feature}").into()) +} + +pub fn four_ascii_bytes(value: &str) -> Result<[u8; 4]> { + if value.len() != 4 || !value.is_ascii() { + return Err(format!("{value} must be exactly 4 ASCII bytes").into()); + } + let bytes = value.as_bytes(); + Ok([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +pub fn fourcc(value: &str) -> Result { + let bytes = four_ascii_bytes(value)?; + Ok(((bytes[0] as u32) << 24) + | ((bytes[1] as u32) << 16) + | ((bytes[2] as u32) << 8) + | (bytes[3] as u32)) +} + +pub fn vst3_component_id_bytes(value: &str) -> Result<[u8; 16]> { + let hex = value.replace('-', ""); + if hex.len() != 32 || !hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(format!("vst3_component_id must be a UUID: {value}").into()); + } + let mut bytes = [0_u8; 16]; + for (index, byte) in bytes.iter_mut().enumerate() { + let start = index * 2; + *byte = u8::from_str_radix(&hex[start..start + 2], 16) + .map_err(|error| format!("vst3_component_id must be a UUID: {error}"))?; + } + bytes.swap(0, 3); + bytes.swap(1, 2); + bytes.swap(4, 5); + bytes.swap(6, 7); + Ok(bytes) +} + +pub fn aax_category_bits(category: &str) -> Result { + Ok(match category { + "eq" => 0x0000_0001, + "dynamics" => 0x0000_0002, + "pitch-shift" => 0x0000_0004, + "reverb" => 0x0000_0008, + "delay" => 0x0000_0010, + "modulation" => 0x0000_0020, + "harmonic" => 0x0000_0040, + "noise-reduction" => 0x0000_0080, + "dither" => 0x0000_0100, + "sound-field" => 0x0000_0200, + "hardware-generator" => 0x0000_0400, + "software-generator" => 0x0000_0800, + "wrapped-plugin" => 0x0000_1000, + "effect" => 0x0000_2000, + "midi-effect" => 0x0001_0000, + _ => return Err(format!("unsupported AAX category value: {category}").into()), + }) +} + +pub fn aax_stem_format_value(format: &str) -> Result { + Ok(match format { + "mono" => 1, + "stereo" => 0x0001_0002, + _ => return Err(format!("AAX stem format must be mono or stereo: {format}").into()), + }) +} + +fn validate_required(key: &str, value: &str) -> Result<()> { + if value.is_empty() { + Err(format!("{key} must not be empty").into()) + } else { + Ok(()) + } +} + +fn validate_four_ascii(key: &str, value: &str) -> Result<()> { + four_ascii_bytes(value) + .map(|_| ()) + .map_err(|_| format!("{key} must be exactly 4 ASCII bytes").into()) +} + +#[derive(Debug, Deserialize)] +struct DedicatedManifest { + #[allow(dead_code)] + schema_version: Option, + #[serde(default)] + package: Option, + bundle: DedicatedBundle, + #[serde(default)] + plugins: Vec, + validation: Option, +} + +#[derive(Debug, Deserialize)] +struct DedicatedBundle { + company_name: String, + auv2_manufacturer_code: String, + aax_manufacturer_id: Option, + bundle_name: String, + bundle_identifier: String, + homepage_url: String, + manual_url: String, + support_url: String, + description: String, + copyright: String, + supported_formats: Vec, +} + +impl<'de> Deserialize<'de> for ManifestPackageInfo { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + name: Option, + version: Option, + repository: Option, + #[allow(dead_code)] + version_source: Option, + } + let raw = Raw::deserialize(deserializer)?; + Ok(Self { + package_name: raw.name, + version: raw.version, + repository: raw.repository, + }) + } +} + +#[derive(Debug, Deserialize)] +struct CargoManifest { + package: CargoPackage, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + name: String, + version: String, + repository: Option, + #[serde(default)] + metadata: PackageMetadata, +} + +#[derive(Debug, Default, Deserialize)] +struct PackageMetadata { + wrac: Option, +} + +#[derive(Debug, Deserialize)] +struct LegacyWracMetadata { + manifest: Option, + company_name: Option, + auv2_manufacturer_code: Option, + aax_manufacturer_id: Option, + bundle_name: Option, + bundle_identifier: Option, + homepage_url: Option, + manual_url: Option, + support_url: Option, + description: Option, + copyright: Option, + supported_formats: Option>, + #[serde(default)] + plugins: Vec, + validation: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vst3_component_id_uses_clap_wrapper_inverse_tuid_order() { + assert_eq!( + vst3_component_id_bytes("c905bf36-234a-54d0-94f6-70d73f16a08e").unwrap(), + [ + 0x36, 0xbf, 0x05, 0xc9, 0x4a, 0x23, 0xd0, 0x54, 0x94, 0xf6, 0x70, 0xd7, 0x3f, 0x16, + 0xa0, 0x8e, + ] + ); + } + + #[test] + fn fourcc_is_big_endian_ascii() { + assert_eq!(fourcc("SnCl").unwrap(), 0x536E_436C); + } +} diff --git a/crates/wrac_wxp_gui/src/commands/resize.rs b/crates/wrac_wxp_gui/src/commands/resize.rs index 3d17b1c9..f414f4fc 100644 --- a/crates/wrac_wxp_gui/src/commands/resize.rs +++ b/crates/wrac_wxp_gui/src/commands/resize.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::json; -use wrac_clap_adapter::HostGuiResizeRequester; +use wrac_clap_adapter::HostGui; use wxp::{WxpCommandHandler, dpi::LogicalSize}; use crate::{controller::WxpGuiResizeHandle, resize_drag::WxpNativeResizeDrag}; @@ -37,7 +37,7 @@ struct EndGuiResizeDragRequest { /// `WxpGuiResizeHandle` makes future fixes apply to all wxp-based plugin GUIs. pub fn register_resize_commands( command_handler: &Rc, - host_gui_resize_requester: Arc, + host_gui: Arc, gui_resize_handle: WxpGuiResizeHandle, ) { let native_resize_drag = Rc::new(WxpNativeResizeDrag::default()); @@ -79,7 +79,7 @@ pub fn register_resize_commands( LogicalSize::new(request.width, request.height), ); let size = gui_resize_handle - .request_resize(requested, ctx.webview(), host_gui_resize_requester.as_ref()) + .request_resize(requested, ctx.webview(), host_gui.as_ref()) .map_err(|e| e.to_string())?; Ok::<_, String>(json!({ "ok": true, diff --git a/crates/wrac_wxp_gui/src/controller.rs b/crates/wrac_wxp_gui/src/controller.rs index c3509c8a..dd5a1571 100644 --- a/crates/wrac_wxp_gui/src/controller.rs +++ b/crates/wrac_wxp_gui/src/controller.rs @@ -5,8 +5,8 @@ use std::time::{Duration, Instant}; use novonotes_run_loop::{RunLoop, RunLoopLocal}; use parking_lot::Mutex; use wrac_clap_adapter::{ - GuiApi, GuiConfig, GuiResizeHints, GuiSize, HostGuiResizeRequester, HostWindow, PluginError, - PluginGuiExtension, PluginResult, + GuiApi, GuiConfig, GuiResizeHints, GuiSize, HostGui, HostWindow, PluginError, + PluginGuiExtension, PluginGuiMainThreadExtension, PluginGuiQueryExtension, PluginResult, }; use wrac_host_context::{HostContext, HostFamily, PluginFormat}; use wxp::{WebViewDispatch, dpi::LogicalSize}; @@ -682,9 +682,9 @@ impl WxpGuiResizeHandle { &self, requested: LogicalSize, web_view: &WebViewDispatch, - host_gui_resize_requester: &dyn HostGuiResizeRequester, + host_gui: &dyn HostGui, ) -> PluginResult> { - // `HostGuiResizeRequester` can be shared from Send/Sync product state, but the target + // `HostGui` can be shared from Send/Sync product state, but the target // API is a host GUI extension. Keep the "GUI command only" threading contract at the // command registration boundary rather than making this a generic background-thread API. let scale = *self.scale.lock(); @@ -693,7 +693,7 @@ impl WxpGuiResizeHandle { let gui_size = dpi.logical_size_to_gui(logical_size); let previous_revision = self.layout.accepted_size_revision(); - let resize_result = host_gui_resize_requester.request_resize(gui_size); + let resize_result = host_gui.request_resize(gui_size); let current_revision = self.layout.accepted_size_revision(); // Logic's AUv2 wrapper applies the NSView frame inside `request_resize()`, calls @@ -851,7 +851,7 @@ fn corrected_scale_for_parent(_parent: Option) -> Option bool { !is_floating && api == default_gui_api() } @@ -860,9 +860,37 @@ impl PluginGuiExtension for WxpGuiController { Some(default_gui_configuration()) } + fn get_size(&self) -> PluginResult { + let size = self.layout.accepted_size(); + log::debug!( + "wxp controller: get_size called: width={}, height={}", + size.width, + size.height + ); + Ok(size) + } + + fn can_resize(&self) -> bool { + self.layout.can_resize() + } + + fn resize_hints(&self) -> Option { + Some(self.layout.resize_hints()) + } + + fn adjust_size(&self, size: GuiSize) -> PluginResult { + Ok(self.layout.clamp_size(size)) + } +} + +impl PluginGuiMainThreadExtension for WxpGuiController { fn create(&self, configuration: GuiConfig) -> PluginResult<()> { log::debug!("wxp controller: create called: configuration={configuration:?}"); - if !self.is_api_supported(configuration.api, configuration.is_floating) { + if !PluginGuiQueryExtension::is_api_supported( + self, + configuration.api, + configuration.is_floating, + ) { log::debug!("wxp controller: create rejected unsupported configuration"); return Err(PluginError::Message("unsupported GUI configuration")); } @@ -916,28 +944,6 @@ impl PluginGuiExtension for WxpGuiController { Ok(()) } - fn get_size(&self) -> PluginResult { - let size = self.layout.accepted_size(); - log::debug!( - "wxp controller: get_size called: width={}, height={}", - size.width, - size.height - ); - Ok(size) - } - - fn can_resize(&self) -> bool { - self.layout.can_resize() - } - - fn resize_hints(&self) -> Option { - Some(self.layout.resize_hints()) - } - - fn adjust_size(&self, size: GuiSize) -> PluginResult { - Ok(self.layout.clamp_size(size)) - } - fn set_size(&self, requested_size: GuiSize) -> PluginResult<()> { let size = self.layout.clamp_size(requested_size); let previous_size = self.layout.accepted_size(); @@ -1117,6 +1123,16 @@ impl PluginGuiExtension for WxpGuiController { } } +impl PluginGuiExtension for WxpGuiController { + fn query(&self) -> &(dyn PluginGuiQueryExtension + Send + Sync) { + self + } + + fn main_thread(&self) -> &dyn PluginGuiMainThreadExtension { + self + } +} + fn drop_session(session: Option) -> bool { if let Some(mut session) = session { log::debug!("wxp controller: drop_session start"); diff --git a/crates/wrac_wxp_gui/src/runtime.rs b/crates/wrac_wxp_gui/src/runtime.rs index bc10e22f..592ca22d 100644 --- a/crates/wrac_wxp_gui/src/runtime.rs +++ b/crates/wrac_wxp_gui/src/runtime.rs @@ -11,7 +11,7 @@ use crate::window::ParentWindowHandle; thread_local! { // Native GUI objects such as WebViews are typically bound to the thread that created them. - // `WxpGuiController` lives inside a Send/Sync `PluginCore`, so runtimes are confined to TLS. + // `WxpGuiController` lives inside a Send/Sync `PluginInstance`, so runtimes are confined to TLS. static GUI_RUNTIMES: RefCell> = RefCell::new(HashMap::new()); // Keep the `!Send` run-loop guard on the GUI thread. `GuiThreadLease` is only a // cross-thread token; it releases this guard by dispatching back to the owner thread. @@ -70,7 +70,7 @@ pub trait WxpGuiRuntime: 'static { /// Factory that creates a product-specific GUI runtime. /// -/// The factory itself is `Send + Sync` because it is held inside `PluginCore`, but the +/// The factory itself is `Send + Sync` because it is held inside `PluginInstance`, but the /// runtime it returns does not need to be `Send` — it lives in the UI thread's TLS. /// Runtime creation may allocate and touch native GUI APIs; it is not realtime-safe. pub trait WxpGuiFactory: Send + Sync + 'static { diff --git a/crates/wrac_xtask/Cargo.toml b/crates/wrac_xtask/Cargo.toml index b61c4351..e06f3374 100644 --- a/crates/wrac_xtask/Cargo.toml +++ b/crates/wrac_xtask/Cargo.toml @@ -17,3 +17,4 @@ petgraph = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +wrac_manifest = { workspace = true } diff --git a/crates/wrac_xtask/src/cli.rs b/crates/wrac_xtask/src/cli.rs index 7cbc0f9e..76a0aff2 100644 --- a/crates/wrac_xtask/src/cli.rs +++ b/crates/wrac_xtask/src/cli.rs @@ -10,7 +10,7 @@ Targets: clap, vst3, au, aax, standalone Default targets: - package.metadata.wrac.supported_formats supported on this platform, plus standalone + wrac-plugin.toml supported_formats supported on this platform, plus standalone Examples: cargo xtask build @@ -24,7 +24,7 @@ Notes: -p/--package can be omitted when the workspace contains exactly one WRAC plugin package. xtask expands requested terminal tasks into a dependency graph before execution. Default target selection skips formats unsupported on the current platform and logs the reason. - Explicit plugin-format targets must be listed in package.metadata.wrac.supported_formats. + Explicit plugin-format targets must be listed in wrac-plugin.toml supported_formats. Explicit plugin-format targets fail when unsupported on the current platform. VST3/AU/AAX/standalone targets require clap-wrapper dependencies."; @@ -33,7 +33,7 @@ Targets: clap, vst3, au, aax Default targets: - package.metadata.wrac.supported_formats supported on this platform + wrac-plugin.toml supported_formats supported on this platform Examples: cargo xtask install @@ -46,7 +46,7 @@ Notes: -p/--package can be omitted when the workspace contains exactly one WRAC plugin package. install expands the selected plugin formats into a dependency graph before copying artifacts. Default target selection skips formats unsupported on the current platform and logs the reason. - Explicit targets must be listed in package.metadata.wrac.supported_formats. + Explicit targets must be listed in wrac-plugin.toml supported_formats. Explicit targets fail when unsupported on the current platform. --scope=default installs AAX system-wide and CLAP/VST3/AU user-locally. standalone is not a plugin format and cannot be installed with this command."; @@ -56,7 +56,7 @@ Targets: clap, vst3, au, aax Default targets: - package.metadata.wrac.supported_formats supported on this platform + wrac-plugin.toml supported_formats supported on this platform Examples: cargo xtask uninstall @@ -69,7 +69,7 @@ Examples: Notes: -p/--package can be omitted when the workspace contains exactly one WRAC plugin package. Default target selection skips formats unsupported on the current platform and logs the reason. - Explicit targets must be listed in package.metadata.wrac.supported_formats. + Explicit targets must be listed in wrac-plugin.toml supported_formats. Explicit targets fail when unsupported on the current platform. --scope defaults to all and removes both user-local and system-wide plugin artifacts. AAX has no user-local install scope, so --scope=all removes only its system-wide Avid bundle."; @@ -79,7 +79,7 @@ Targets: clap, vst3, au, aax Default targets: - package.metadata.wrac.supported_formats supported on this platform + wrac-plugin.toml supported_formats supported on this platform Examples: cargo xtask validate @@ -92,13 +92,13 @@ Notes: -p/--package can be omitted when the workspace contains exactly one WRAC plugin package. validate expands the selected plugin formats into a dependency graph, runs WRAC checks, then runs external validators. Default target selection skips formats unsupported on the current platform and logs the reason. - Explicit targets must be listed in package.metadata.wrac.supported_formats. + Explicit targets must be listed in wrac-plugin.toml supported_formats. Explicit targets fail when unsupported on the current platform. WRAC check violations are errors. See docs/production-readiness-checks.md for rule IDs and disable metadata. CLAP validation downloads clap-validator 0.3.2 into target/tools if needed. VST3 validation uses the VST3 validator. AU validation is available only on macOS and installs the built AU before running auval. - AAX validation requires AAX_SDK_ROOT and AAX_VALIDATOR_DSH_ARCHIVE from .env or the process environment. + AAX validation requires an AAX SDK from AAX_SDK_ROOT or the configured default path, and AAX_VALIDATOR_DSH_ARCHIVE from .env or the process environment. AAX validation runs selected AAX Validator tests by test ID through Avid's bundled DTT runner. AAX validation saves official JSON results under target/wrac-plugins//wrac/validation/aax/. AAX validation intentionally skips DSP/HDX cycle-count and page-table XML load tests. @@ -193,9 +193,16 @@ pub(crate) struct BuildArgs { value_delimiter = ',', num_args = 1.., help = "Targets to build, comma-separated.", - long_help = "Targets to build, comma-separated. Supported values are clap, vst3, au, aax, and standalone. Defaults to package.metadata.wrac.supported_formats supported on this platform plus standalone." + long_help = "Targets to build, comma-separated. Supported values are clap, vst3, au, aax, and standalone. Defaults to wrac-plugin.toml supported_formats supported on this platform plus standalone." )] pub(crate) target: Vec, + + #[arg( + long = "plugin-id", + value_name = "PLUGIN_ID", + help = "Standalone plugin ID to build when --target includes standalone." + )] + pub(crate) standalone_plugin_id: Option, } #[derive(Debug, Args)] @@ -238,7 +245,7 @@ pub(crate) struct InstallArgs { value_delimiter = ',', num_args = 1.., help = "Plugin formats to install, comma-separated.", - long_help = "Plugin formats to install, comma-separated. Supported values are clap, vst3, au, and aax. Defaults to package.metadata.wrac.supported_formats supported on this platform. standalone is not supported here." + long_help = "Plugin formats to install, comma-separated. Supported values are clap, vst3, au, and aax. Defaults to wrac-plugin.toml supported_formats supported on this platform. standalone is not supported here." )] pub(crate) target: Vec, } @@ -285,7 +292,7 @@ pub(crate) struct UninstallArgs { value_delimiter = ',', num_args = 1.., help = "Plugin formats to uninstall, comma-separated.", - long_help = "Plugin formats to uninstall, comma-separated. Supported values are clap, vst3, au, and aax. Defaults to package.metadata.wrac.supported_formats supported on this platform. standalone is not supported here." + long_help = "Plugin formats to uninstall, comma-separated. Supported values are clap, vst3, au, and aax. Defaults to wrac-plugin.toml supported_formats supported on this platform. standalone is not supported here." )] pub(crate) target: Vec, @@ -333,7 +340,7 @@ pub(crate) struct ValidateArgs { value_delimiter = ',', num_args = 1.., help = "Targets to validate, comma-separated.", - long_help = "Targets to validate, comma-separated. Supported values are clap, vst3, au, and aax. Defaults to package.metadata.wrac.supported_formats supported on this platform." + long_help = "Targets to validate, comma-separated. Supported values are clap, vst3, au, and aax. Defaults to wrac-plugin.toml supported_formats supported on this platform." )] pub(crate) target: Vec, } diff --git a/crates/wrac_xtask/src/commands.rs b/crates/wrac_xtask/src/commands.rs index cea12329..aba54c51 100644 --- a/crates/wrac_xtask/src/commands.rs +++ b/crates/wrac_xtask/src/commands.rs @@ -1,5 +1,5 @@ use std::env; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output, Stdio}; @@ -11,7 +11,7 @@ use serde_json::Value; use crate::Result; use crate::cli::{InstallScope, UninstallScope}; use crate::context::Context; -use crate::metadata::PluginMetadata; +use crate::metadata::{PluginMetadata, PluginProductMetadata}; use crate::profile::BuildProfile; use crate::targets::{Platform, PluginFormat, PluginTarget, Target, ValidateTarget}; use crate::util::{ @@ -71,10 +71,10 @@ pub(crate) fn build_gui(ctx: &Context) -> Result<()> { if !is_pnpm_workspace(ctx) { // Standalone template projects keep the frontend package under src-gui // without a repository-level package.json. - run(Command::new(npm_command(ctx.platform)) + run(Command::new(command_for_platform(ctx.platform, "npm")) .arg("install") .current_dir(ctx.gui_dir()))?; - run(Command::new(npm_command(ctx.platform)) + run(Command::new(command_for_platform(ctx.platform, "npm")) .args(["run", "build"]) .current_dir(ctx.gui_dir()))?; return Ok(()); @@ -84,15 +84,15 @@ pub(crate) fn build_gui(ctx: &Context) -> Result<()> { let dependency_names = workspace_dependency_names(&package); // build.rs embeds src-gui/dist into the plugin binary. Workspace packages such as // @novonotes/webview-bridge also need their dist before the GUI typecheck runs. - run(Command::new(pnpm_command(ctx.platform)) + run(Command::new(command_for_platform(ctx.platform, "pnpm")) .arg("install") .current_dir(&ctx.root))?; for dependency_name in dependency_names { - run(Command::new(pnpm_command(ctx.platform)) + run(Command::new(command_for_platform(ctx.platform, "pnpm")) .args(["--filter", &dependency_name, "run", "--if-present", "build"]) .current_dir(&ctx.root))?; } - run(Command::new(pnpm_command(ctx.platform)) + run(Command::new(command_for_platform(ctx.platform, "pnpm")) .args(["--filter", &package_name, "run", "build"]) .current_dir(&ctx.root))?; Ok(()) @@ -137,20 +137,32 @@ fn workspace_dependency_names(json: &Value) -> Vec { .collect() } -fn pnpm_command(platform: Platform) -> &'static str { +fn command_for_platform(platform: Platform, command: &'static str) -> OsString { if platform == Platform::Windows { - "pnpm.cmd" - } else { - "pnpm" + let candidates = [ + format!("{command}.cmd"), + format!("{command}.exe"), + command.to_string(), + ]; + for candidate in candidates { + let candidate = OsString::from(candidate); + if command_exists_on_path(&candidate) { + return candidate; + } + } } + OsString::from(command) } -fn npm_command(platform: Platform) -> &'static str { - if platform == Platform::Windows { - "npm.cmd" - } else { - "npm" - } +fn command_exists_on_path(command: &OsStr) -> bool { + let Some(paths) = env::var_os("PATH") else { + return false; + }; + command_exists_in_paths(command, env::split_paths(&paths)) +} + +fn command_exists_in_paths(command: &OsStr, paths: impl IntoIterator) -> bool { + paths.into_iter().any(|path| path.join(command).is_file()) } #[derive(Debug, Clone, Copy)] @@ -455,7 +467,7 @@ pub(crate) fn configure_wrapper( ); } - if let Some(generator) = ctx.platform.cmake_generator() { + if let Some(generator) = cmake_generator(ctx.platform)? { push_cmake_arg(&mut args, "-G"); push_cmake_arg(&mut args, generator); } @@ -487,9 +499,10 @@ pub(crate) fn build_wrapper_target( profile: BuildProfile, build: WrapperBuild, target: WrapperTarget, + standalone_plugin_id: Option<&str>, ) -> Result<()> { let build_dir = ctx.cmake_dir(build.purpose(), profile); - for cmake_target in cmake_wrapper_targets(ctx, build, target) { + for cmake_target in cmake_wrapper_targets(ctx, build, target, standalone_plugin_id)? { // Build the concrete CMake target for this DAG node instead of ALL_BUILD. // That keeps dry-run output aligned with the actual work and lets // independent format tasks fail or pass separately. @@ -544,7 +557,8 @@ pub(crate) fn build_wrapper_target( } } WrapperTarget::Standalone => { - for artifact in ctx.standalone_artifacts(profile) { + for (_, plugin) in standalone_products(ctx, standalone_plugin_id)? { + let artifact = ctx.standalone_artifact_for(profile, plugin); ensure_exists(&artifact, "standalone artifact")?; if ctx.platform == Platform::Macos { // Apply the same Gatekeeper/loader treatment to the standalone app as to plugin bundles. @@ -565,26 +579,145 @@ pub(crate) enum WrapperTarget { Standalone, } -fn cmake_wrapper_targets(ctx: &Context, build: WrapperBuild, target: WrapperTarget) -> Vec { +fn cmake_wrapper_targets( + ctx: &Context, + build: WrapperBuild, + target: WrapperTarget, + standalone_plugin_id: Option<&str>, +) -> Result> { let base = build.target_name_base(ctx); - match target { + Ok(match target { WrapperTarget::Vst3 => vec![format!("{base}_vst3")], WrapperTarget::Aax => vec![format!("{base}_aax")], WrapperTarget::Au => vec![format!("{base}_auv2")], - WrapperTarget::Standalone => ctx + WrapperTarget::Standalone => standalone_products(ctx, standalone_plugin_id)? + .iter() + .map(|(index, _)| format!("{base}_product_{index}_standalone")) + .collect::>(), + }) +} + +fn standalone_products<'a>( + ctx: &'a Context, + plugin_id: Option<&str>, +) -> Result> { + if let Some(plugin_id) = plugin_id { + return ctx .metadata .plugins .iter() .enumerate() - .map(|(index, _)| format!("{base}_product_{index}_standalone")) - .collect::>(), + .find(|(_, plugin)| plugin.plugin_id == plugin_id) + .map(|(index, plugin)| vec![(index, plugin)]) + .ok_or_else(|| format!("plugin ID not found in WRAC metadata: {plugin_id}").into()); } + Ok(ctx.metadata.plugins.iter().enumerate().collect()) } fn push_cmake_arg(args: &mut Vec, arg: impl Into) { args.push(arg.into()); } +fn cmake_generator(platform: Platform) -> Result> { + match platform { + Platform::Macos => Ok(platform.cmake_generator().map(ToOwned::to_owned)), + Platform::Windows => Ok(Some(windows_cmake_generator()?)), + Platform::Linux => Ok(None), + } +} + +fn windows_cmake_generator() -> Result { + if let Ok(generator) = env::var("WRAC_CMAKE_GENERATOR") { + let generator = generator.trim(); + if !generator.is_empty() { + return Ok(generator.to_owned()); + } + } + + ensure_visual_studio_msbuild_available()?; + let generator = latest_cmake_visual_studio_generator()?; + println!("Using CMake generator: {generator}"); + Ok(generator) +} + +fn ensure_visual_studio_msbuild_available() -> Result<()> { + let mut vswhere = Command::new(vswhere_command()); + vswhere.args([ + "-products", + "*", + "-requires", + "Microsoft.Component.MSBuild", + "-latest", + "-property", + "installationPath", + ]); + let output = run_output(&mut vswhere)?; + let installation_path = String::from_utf8(output.stdout)?; + if installation_path.trim().is_empty() { + return Err("Visual Studio with MSBuild was not found by vswhere".into()); + } + Ok(()) +} + +fn vswhere_command() -> PathBuf { + env::var_os("ProgramFiles(x86)") + .map(PathBuf::from) + .map(|program_files_x86| { + program_files_x86 + .join("Microsoft Visual Studio") + .join("Installer") + .join("vswhere.exe") + }) + .filter(|path| path.exists()) + .unwrap_or_else(|| PathBuf::from("vswhere")) +} + +fn latest_cmake_visual_studio_generator() -> Result { + let output = run_output(Command::new("cmake").arg("--help"))?; + let help = String::from_utf8(output.stdout)?; + select_latest_visual_studio_generator(&help) + .ok_or_else(|| "CMake does not list any Visual Studio generator".into()) +} + +#[cfg(test)] +fn cmake_help_lists_generator(help: &str, generator: &str) -> bool { + cmake_visual_studio_generators(help) + .into_iter() + .any(|available| available == generator) +} + +fn select_latest_visual_studio_generator(help: &str) -> Option { + cmake_visual_studio_generators(help) + .into_iter() + .filter_map(|generator| { + visual_studio_generator_version(&generator).map(|version| (version, generator)) + }) + .max_by_key(|(version, _)| *version) + .map(|(_, generator)| generator) +} + +fn visual_studio_generator_version(generator: &str) -> Option { + let mut words = generator.split_whitespace(); + match (words.next(), words.next(), words.next()) { + (Some("Visual"), Some("Studio"), Some(version)) => version.parse().ok(), + _ => None, + } +} + +fn cmake_visual_studio_generators(help: &str) -> Vec { + help.lines() + .filter_map(|line| { + let line = line + .trim_start() + .strip_prefix("* ") + .unwrap_or(line.trim_start()); + let (name, _) = line.split_once(" = ")?; + let name = name.trim(); + name.starts_with("Visual Studio").then(|| name.to_owned()) + }) + .collect() +} + fn cmake_configure_stamp_path(build_dir: &Path) -> PathBuf { build_dir.join(".wrac-configure-args") } @@ -1096,7 +1229,7 @@ fn validate_vst3_component_ids( .into()); } - println!("VST3 component IDs match package.metadata.wrac.plugins.vst3_component_id"); + println!("VST3 component IDs match plugins.vst3_component_id"); Ok(()) } @@ -1693,7 +1826,12 @@ fn ensure_vst3_validator(ctx: &Context) -> Result { } else { "validator" }; - let validator_bin_dir = ctx.target_dir.join("vst3sdk-validator").join("bin"); + let shared_validator_dir = ctx + .target_dir + .parent() + .map(|path| path.join("vst3sdk-validator")) + .unwrap_or_else(|| ctx.target_dir.join("vst3sdk-validator")); + let validator_bin_dir = shared_validator_dir.join("bin"); let validator = validator_bin_dir.join("Debug").join(executable); let validator_without_config = validator_bin_dir.join(executable); @@ -1705,8 +1843,9 @@ fn ensure_vst3_validator(ctx: &Context) -> Result { } // The validator is a verification tool, not a shipping artifact. - // It is independent of the plugin's release/debug profile, so a single Debug build is reused for both profiles. - let build_dir = ctx.target_dir.join("vst3sdk-validator"); + // It is independent of the plugin and release/debug profile, so one Debug build is + // shared by all plugin validations in the same target namespace. + let build_dir = shared_validator_dir; let mut configure = Command::new("cmake"); configure .arg("-S") @@ -1721,14 +1860,28 @@ fn ensure_vst3_validator(ctx: &Context) -> Result { } run(configure.current_dir(&ctx.root))?; - run(Command::new("cmake") + let mut build = Command::new("cmake"); + build .arg("--build") .arg(&build_dir) .arg("--target") .arg("validator") .arg("--config") - .arg("Debug") - .current_dir(&ctx.root))?; + .arg("Debug"); + if ctx.platform == Platform::Macos { + build.args([ + "--", + "-quiet", + "OTHER_CPLUSPLUSFLAGS=$(inherited) -Wno-unknown-warning-option -Wno-gnu-statement-expression-from-macro-expansion -Wno-shorten-64-to-32 -Wno-perf-constraint-implies-noexcept", + ]); + } + + let build = build.current_dir(&ctx.root); + if ctx.platform == Platform::Macos { + run_with_optional_xcbeautify(build)?; + } else { + run(build)?; + } if validator.exists() { Ok(validator) @@ -1782,7 +1935,7 @@ fn ensure_au_sdk_input(ctx: &Context) -> Result<()> { fn ensure_aax_sdk_input(ctx: &Context) -> Result<()> { let root = aax_sdk_root(ctx)?; - ensure_exists(&root.join("Interfaces").join("AAX.h"), "AAX SDK") + ensure_aax_sdk_exists(&root) } fn aax_sdk_root(ctx: &Context) -> Result { @@ -1790,11 +1943,28 @@ fn aax_sdk_root(ctx: &Context) -> Result { // clap-wrapper evaluates AAX_SDK_ROOT inside its CMake project, so a relative // path would be resolved against clap_wrapper_builder rather than this repo. // Resolve relative .env and CI paths from the repository root instead. - ensure_exists(&root.join("Interfaces").join("AAX.h"), "AAX SDK")?; + ensure_aax_sdk_exists(&root)?; return Ok(root); } - Err("AAX SDK not found. Set AAX_SDK_ROOT in .env or the process environment.".into()) + if let Some(root) = config_path(ctx, ctx.default_aax_sdk_root.as_ref()) { + ensure_aax_sdk_exists(&root)?; + return Ok(root); + } + + Err("AAX SDK not found.\nRun `cargo xtask setup` to download the repository-local AAX SDK, then retry.".into()) +} + +fn ensure_aax_sdk_exists(root: &Path) -> Result<()> { + let header = root.join("Interfaces").join("AAX.h"); + if header.exists() { + return Ok(()); + } + Err(format!( + "AAX SDK not found: {}\nRun `cargo xtask setup` to download the repository-local AAX SDK, then retry.", + header.display() + ) + .into()) } fn env_path(ctx: &Context, key: &str) -> Result> { @@ -1815,7 +1985,21 @@ fn env_path(ctx: &Context, key: &str) -> Result> { } } -pub(crate) fn print_outputs(ctx: &Context, profile: BuildProfile, targets: &[Target]) { +fn config_path(ctx: &Context, path: Option<&PathBuf>) -> Option { + let path = path?; + if path.is_absolute() { + Some(path.clone()) + } else { + Some(ctx.root.join(path)) + } +} + +pub(crate) fn print_outputs( + ctx: &Context, + profile: BuildProfile, + targets: &[Target], + standalone_plugin_id: Option<&str>, +) -> Result<()> { for target in targets { match target { Target::Clap => println!("CLAP: {}", ctx.clap_bundle(profile).display()), @@ -1827,12 +2011,14 @@ pub(crate) fn print_outputs(ctx: &Context, profile: BuildProfile, targets: &[Tar } } Target::Standalone => { - for artifact in ctx.standalone_artifacts(profile) { + for (_, plugin) in standalone_products(ctx, standalone_plugin_id)? { + let artifact = ctx.standalone_artifact_for(profile, plugin); println!("Standalone: {}", artifact.display()); } } } } + Ok(()) } fn macos_clap_info_plist(metadata: &PluginMetadata) -> String { @@ -1907,6 +2093,29 @@ fn codesign_nested_macos_bundle(bundle: &Path) -> Result<()> { mod tests { use super::*; + #[test] + fn command_exists_in_paths_checks_exact_candidate_files() { + let temp_dir = std::env::temp_dir().join(format!( + "wrac_xtask_command_path_test_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + fs::write(temp_dir.join("pnpm.exe"), "").unwrap(); + + assert!(command_exists_in_paths( + OsStr::new("pnpm.exe"), + [temp_dir.clone()] + )); + assert!(!command_exists_in_paths( + OsStr::new("pnpm.cmd"), + [temp_dir.clone()] + )); + + fs::remove_dir_all(temp_dir).unwrap(); + } + #[test] fn parses_vst3_validator_cids_from_logged_output() { let output = r#" @@ -1927,4 +2136,45 @@ mod tests { ] ); } + + #[test] + fn parses_visual_studio_generators_from_cmake_help() { + let help = r#" +Generators + +The following generators are available on this platform (* marks default): +* Visual Studio 18 2026 = Generates Visual Studio 2026 project files. + Visual Studio 17 2022 = Generates Visual Studio 2022 project files. + Ninja = Generates build.ninja files. +"#; + + assert!(cmake_help_lists_generator(help, "Visual Studio 18 2026")); + assert!(cmake_help_lists_generator(help, "Visual Studio 17 2022")); + assert!(!cmake_help_lists_generator(help, "Visual Studio 16 2019")); + assert_eq!( + cmake_visual_studio_generators(help), + vec![ + "Visual Studio 18 2026".to_owned(), + "Visual Studio 17 2022".to_owned(), + ] + ); + } + + #[test] + fn selects_latest_visual_studio_generator_from_cmake_help() { + let help = r#" +Generators + +The following generators are available on this platform (* marks default): + Visual Studio 17 2022 = Generates Visual Studio 2022 project files. +* Visual Studio 16 2019 = Generates Visual Studio 2019 project files. + Visual Studio 15 2017 [arch] = Generates Visual Studio 2017 project files. + Ninja = Generates build.ninja files. +"#; + + assert_eq!( + select_latest_visual_studio_generator(help), + Some("Visual Studio 17 2022".to_owned()) + ); + } } diff --git a/crates/wrac_xtask/src/context.rs b/crates/wrac_xtask/src/context.rs index f1781579..5ef347a0 100644 --- a/crates/wrac_xtask/src/context.rs +++ b/crates/wrac_xtask/src/context.rs @@ -5,26 +5,17 @@ use cargo_metadata::MetadataCommand; use crate::metadata::{PluginMetadata, PluginProductMetadata}; use crate::profile::BuildProfile; use crate::targets::Platform; -use crate::{Result, XtaskConfig}; - -#[derive(Debug, Clone)] -pub(crate) struct PluginPackage { - pub(crate) package_name: String, - pub(crate) artifact_namespace: String, - pub(crate) manifest_path: PathBuf, - pub(crate) package_dir: PathBuf, - pub(crate) plugin_root: PathBuf, -} +use crate::{Result, WracPluginPackage, XtaskConfig}; pub(crate) struct Context { pub(crate) root: PathBuf, pub(crate) package_name: String, - pub(crate) package_dir: PathBuf, pub(crate) plugin_root: PathBuf, pub(crate) manifest_path: PathBuf, pub(crate) platform: Platform, pub(crate) target_dir: PathBuf, pub(crate) wrapper_dir: PathBuf, + pub(crate) default_aax_sdk_root: Option, pub(crate) metadata: PluginMetadata, } @@ -46,36 +37,26 @@ impl Context { let wrapper_dir = std::env::var_os("CLAP_WRAPPER_DIR") .map(PathBuf::from) .unwrap_or_else(|| config.wrapper_dir.clone()); - // Plugin identity is sourced from [package.metadata.wrac] in src-plugin/Cargo.toml. - // Maintaining separate bundle names or wrapper arguments in xtask risks stale build artifacts on rename. - let metadata = PluginMetadata::read(&package.manifest_path)?; + // Plugin identity is sourced from wrac-plugin.toml, with legacy Cargo + // metadata supported only as a migration fallback. + let metadata = + PluginMetadata::read_discovered(&package.manifest_path, &package.plugin_root)?; Ok(Self { root: config.root.clone(), package_name: package.package_name, - package_dir: package.package_dir, plugin_root: package.plugin_root, manifest_path: package.manifest_path, platform: Platform::detect()?, target_dir, wrapper_dir, + default_aax_sdk_root: config.default_aax_sdk_root.clone(), metadata, }) } pub(crate) fn gui_dir(&self) -> PathBuf { - let package_gui_dir = self.package_dir.join("src-gui"); - if package_gui_dir.join("package.json").exists() { - return package_gui_dir; - } - let plugin_root_gui_dir = self.plugin_root.join("src-gui"); - if plugin_root_gui_dir.join("package.json").exists() { - return plugin_root_gui_dir; - } - // Some product repos keep the frontend package at the plugin root while - // the Rust crate lives in src-plugin. Build that package so release - // artifacts do not depend on checked-in dist files. - self.plugin_root.clone() + self.plugin_root.join("src-gui") } pub(crate) fn plugin_manifest(&self) -> PathBuf { @@ -135,14 +116,6 @@ impl Context { .join(self.metadata.au_bundle_name()) } - pub(crate) fn standalone_artifacts(&self, profile: BuildProfile) -> Vec { - self.metadata - .plugins - .iter() - .map(|plugin| self.standalone_artifact_for(profile, plugin)) - .collect() - } - pub(crate) fn standalone_artifact_for( &self, profile: BuildProfile, @@ -167,16 +140,13 @@ impl Context { } } -pub(crate) fn available_packages(config: &XtaskConfig) -> Result> { +pub(crate) fn available_packages(config: &XtaskConfig) -> Result> { let metadata = MetadataCommand::new() .manifest_path(config.root.join("Cargo.toml")) .exec()?; let mut packages = Vec::new(); for package in metadata.workspace_packages() { - if package.metadata.get("wrac").is_none() { - continue; - } let manifest_path = package.manifest_path.clone().into_std_path_buf(); let package_dir = manifest_path .parent() @@ -206,11 +176,22 @@ pub(crate) fn available_packages(config: &XtaskConfig) -> Result path.exists(), + wrac_manifest::ManifestSource::LegacyCargoMetadata(_) => { + package.metadata.get("wrac").is_some() + } + }) + .unwrap_or(false); + if !has_manifest { + continue; + } + validate_plugin_layout(&package_dir, &plugin_root)?; + packages.push(WracPluginPackage { package_name: package.name.clone(), artifact_namespace, manifest_path, - package_dir, plugin_root, }); } @@ -218,7 +199,7 @@ pub(crate) fn available_packages(config: &XtaskConfig) -> Result Result { +fn find_package(config: &XtaskConfig, package_name: &str) -> Result { let packages = available_packages(config)?; for package in &packages { if package.package_name == package_name { @@ -236,3 +217,106 @@ fn find_package(config: &XtaskConfig, package_name: &str) -> Result Result<()> { + if package_dir.file_name().and_then(|name| name.to_str()) != Some("src-plugin") { + return Err(format!( + "WRAC plugin package must live at /src-plugin, but found {}", + package_dir.display() + ) + .into()); + } + + let root_package_json = plugin_root.join("package.json"); + if root_package_json.exists() { + return Err(format!( + "WRAC plugin frontend package must live at /src-gui/package.json, but found {}", + root_package_json.display() + ) + .into()); + } + + let nested_package_json = package_dir.join("src-gui").join("package.json"); + if nested_package_json.exists() { + return Err(format!( + "WRAC plugin frontend package must live at /src-gui/package.json, but found {}", + nested_package_json.display() + ) + .into()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::validate_plugin_layout; + + #[test] + fn accepts_conventional_plugin_layout() { + let root = temp_dir("conventional"); + let plugin_root = root.join("plugins").join("wrac-gain"); + let package_dir = plugin_root.join("src-plugin"); + fs::create_dir_all(&package_dir).unwrap(); + + validate_plugin_layout(&package_dir, &plugin_root).unwrap(); + } + + #[test] + fn rejects_plugin_crate_outside_src_plugin() { + let root = temp_dir("plugin-crate-outside-src-plugin"); + let plugin_root = root.join("plugins").join("wrac-gain"); + let package_dir = plugin_root.clone(); + fs::create_dir_all(&package_dir).unwrap(); + + let error = validate_plugin_layout(&package_dir, &plugin_root) + .unwrap_err() + .to_string(); + + assert!(error.contains("/src-plugin")); + } + + #[test] + fn rejects_frontend_package_at_plugin_root() { + let root = temp_dir("frontend-at-plugin-root"); + let plugin_root = root.join("plugins").join("wrac-gain"); + let package_dir = plugin_root.join("src-plugin"); + fs::create_dir_all(&package_dir).unwrap(); + fs::write(plugin_root.join("package.json"), "{}").unwrap(); + + let error = validate_plugin_layout(&package_dir, &plugin_root) + .unwrap_err() + .to_string(); + + assert!(error.contains("/src-gui/package.json")); + } + + fn temp_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "wrac_xtask_layout_test_{}_{}_{}", + std::process::id(), + nanos, + name + )); + reset_dir(&path); + fs::create_dir_all(&path).unwrap(); + path + } + + fn reset_dir(path: &Path) { + if path.exists() { + fs::remove_dir_all(path).unwrap(); + } + } +} diff --git a/crates/wrac_xtask/src/lib.rs b/crates/wrac_xtask/src/lib.rs index 5cf0bcbf..e937ae80 100644 --- a/crates/wrac_xtask/src/lib.rs +++ b/crates/wrac_xtask/src/lib.rs @@ -1,3 +1,10 @@ +//! Shared implementation of the standard WRAC `cargo xtask` command surface. +//! +//! Repository-local `xtask` crates provide workspace paths and wrapper settings, +//! then delegate build, install, launch, validate, uninstall, and clean behavior +//! to this crate. Keeping the command implementation here prevents template and +//! product repositories from drifting apart. + use std::env; use std::error::Error; use std::path::PathBuf; @@ -10,119 +17,372 @@ mod context; mod metadata; mod plan; mod profile; -mod targets; +pub mod targets; mod util; mod validation; -use cli::{Cli, Commands}; +use cli::{CleanArgs, InstallScope, UninstallScope}; use commands::{clean, launch}; use context::{Context, available_packages}; use profile::BuildProfile; +use targets::{PluginTarget, Target, ValidateTarget}; pub type Result = std::result::Result>; +/// Parses the standard WRAC xtask CLI into a typed command. +/// +/// Repository-local xtasks can provide only workspace wiring and delegate the +/// WRAC command surface to this crate, which keeps help text and behavior from +/// drifting between the template and downstream product repositories. +pub fn command_from_args() -> WracCommand { + cli::Cli::parse().command.into() +} + #[derive(Debug, Clone)] pub struct XtaskConfig { pub root: PathBuf, pub wrapper_dir: PathBuf, pub target_namespace: String, + pub default_aax_sdk_root: Option, } -pub fn run(config: XtaskConfig) -> Result<()> { - load_workspace_dotenv(&config)?; - let cli = Cli::parse(); - - match cli.command { - Commands::Build(args) => { - // Keep build/install logic scoped to one plugin package at a time. A package may - // export multiple plugin products; the shared Context is still the correct unit for - // metadata, GUI assets, wrapper staging, and install paths. - let mut failures = Vec::new(); - for package in selected_packages(&config, args.package.as_deref(), args.all)? { - let ctx = Context::new(&config, &package)?; - if let Err(err) = plan::run_build(&ctx, &args) { - if args.continue_on_error { - failures.push(format!("{package}: {err}")); - } else { - return Err(err); - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WracPluginPackage { + pub package_name: String, + pub artifact_namespace: String, + pub manifest_path: PathBuf, + pub plugin_root: PathBuf, +} + +/// Discovers WRAC plugin packages from workspace metadata. +/// +/// This is the same package discovery used by `--all` commands, exposed for +/// repository-local automation that needs to build CI matrices without +/// duplicating Cargo metadata and WRAC manifest lookup rules. +pub fn discover_plugin_packages(config: &XtaskConfig) -> Result> { + available_packages(config) +} + +#[derive(Debug, Clone)] +pub struct WracWorkspace { + config: XtaskConfig, +} + +#[derive(Debug, Clone)] +pub enum WracCommand { + Build(BuildOptions), + Install(InstallOptions), + Uninstall(UninstallOptions), + Validate(ValidateOptions), + Launch(LaunchOptions), + Clean(CleanOptions), +} + +#[derive(Debug, Clone, Default)] +pub struct BuildOptions { + pub package: Option, + pub all: bool, + pub release: bool, + pub clean: bool, + pub dry_run: bool, + pub continue_on_error: bool, + pub target: Vec, + pub plugin_id: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct InstallOptions { + pub package: Option, + pub all: bool, + pub release: bool, + pub scope: WracInstallScope, + pub dry_run: bool, + pub continue_on_error: bool, + pub target: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct UninstallOptions { + pub package: Option, + pub all: bool, + pub scope: WracUninstallScope, + pub target: Vec, + pub dry_run: bool, + pub continue_on_error: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ValidateOptions { + pub package: Option, + pub all: bool, + pub release: bool, + pub dry_run: bool, + pub continue_on_error: bool, + pub target: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct LaunchOptions { + pub package: Option, + pub release: bool, + pub plugin_id: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CleanOptions { + pub package: Option, + pub all: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WracInstallScope { + Default, + User, + System, +} + +impl Default for WracInstallScope { + fn default() -> Self { + Self::Default + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WracUninstallScope { + All, + User, + System, +} + +impl Default for WracUninstallScope { + fn default() -> Self { + Self::All + } +} + +impl WracWorkspace { + pub fn new(config: XtaskConfig) -> Result { + load_workspace_dotenv(&config)?; + Ok(Self { config }) + } + + pub fn run(&self, command: WracCommand) -> Result<()> { + match command { + WracCommand::Build(options) => { + self.run_build(options)?; } - if !failures.is_empty() { - return Err(failures.join("\n").into()); + WracCommand::Install(options) => { + self.run_install(options)?; } - } - Commands::Install(args) => { - let mut failures = Vec::new(); - for package in selected_packages(&config, args.package.as_deref(), args.all)? { - let ctx = Context::new(&config, &package)?; - if let Err(err) = plan::run_install(&ctx, &args) { - if args.continue_on_error { - failures.push(format!("{package}: {err}")); - } else { - return Err(err); - } - } + WracCommand::Uninstall(options) => { + self.run_uninstall(options)?; + } + WracCommand::Validate(options) => { + self.run_validate(options)?; + } + WracCommand::Launch(options) => { + self.run_launch(options)?; } - if !failures.is_empty() { - return Err(failures.join("\n").into()); + WracCommand::Clean(options) => { + self.run_clean(options)?; } } - Commands::Uninstall(args) => { - let mut failures = Vec::new(); - for package in selected_packages(&config, args.package.as_deref(), args.all)? { - let ctx = Context::new(&config, &package)?; - if let Err(err) = plan::run_uninstall(&ctx, &args) { - if args.continue_on_error { - failures.push(format!("{package}: {err}")); - } else { - return Err(err); - } + Ok(()) + } + + fn run_build(&self, options: BuildOptions) -> Result<()> { + let args = cli::BuildArgs { + package: options.package, + all: options.all, + release: options.release, + clean: options.clean, + dry_run: options.dry_run, + continue_on_error: options.continue_on_error, + target: options.target, + standalone_plugin_id: options.plugin_id, + }; + // Keep build/install logic scoped to one plugin package at a time. A package may + // export multiple plugin products; the shared Context is still the correct unit for + // metadata, GUI assets, wrapper staging, and install paths. + let mut failures = Vec::new(); + for package in selected_packages(&self.config, args.package.as_deref(), args.all)? { + let ctx = Context::new(&self.config, &package)?; + if let Err(err) = plan::run_build(&ctx, &args) { + if args.continue_on_error { + failures.push(format!("{package}: {err}")); + } else { + return Err(err); } } - if !failures.is_empty() { - return Err(failures.join("\n").into()); - } } - Commands::Validate(args) => { - let mut failures = Vec::new(); - for package in selected_packages(&config, args.package.as_deref(), args.all)? { - let ctx = Context::new(&config, &package)?; - if let Err(err) = plan::run_validate(&ctx, &args) { - if args.continue_on_error { - failures.push(format!("{package}: {err}")); - } else { - return Err(err); - } + if !failures.is_empty() { + return Err(failures.join("\n").into()); + } + Ok(()) + } + + fn run_install(&self, options: InstallOptions) -> Result<()> { + let args = cli::InstallArgs { + package: options.package, + all: options.all, + release: options.release, + scope: options.scope.into(), + dry_run: options.dry_run, + continue_on_error: options.continue_on_error, + target: options.target, + }; + let mut failures = Vec::new(); + for package in selected_packages(&self.config, args.package.as_deref(), args.all)? { + let ctx = Context::new(&self.config, &package)?; + if let Err(err) = plan::run_install(&ctx, &args) { + if args.continue_on_error { + failures.push(format!("{package}: {err}")); + } else { + return Err(err); } } - if !failures.is_empty() { - return Err(failures.join("\n").into()); + } + if !failures.is_empty() { + return Err(failures.join("\n").into()); + } + Ok(()) + } + + fn run_uninstall(&self, options: UninstallOptions) -> Result<()> { + let args = cli::UninstallArgs { + package: options.package, + all: options.all, + scope: options.scope.into(), + target: options.target, + dry_run: options.dry_run, + continue_on_error: options.continue_on_error, + }; + let mut failures = Vec::new(); + for package in selected_packages(&self.config, args.package.as_deref(), args.all)? { + let ctx = Context::new(&self.config, &package)?; + if let Err(err) = plan::run_uninstall(&ctx, &args) { + if args.continue_on_error { + failures.push(format!("{package}: {err}")); + } else { + return Err(err); + } } } - Commands::Launch(args) => { - let package = selected_package(&config, args.package.as_deref())?; - let ctx = Context::new(&config, &package)?; - // Validate product selection before the implicit standalone build. - // A typo in --plugin-id is independent of artifacts and should not - // spend time configuring CMake or building wrapper dependencies. - commands::ensure_launch_target_exists(&ctx, args.plugin_id.as_deref())?; - plan::run_build(&ctx, &args_for_launch_build(&args))?; - launch( - &ctx, - BuildProfile::from_release(args.release), - args.plugin_id.as_deref(), - )?; - } - Commands::Clean(args) => { - for package in selected_packages(&config, args.package.as_deref(), args.all)? { - let ctx = Context::new(&config, &package)?; - clean(&ctx)?; + if !failures.is_empty() { + return Err(failures.join("\n").into()); + } + Ok(()) + } + + fn run_validate(&self, options: ValidateOptions) -> Result<()> { + let args = cli::ValidateArgs { + package: options.package, + all: options.all, + release: options.release, + dry_run: options.dry_run, + continue_on_error: options.continue_on_error, + target: options.target, + }; + let mut failures = Vec::new(); + for package in selected_packages(&self.config, args.package.as_deref(), args.all)? { + let ctx = Context::new(&self.config, &package)?; + if let Err(err) = plan::run_validate(&ctx, &args) { + if args.continue_on_error { + failures.push(format!("{package}: {err}")); + } else { + return Err(err); + } } } + if !failures.is_empty() { + return Err(failures.join("\n").into()); + } + Ok(()) } - Ok(()) + fn run_launch(&self, options: LaunchOptions) -> Result<()> { + let args = cli::LaunchArgs { + package: options.package, + release: options.release, + plugin_id: options.plugin_id, + }; + let package = selected_package(&self.config, args.package.as_deref())?; + let ctx = Context::new(&self.config, &package)?; + // Validate product selection before the implicit standalone build. + // A typo in --plugin-id is independent of artifacts and should not + // spend time configuring CMake or building wrapper dependencies. + commands::ensure_launch_target_exists(&ctx, args.plugin_id.as_deref())?; + plan::run_build(&ctx, &args_for_launch_build(&args))?; + launch( + &ctx, + BuildProfile::from_release(args.release), + args.plugin_id.as_deref(), + )?; + Ok(()) + } + + fn run_clean(&self, options: CleanOptions) -> Result<()> { + let args = CleanArgs { + package: options.package, + all: options.all, + }; + for package in selected_packages(&self.config, args.package.as_deref(), args.all)? { + let ctx = Context::new(&self.config, &package)?; + clean(&ctx)?; + } + Ok(()) + } +} + +impl From for WracCommand { + fn from(command: cli::Commands) -> Self { + match command { + cli::Commands::Build(args) => Self::Build(BuildOptions { + package: args.package, + all: args.all, + release: args.release, + clean: args.clean, + dry_run: args.dry_run, + continue_on_error: args.continue_on_error, + target: args.target, + plugin_id: args.standalone_plugin_id, + }), + cli::Commands::Install(args) => Self::Install(InstallOptions { + package: args.package, + all: args.all, + release: args.release, + scope: args.scope.into(), + dry_run: args.dry_run, + continue_on_error: args.continue_on_error, + target: args.target, + }), + cli::Commands::Uninstall(args) => Self::Uninstall(UninstallOptions { + package: args.package, + all: args.all, + scope: args.scope.into(), + target: args.target, + dry_run: args.dry_run, + continue_on_error: args.continue_on_error, + }), + cli::Commands::Validate(args) => Self::Validate(ValidateOptions { + package: args.package, + all: args.all, + release: args.release, + dry_run: args.dry_run, + continue_on_error: args.continue_on_error, + target: args.target, + }), + cli::Commands::Launch(args) => Self::Launch(LaunchOptions { + package: args.package, + release: args.release, + plugin_id: args.plugin_id, + }), + cli::Commands::Clean(args) => Self::Clean(CleanOptions { + package: args.package, + all: args.all, + }), + } + } } fn load_workspace_dotenv(config: &XtaskConfig) -> Result<()> { @@ -205,5 +465,46 @@ fn args_for_launch_build(args: &cli::LaunchArgs) -> cli::BuildArgs { dry_run: false, continue_on_error: false, target: vec![targets::Target::Standalone], + standalone_plugin_id: args.plugin_id.clone(), + } +} + +impl From for InstallScope { + fn from(scope: WracInstallScope) -> Self { + match scope { + WracInstallScope::Default => Self::Default, + WracInstallScope::User => Self::User, + WracInstallScope::System => Self::System, + } + } +} + +impl From for WracInstallScope { + fn from(scope: InstallScope) -> Self { + match scope { + InstallScope::Default => Self::Default, + InstallScope::User => Self::User, + InstallScope::System => Self::System, + } + } +} + +impl From for UninstallScope { + fn from(scope: WracUninstallScope) -> Self { + match scope { + WracUninstallScope::All => Self::All, + WracUninstallScope::User => Self::User, + WracUninstallScope::System => Self::System, + } + } +} + +impl From for WracUninstallScope { + fn from(scope: UninstallScope) -> Self { + match scope { + UninstallScope::All => Self::All, + UninstallScope::User => Self::User, + UninstallScope::System => Self::System, + } } } diff --git a/crates/wrac_xtask/src/metadata.rs b/crates/wrac_xtask/src/metadata.rs index 7bd0cb7a..11d3c3b4 100644 --- a/crates/wrac_xtask/src/metadata.rs +++ b/crates/wrac_xtask/src/metadata.rs @@ -1,13 +1,12 @@ -use std::collections::{HashMap, HashSet}; -use std::fs; use std::path::Path; -use serde::Deserialize; - use crate::Result; use crate::targets::PluginFormat; +pub(crate) use wrac_manifest::{AaxStemConfig as AaxStemConfigMetadata, ValidationMetadata}; + #[derive(Debug, Clone)] +#[allow(dead_code)] pub(crate) struct PluginMetadata { pub(crate) package_name: String, pub(crate) version: String, @@ -22,34 +21,13 @@ pub(crate) struct PluginMetadata { pub(crate) support_url: String, pub(crate) description: String, pub(crate) copyright: String, - // Product-level plugin format policy. xtask uses this for default build, - // install, and validate selections so AAX adoption is decided once in - // metadata instead of repeated on every command line. pub(crate) supported_formats: Vec, pub(crate) plugins: Vec, pub(crate) validation: ValidationMetadata, } -#[derive(Debug, Clone, Default, Deserialize)] -pub(crate) struct ValidationMetadata { - #[serde(default)] - pub(crate) disabled_rules: HashMap, - #[serde(default)] - pub(crate) clap_validator: ClapValidatorMetadata, -} - -#[derive(Debug, Clone, Deserialize)] -pub(crate) struct DisabledValidationRule { - pub(crate) reason: String, -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub(crate) struct ClapValidatorMetadata { - pub(crate) skip_test_filter: Option, - pub(crate) skip_reason: Option, -} - -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] +#[allow(dead_code)] pub(crate) struct PluginProductMetadata { pub(crate) plugin_id: String, pub(crate) plugin_name: String, @@ -61,48 +39,24 @@ pub(crate) struct PluginProductMetadata { pub(crate) auv2_subtype: String, pub(crate) aax_categories: Option>, pub(crate) aax_product_id: Option, - #[serde(default)] pub(crate) aax_stem_configs: Vec, } -#[derive(Debug, Clone, Deserialize)] -pub(crate) struct AaxStemConfigMetadata { - pub(crate) name: String, - pub(crate) input: String, - pub(crate) output: String, - pub(crate) plugin_id: String, -} - impl PluginMetadata { - pub(crate) fn read(manifest_path: &Path) -> Result { - let manifest = fs::read_to_string(manifest_path)?; - let cargo_manifest: CargoManifest = toml::from_str(&manifest)?; - let wrac = cargo_manifest.package.metadata.wrac.ok_or_else(|| { - format!( - "missing package.metadata.wrac in {}", - manifest_path.display() - ) - })?; - let metadata = Self { - package_name: cargo_manifest.package.name, - version: cargo_manifest.package.version, - repository: cargo_manifest.package.repository, - company_name: wrac.company_name, - auv2_manufacturer_code: wrac.auv2_manufacturer_code, - aax_manufacturer_id: wrac.aax_manufacturer_id, - bundle_name: wrac.bundle_name, - bundle_identifier: wrac.bundle_identifier, - homepage_url: wrac.homepage_url, - manual_url: wrac.manual_url, - support_url: wrac.support_url, - description: wrac.description, - copyright: wrac.copyright, - supported_formats: wrac.supported_formats, - plugins: wrac.plugins, - validation: wrac.validation.unwrap_or_default(), - }; - metadata.validate()?; - Ok(metadata) + pub(crate) fn read_discovered(manifest_path: &Path, plugin_root: &Path) -> Result { + let source = wrac_manifest::discover_manifest(manifest_path, plugin_root)?; + let mut manifest = wrac_manifest::read_manifest(&source)?; + let cargo_package = wrac_manifest::read_cargo_package_info(manifest_path)?; + if manifest.package.package_name.is_none() { + manifest.package.package_name = cargo_package.package_name; + } + if manifest.package.version.is_none() { + manifest.package.version = cargo_package.version; + } + if manifest.package.repository.is_none() { + manifest.package.repository = cargo_package.repository; + } + Self::from_manifest(manifest) } pub(crate) fn clap_bundle_name(&self) -> String { @@ -122,274 +76,118 @@ impl PluginMetadata { } pub(crate) fn bundle_identity_plugin(&self) -> &PluginProductMetadata { - // CLAP bundle Info.plist has one CFBundleIdentifier even when the CLAP - // factory exposes multiple products. Use the first metadata entry only - // for that bundle-level identifier; product-specific outputs must still - // iterate over `plugins`. self.plugins .first() .expect("validated metadata must contain at least one plugin") } - fn validate(&self) -> Result<()> { - validate_required("package.name", &self.package_name)?; - validate_required("package.version", &self.version)?; - validate_required("package.metadata.wrac.company_name", &self.company_name)?; - validate_four_ascii("auv2_manufacturer_code", &self.auv2_manufacturer_code)?; - validate_required("package.metadata.wrac.bundle_name", &self.bundle_name)?; - validate_required( - "package.metadata.wrac.bundle_identifier", - &self.bundle_identifier, - )?; - validate_required("package.metadata.wrac.homepage_url", &self.homepage_url)?; - validate_required("package.metadata.wrac.manual_url", &self.manual_url)?; - validate_required("package.metadata.wrac.support_url", &self.support_url)?; - validate_required("package.metadata.wrac.description", &self.description)?; - validate_required("package.metadata.wrac.copyright", &self.copyright)?; - if self.supported_formats.is_empty() { - return Err("package.metadata.wrac.supported_formats must not be empty".into()); - } - // Treat duplicates as metadata errors rather than silently deduplicating: - // supported_formats is commercial product policy, so ambiguity here is - // more likely to hide a setup mistake than help a caller. - let mut supported_formats = HashSet::new(); - for format in &self.supported_formats { - if !supported_formats.insert(*format) { - return Err(format!( - "duplicate package.metadata.wrac.supported_formats entry: {}", - format.display() - ) - .into()); - } - } - let supports_aax = supported_formats.contains(&PluginFormat::Aax); - if supports_aax { - let Some(aax_manufacturer_id) = self.aax_manufacturer_id.as_ref() else { - return Err("package.metadata.wrac.aax_manufacturer_id is required when supported_formats contains aax".into()); - }; - validate_four_ascii("aax_manufacturer_id", aax_manufacturer_id)?; - } - if self.plugins.is_empty() { - return Err("package.metadata.wrac.plugins must contain at least one plugin".into()); - } - let mut plugin_ids = HashSet::new(); - let mut standalone_names = HashSet::new(); - let mut auv2_ids = HashSet::new(); - for plugin in &self.plugins { - validate_required("package.metadata.wrac.plugins.plugin_id", &plugin.plugin_id)?; - validate_required( - "package.metadata.wrac.plugins.plugin_name", - &plugin.plugin_name, - )?; - if plugin.clap_features.is_empty() { - return Err("package.metadata.wrac.plugins.clap_features must not be empty".into()); - } - for feature in &plugin.clap_features { - validate_required("package.metadata.wrac.plugins.clap_features", feature)?; - validate_clap_feature(feature)?; - } - validate_required( - "package.metadata.wrac.plugins.vst3_subcategories", - &plugin.vst3_subcategories, - )?; - validate_uuid( - "package.metadata.wrac.plugins.vst3_component_id", - &plugin.vst3_component_id, - )?; - validate_required( - "package.metadata.wrac.plugins.standalone_name", - &plugin.standalone_name, - )?; - validate_four_ascii("auv2_type", &plugin.auv2_type)?; - validate_four_ascii("auv2_subtype", &plugin.auv2_subtype)?; - if supports_aax { - let Some(aax_categories) = plugin.aax_categories.as_ref() else { - return Err("package.metadata.wrac.plugins.aax_categories is required when supported_formats contains aax".into()); - }; - if aax_categories.is_empty() { - return Err( - "package.metadata.wrac.plugins.aax_categories must not be empty".into(), - ); - } - for category in aax_categories { - validate_aax_category(category)?; - } - let Some(aax_product_id) = plugin.aax_product_id.as_ref() else { - return Err("package.metadata.wrac.plugins.aax_product_id is required when supported_formats contains aax".into()); - }; - validate_four_ascii("plugins.aax_product_id", aax_product_id)?; - if plugin.aax_stem_configs.is_empty() { - return Err( - "package.metadata.wrac.plugins.aax_stem_configs must not be empty".into(), - ); - } - let mut aax_plugin_ids = HashSet::new(); - for stem_config in &plugin.aax_stem_configs { - validate_required( - "package.metadata.wrac.plugins.aax_stem_configs.name", - &stem_config.name, - )?; - validate_aax_stem_format( - "package.metadata.wrac.plugins.aax_stem_configs.input", - &stem_config.input, - )?; - validate_aax_stem_format( - "package.metadata.wrac.plugins.aax_stem_configs.output", - &stem_config.output, - )?; - validate_four_ascii( - "plugins.aax_stem_configs.plugin_id", - &stem_config.plugin_id, - )?; - if !aax_plugin_ids.insert(stem_config.plugin_id.as_str()) { - return Err(format!( - "duplicate package.metadata.wrac.plugins.aax_stem_configs plugin_id: {}", - stem_config.plugin_id - ) - .into()); - } - } - } - if !plugin_ids.insert(plugin.plugin_id.as_str()) { - return Err(format!( - "duplicate package.metadata.wrac.plugins plugin_id: {}", - plugin.plugin_id - ) - .into()); - } - if !standalone_names.insert(plugin.standalone_name.as_str()) { - return Err(format!( - "duplicate package.metadata.wrac.plugins standalone_name: {}", - plugin.standalone_name - ) - .into()); - } - if !auv2_ids.insert((plugin.auv2_type.as_str(), plugin.auv2_subtype.as_str())) { - return Err(format!( - "duplicate package.metadata.wrac.plugins AUv2 type/subtype: {}/{}", - plugin.auv2_type, plugin.auv2_subtype - ) - .into()); - } - } - for (rule_id, disabled) in &self.validation.disabled_rules { - validate_required( - &format!("package.metadata.wrac.validation.disabled_rules.{rule_id}.reason"), - disabled.reason.trim(), - )?; - } - if let Some(filter) = self.validation.clap_validator.skip_test_filter.as_deref() { - validate_required( - "package.metadata.wrac.validation.clap_validator.skip_test_filter", - filter.trim(), - )?; - validate_required( - "package.metadata.wrac.validation.clap_validator.skip_reason", - self.validation - .clap_validator - .skip_reason - .as_deref() - .unwrap_or_default() - .trim(), - )?; - } - Ok(()) - } -} - -fn validate_clap_feature(feature: &str) -> Result<()> { - match feature { - "audio-effect" | "analyzer" | "ambisonic" | "chorus" | "compressor" | "de-esser" - | "delay" | "instrument" | "note-effect" | "note-detector" | "drum" | "drum-machine" - | "equalizer" | "expander" | "filter" | "flanger" | "frequency-shifter" | "gate" - | "glitch" | "granular" | "distortion" | "limiter" | "mastering" | "mixing" | "mono" - | "multi-effects" | "phaser" | "phase-vocoder" | "pitch-correction" | "pitch-shifter" - | "restoration" | "reverb" | "sampler" | "stereo" | "surround" | "synthesizer" - | "transient-shaper" | "tremolo" | "utility" => Ok(()), - _ => Err(format!( - "unsupported package.metadata.wrac.plugins.clap_features value: {feature}" - ) - .into()), - } -} - -fn validate_aax_category(category: &str) -> Result<()> { - match category { - "eq" | "dynamics" | "pitch-shift" | "reverb" | "delay" | "modulation" | "harmonic" - | "noise-reduction" | "dither" | "sound-field" | "hardware-generator" - | "software-generator" | "wrapped-plugin" | "effect" | "midi-effect" => Ok(()), - _ => Err(format!( - "unsupported package.metadata.wrac.plugins.aax_categories value: {category}" - ) - .into()), + fn from_manifest(manifest: wrac_manifest::PluginManifest) -> Result { + let package_name = manifest + .package + .package_name + .ok_or("WRAC manifest package name is required when used from xtask")?; + let version = manifest + .package + .version + .ok_or("WRAC manifest package version is required when used from xtask")?; + Ok(Self { + package_name, + version, + repository: manifest.package.repository, + company_name: manifest.company_name, + auv2_manufacturer_code: manifest.auv2_manufacturer_code, + aax_manufacturer_id: manifest.aax_manufacturer_id, + bundle_name: manifest.bundle_name, + bundle_identifier: manifest.bundle_identifier, + homepage_url: manifest.homepage_url, + manual_url: manifest.manual_url, + support_url: manifest.support_url, + description: manifest.description, + copyright: manifest.copyright, + supported_formats: manifest + .supported_formats + .into_iter() + .map(convert_plugin_format) + .collect(), + plugins: manifest + .plugins + .into_iter() + .map(|plugin| PluginProductMetadata { + plugin_id: plugin.plugin_id, + plugin_name: plugin.plugin_name, + clap_features: plugin.clap_features, + vst3_subcategories: plugin.vst3_subcategories, + vst3_component_id: plugin.vst3_component_id, + standalone_name: plugin.standalone_name, + auv2_type: plugin.auv2_type, + auv2_subtype: plugin.auv2_subtype, + aax_categories: plugin.aax_categories, + aax_product_id: plugin.aax_product_id, + aax_stem_configs: plugin.aax_stem_configs, + }) + .collect(), + validation: manifest.validation, + }) } } -fn validate_aax_stem_format(label: &str, format: &str) -> Result<()> { +fn convert_plugin_format(format: wrac_manifest::PluginFormat) -> PluginFormat { match format { - "mono" | "stereo" => Ok(()), - _ => Err(format!("{label} must be mono or stereo").into()), - } -} - -fn validate_required(key: &str, value: &str) -> Result<()> { - if value.is_empty() { - Err(format!("{key} must not be empty").into()) - } else { - Ok(()) + wrac_manifest::PluginFormat::Clap => PluginFormat::Clap, + wrac_manifest::PluginFormat::Vst3 => PluginFormat::Vst3, + wrac_manifest::PluginFormat::Au => PluginFormat::Au, + wrac_manifest::PluginFormat::Aax => PluginFormat::Aax, } } -fn validate_four_ascii(key: &str, value: &str) -> Result<()> { - if value.len() == 4 && value.is_ascii() { - Ok(()) - } else { - Err(format!("package.metadata.wrac.{key} must be exactly 4 ASCII bytes").into()) +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use wrac_manifest::ClapValidatorMetadata; + + fn metadata() -> PluginMetadata { + PluginMetadata { + package_name: "test_plugin".to_string(), + version: "1.0.0".to_string(), + repository: None, + company_name: "Example".to_string(), + auv2_manufacturer_code: "ExCo".to_string(), + aax_manufacturer_id: None, + bundle_name: "Test Plugin".to_string(), + bundle_identifier: "com.example.test-plugin".to_string(), + homepage_url: "https://example.com".to_string(), + manual_url: "https://example.com/manual".to_string(), + support_url: "https://example.com/support".to_string(), + description: "Test plugin".to_string(), + copyright: "Copyright Example".to_string(), + supported_formats: vec![PluginFormat::Clap, PluginFormat::Vst3, PluginFormat::Au], + plugins: vec![PluginProductMetadata { + plugin_id: "com.example.test-plugin".to_string(), + plugin_name: "Test Plugin".to_string(), + clap_features: vec!["audio-effect".to_string(), "stereo".to_string()], + vst3_subcategories: "Fx".to_string(), + vst3_component_id: "5c65bb45-6f84-527b-915a-a51a30ea5854".to_string(), + standalone_name: "Test Plugin Standalone".to_string(), + auv2_type: "aufx".to_string(), + auv2_subtype: "TstP".to_string(), + aax_categories: None, + aax_product_id: None, + aax_stem_configs: Vec::new(), + }], + validation: ValidationMetadata { + disabled_rules: HashMap::new(), + clap_validator: ClapValidatorMetadata::default(), + }, + } } -} -fn validate_uuid(label: &str, value: &str) -> Result<()> { - let hex = value.replace('-', ""); - if hex.len() == 32 && hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { - Ok(()) - } else { - Err(format!("{label} must be a UUID").into()) + #[test] + fn bundle_names_use_bundle_name() { + let metadata = metadata(); + assert_eq!(metadata.clap_bundle_name(), "Test Plugin.clap"); + assert_eq!(metadata.vst3_bundle_name(), "Test Plugin.vst3"); + assert_eq!(metadata.au_bundle_name(), "Test Plugin.component"); } } - -#[derive(Debug, Deserialize)] -struct CargoManifest { - package: CargoPackage, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - name: String, - version: String, - repository: Option, - #[serde(default)] - metadata: PackageMetadata, -} - -#[derive(Debug, Default, Deserialize)] -struct PackageMetadata { - wrac: Option, -} - -#[derive(Debug, Deserialize)] -struct WracMetadata { - company_name: String, - auv2_manufacturer_code: String, - aax_manufacturer_id: Option, - bundle_name: String, - bundle_identifier: String, - homepage_url: String, - manual_url: String, - support_url: String, - description: String, - copyright: String, - supported_formats: Vec, - #[serde(default)] - plugins: Vec, - validation: Option, -} diff --git a/crates/wrac_xtask/src/plan.rs b/crates/wrac_xtask/src/plan.rs index 66207f06..01b00d1a 100644 --- a/crates/wrac_xtask/src/plan.rs +++ b/crates/wrac_xtask/src/plan.rs @@ -83,7 +83,9 @@ enum TaskKind { BuildVst3Bundle, BuildAuBundle, BuildAaxBundle, - BuildStandaloneBundle, + BuildStandaloneBundle { + plugin_id: Option, + }, CheckInstallScope { target: PluginTarget, scope: crate::cli::InstallScope, @@ -128,7 +130,10 @@ impl TaskKind { Self::BuildVst3Bundle => "build VST3 bundle".to_string(), Self::BuildAuBundle => "build AU bundle".to_string(), Self::BuildAaxBundle => "build AAX bundle".to_string(), - Self::BuildStandaloneBundle => "build standalone artifact".to_string(), + Self::BuildStandaloneBundle { plugin_id } => match plugin_id { + Some(plugin_id) => format!("build standalone artifact ({plugin_id})"), + None => "build standalone artifact".to_string(), + }, Self::CheckInstallScope { target, scope } => { format!("check install scope for {} ({scope:?})", target.display()) } @@ -251,7 +256,14 @@ pub(crate) fn run_build(ctx: &Context, args: &BuildArgs) -> Result<()> { let targets = resolve_build_targets_from_metadata(ctx, &args.target)?; // The command only chooses terminal build tasks. The graph builder expands // those into Rust, wrapper-configure, and format-specific build tasks. - let graph = build_graph(ctx, CommandKind::Build, &targets, args.clean, None)?; + let graph = build_graph( + ctx, + CommandKind::Build, + &targets, + args.clean, + None, + args.standalone_plugin_id.clone(), + )?; execute_plan( ctx, profile, @@ -260,7 +272,7 @@ pub(crate) fn run_build(ctx: &Context, args: &BuildArgs) -> Result<()> { failure_policy(args.continue_on_error), )?; if !args.dry_run { - print_outputs(ctx, profile, &targets); + print_outputs(ctx, profile, &targets, args.standalone_plugin_id.as_deref())?; } Ok(()) } @@ -281,6 +293,7 @@ pub(crate) fn run_install(ctx: &Context, args: &InstallArgs) -> Result<()> { targets, scope: args.scope, }), + None, )?; execute_plan( ctx, @@ -340,6 +353,7 @@ pub(crate) fn run_validate(ctx: &Context, args: &ValidateArgs) -> Result<()> { .collect(), scope: crate::cli::InstallScope::Default, }), + None, )?; execute_plan( ctx, @@ -362,6 +376,7 @@ fn build_graph( targets: &[Target], clean_first: bool, install_selection: Option, + standalone_plugin_id: Option, ) -> Result { let mut graph = TaskGraph::new(); // Install scope validation is modeled as a task, not an upfront global @@ -534,7 +549,9 @@ fn build_graph( ); let standalone = graph.task( package_task_id(ctx, "build-standalone"), - TaskKind::BuildStandaloneBundle, + TaskKind::BuildStandaloneBundle { + plugin_id: standalone_plugin_id, + }, ); graph.depends_on(standalone, configure); build_by_target.insert(Target::Standalone, standalone); @@ -708,6 +725,7 @@ fn run_task(ctx: &Context, profile: BuildProfile, kind: &TaskKind) -> Result<()> au: false, }, WrapperTarget::Vst3, + None, ), TaskKind::BuildAuBundle => build_wrapper_target( ctx, @@ -717,15 +735,17 @@ fn run_task(ctx: &Context, profile: BuildProfile, kind: &TaskKind) -> Result<()> au: true, }, WrapperTarget::Au, + None, ), TaskKind::BuildAaxBundle => { - build_wrapper_target(ctx, profile, WrapperBuild::Aax, WrapperTarget::Aax) + build_wrapper_target(ctx, profile, WrapperBuild::Aax, WrapperTarget::Aax, None) } - TaskKind::BuildStandaloneBundle => build_wrapper_target( + TaskKind::BuildStandaloneBundle { plugin_id } => build_wrapper_target( ctx, profile, WrapperBuild::Standalone, WrapperTarget::Standalone, + plugin_id.as_deref(), ), TaskKind::CheckInstallScope { target, scope } => { install_dir(ctx, *scope, target.format()).map(|_| ()) @@ -961,7 +981,7 @@ fn validate_plugin_format_support( // supported subset. if explicit && !supported.contains(format) { return Err(format!( - "{} is not listed in package.metadata.wrac.supported_formats for {}", + "{} is not listed in bundle.supported_formats for {}", format.display(), ctx.package_name ) diff --git a/crates/wrac_xtask/src/targets.rs b/crates/wrac_xtask/src/targets.rs index 26c038cd..08c4f57c 100644 --- a/crates/wrac_xtask/src/targets.rs +++ b/crates/wrac_xtask/src/targets.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use crate::Result; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -pub(crate) enum Target { +pub enum Target { Clap, Vst3, Au, @@ -14,7 +14,7 @@ pub(crate) enum Target { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) enum PluginFormat { +pub enum PluginFormat { Clap, Vst3, Au, @@ -64,7 +64,7 @@ impl Target { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -pub(crate) enum PluginTarget { +pub enum PluginTarget { Clap, Vst3, Au, @@ -96,7 +96,7 @@ impl PluginTarget { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -pub(crate) enum ValidateTarget { +pub enum ValidateTarget { Clap, Vst3, Au, @@ -184,8 +184,7 @@ impl Platform { pub(crate) fn cmake_generator(self) -> Option<&'static str> { match self { Self::Macos => Some("Xcode"), - Self::Windows => Some("Visual Studio 17 2022"), - Self::Linux => None, + Self::Windows | Self::Linux => None, } } diff --git a/crates/wrac_xtask/src/validation/checks.rs b/crates/wrac_xtask/src/validation/checks.rs index 3e1a532b..3484403f 100644 --- a/crates/wrac_xtask/src/validation/checks.rs +++ b/crates/wrac_xtask/src/validation/checks.rs @@ -257,7 +257,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.company_name", + "bundle.company_name", &metadata.company_name, "Your Company", ); @@ -265,7 +265,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.auv2_manufacturer_code", + "bundle.auv2_manufacturer_code", &metadata.auv2_manufacturer_code, "YrCo", ); @@ -274,7 +274,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.aax_manufacturer_id", + "bundle.aax_manufacturer_id", aax_manufacturer_id, "YrCo", ); @@ -283,7 +283,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.bundle_identifier", + "bundle.bundle_identifier", &metadata.bundle_identifier, "com.your-company", ); @@ -291,7 +291,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.homepage_url", + "bundle.homepage_url", &metadata.homepage_url, "example.com", ); @@ -299,7 +299,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.manual_url", + "bundle.manual_url", &metadata.manual_url, "example.com", ); @@ -307,7 +307,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.support_url", + "bundle.support_url", &metadata.support_url, "example.com", ); @@ -315,7 +315,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.copyright", + "bundle.copyright", &metadata.copyright, "Your Company", ); @@ -323,7 +323,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.bundle_name", + "bundle.bundle_name", &metadata.bundle_name, "WRAC Gain", ); @@ -332,7 +332,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.plugin_id", + "plugins.plugin_id", &plugin.plugin_id, "com.your-company", ); @@ -340,7 +340,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.plugin_name", + "plugins.plugin_name", &plugin.plugin_name, "WRAC Gain", ); @@ -348,7 +348,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.standalone_name", + "plugins.standalone_name", &plugin.standalone_name, "WRAC Gain", ); @@ -356,7 +356,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.auv2_subtype", + "plugins.auv2_subtype", &plugin.auv2_subtype, "WtGn", ); @@ -365,7 +365,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.aax_product_id", + "plugins.aax_product_id", aax_product_id, "WtGn", ); @@ -375,7 +375,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.aax_stem_configs.plugin_id", + "plugins.aax_stem_configs.plugin_id", &stem_config.plugin_id, "WtG", ); @@ -384,7 +384,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.vst3_component_id", + "plugins.vst3_component_id", &plugin.vst3_component_id, "822011ca-37ec-5cef-92d7-ec7e67207195", ); @@ -392,7 +392,7 @@ fn template_placeholder_violations( &mut violations, &subject, location, - "package.metadata.wrac.plugins.vst3_component_id", + "plugins.vst3_component_id", &plugin.vst3_component_id, "ffff664c-b963-53e6-87cc-2a7ceb29674b", ); @@ -563,10 +563,9 @@ mod tests { use std::collections::HashMap; use std::path::Path; - use crate::metadata::{ - DisabledValidationRule, PluginMetadata, PluginProductMetadata, ValidationMetadata, - }; + use crate::metadata::{PluginMetadata, PluginProductMetadata, ValidationMetadata}; use crate::targets::{PluginFormat, ValidateTarget}; + use wrac_manifest::DisabledValidationRule; use super::super::clap_schema::{ParameterSchema, PluginSchema}; use super::*; diff --git a/docs/aax-ja.md b/docs/aax-ja.md index 165137c9..06d5e714 100644 --- a/docs/aax-ja.md +++ b/docs/aax-ja.md @@ -2,7 +2,7 @@ > English version: [aax.md](aax.md) -AAX サポートは、`package.metadata.wrac.supported_formats` に +AAX サポートは、`wrac-plugin.toml` の `supported_formats` に `aax` を追加することで有効にできます。 ## 前提条件 @@ -12,4 +12,3 @@ AAX サポートは、`package.metadata.wrac.supported_formats` に - CMake と clap-wrapper が対応する platform C++ toolchain いくつかの環境変数を設定する必要があります。 `.env.example` を参照してください。。 - diff --git a/docs/aax.md b/docs/aax.md index b4fa0ff1..f5642885 100644 --- a/docs/aax.md +++ b/docs/aax.md @@ -3,7 +3,7 @@ > Japanese version: [aax-ja.md](aax-ja.md) AAX support can be enabled by adding `aax` to -`package.metadata.wrac.supported_formats`. +`wrac-plugin.toml` `supported_formats`. ## Prerequisites diff --git a/docs/production-readiness-checks-ja.md b/docs/production-readiness-checks-ja.md index 46e951e8..9a142863 100644 --- a/docs/production-readiness-checks-ja.md +++ b/docs/production-readiness-checks-ja.md @@ -11,7 +11,7 @@ Production-readiness check は、`cargo xtask validate` の中で実行される ルールはプラグイン crate の manifest で rule ID ごとに無効化できます。無効化する場合は、空ではない `reason` が必須です。 ```toml -[package.metadata.wrac.validation.disabled_rules.fender-studio-pro-generic-editor-single-knob] +[validation.disabled_rules.fender-studio-pro-generic-editor-single-knob] reason = "This product does not support Fender Studio Pro generic editor workflows." ``` diff --git a/docs/production-readiness-checks.md b/docs/production-readiness-checks.md index 048a7f93..2c766bcf 100644 --- a/docs/production-readiness-checks.md +++ b/docs/production-readiness-checks.md @@ -11,7 +11,7 @@ These checks are opinionated NovoNotes release-policy checks for commercial plug Rules can be disabled by rule ID in the plugin crate manifest. Every disabled rule must include a non-empty `reason`. ```toml -[package.metadata.wrac.validation.disabled_rules.fender-studio-pro-generic-editor-single-knob] +[validation.disabled_rules.fender-studio-pro-generic-editor-single-knob] reason = "This product does not support Fender Studio Pro generic editor workflows." ``` diff --git a/docs/setup-ja.md b/docs/setup-ja.md index 0a46b063..30f63372 100644 --- a/docs/setup-ja.md +++ b/docs/setup-ja.md @@ -52,8 +52,8 @@ AAX ビルドには、加えて private な AAX SDK が必要です。ローカ ### 2. プラグインの識別情報を設定する -プラグインの識別情報は、プラグインパッケージの manifest に集約しています。初期状態では `plugins/wrac-gain/src-plugin/Cargo.toml` です。 -この guide に別の manifest sample を複製するのではなく、そこにあるコメント付きの `[package.metadata.wrac]` と `[[package.metadata.wrac.plugins]]` を直接編集してください。 +プラグインの識別情報は `plugins/wrac-gain/src-plugin/wrac-plugin.toml` に集約しています。 +host-visible ID を Rust code や Cargo metadata に重複して書かず、この manifest を編集してください。 > **重要:** プラグイン ID はグローバルに一意である必要があります。一度公開したら変更できません。 > AUv2 の `auv2_type`、`auv2_subtype`、`auv2_manufacturer_code` は、それぞれ 4 byte の ASCII にしてください。 @@ -108,7 +108,7 @@ cargo xtask install `cargo xtask install` は選択したプラグインフォーマットを task graph に展開してからインストールします。 workspace に複数の WRAC plugin package がある場合は、Cargo package 名を `-p/--package` で指定してください。 -既定の plugin format は `package.metadata.wrac.supported_formats` から決まります。 +既定の plugin format は `wrac-plugin.toml` の `supported_formats` から決まります。 `cargo xtask build` も同じ plugin format default を使い、さらに開発用 standalone app もビルドします。 `cargo xtask validate` も同じ plugin format default を使い、選択した validator に必要な artifact をビルドします。 `cargo xtask install --scope=default` は CLAP/VST3/AU を user-local path に、AAX を system-wide の Avid plugin folder にインストールします。 diff --git a/docs/setup.md b/docs/setup.md index e7e4ca60..7b686386 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -52,8 +52,8 @@ AAX builds additionally require the private AAX SDK. Put local AAX paths in `.en ### 2. Configure Plugin Identity -Plugin identity is centralized in the plugin package manifest, initially `plugins/wrac-gain/src-plugin/Cargo.toml`. -Edit the commented `[package.metadata.wrac]` and `[[package.metadata.wrac.plugins]]` sections there instead of copying a separate manifest sample from this guide. +Plugin identity is centralized in `plugins/wrac-gain/src-plugin/wrac-plugin.toml`. +Edit that manifest instead of duplicating host-visible IDs in Rust code or Cargo metadata. > **Important:** The plugin ID must be globally unique. It cannot be changed once published. > AUv2 `auv2_type`, `auv2_subtype`, and `auv2_manufacturer_code` must each be exactly 4 ASCII bytes. @@ -108,7 +108,7 @@ cargo xtask install `cargo xtask install` expands the selected plugin formats into a task graph before installing them. Use `-p/--package` with the Cargo package name when the workspace contains multiple WRAC plugin packages. -Default plugin formats come from `package.metadata.wrac.supported_formats`. +Default plugin formats come from `wrac-plugin.toml` `supported_formats`. `cargo xtask build` uses the same plugin-format defaults and also builds the development standalone app. `cargo xtask validate` uses the same plugin-format defaults and builds any artifacts required by the selected validators. `cargo xtask install --scope=default` installs CLAP/VST3/AU to user-local paths and AAX to the system-wide Avid plugin folder. diff --git a/packages/wrac-frontend-runtime/.gitignore b/packages/wrac-frontend-runtime/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/packages/wrac-frontend-runtime/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/wrac-frontend-runtime/README.md b/packages/wrac-frontend-runtime/README.md new file mode 100644 index 00000000..453d127f --- /dev/null +++ b/packages/wrac-frontend-runtime/README.md @@ -0,0 +1,46 @@ +# @novonotes/wrac-frontend-runtime + +Shared TypeScript helpers for WRAC/WXP plugin frontends. + +This package contains runtime behavior that is common to DAW-hosted WebView +plugin GUIs: + +- frontend log forwarding via `write_to_log` +- host focus restoration via `focus_host_window` +- frontend runtime context via `get_frontend_runtime_context` +- native cursor bridging via `apply_native_cursor` +- host GUI resizing via `begin_gui_resize_drag`, `request_gui_resize`, and + `end_gui_resize_drag` + +It intentionally does not define product parameter APIs, device command +schemas, telemetry payloads, preset behavior, or client subscription models. +Those contracts belong to the plugin or device layer. + +## Example + +```ts +import { + createHostFocusRestorer, + createWracFrontendRuntime, + installConsoleLogPipe, + installNativeCursorBridge, + installResizeBridge, +} from "@novonotes/wrac-frontend-runtime"; + +const runtime = createWracFrontendRuntime(); +installConsoleLogPipe(runtime.writeToLog); + +const restoreHostFocus = createHostFocusRestorer(runtime); + +installResizeBridge({ + runtime, + resizeGrip, + restoreHostFocus, +}); + +const context = await runtime.getFrontendRuntimeContext().catch(() => ({})); +installNativeCursorBridge({ + runtime, + context, +}); +``` diff --git a/packages/wrac-frontend-runtime/package-lock.json b/packages/wrac-frontend-runtime/package-lock.json new file mode 100644 index 00000000..dd9d39d7 --- /dev/null +++ b/packages/wrac-frontend-runtime/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "@novonotes/wrac-frontend-runtime", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@novonotes/wrac-frontend-runtime", + "version": "0.1.0", + "dependencies": { + "@novonotes/webview-bridge": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "peerDependencies": { + "@novonotes/webview-bridge": "0.1.0-alpha.1" + } + }, + "node_modules/@novonotes/webview-bridge": { + "version": "0.1.0-alpha.1", + "resolved": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz", + "integrity": "sha512-WceKPcEy03WszH2oL+idLtDG7tk2jKVbDRLzcdIIk72SGM7+NECk+Y0wHTHdnXOW8OUZVFCWSX4GBIU/2lalng==", + "license": "MIT", + "peerDependencies": { + "@tauri-apps/api": "^2.0.0" + }, + "peerDependenciesMeta": { + "@tauri-apps/api": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/wrac-frontend-runtime/package.json b/packages/wrac-frontend-runtime/package.json new file mode 100644 index 00000000..584ea015 --- /dev/null +++ b/packages/wrac-frontend-runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@novonotes/wrac-frontend-runtime", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc --noEmit" + }, + "dependencies": { + "@novonotes/webview-bridge": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "peerDependencies": { + "@novonotes/webview-bridge": "0.1.0-alpha.1" + } +} diff --git a/plugins/wrac-gain/src-gui/src/nativeLog.ts b/packages/wrac-frontend-runtime/src/consoleLogPipe.ts similarity index 72% rename from plugins/wrac-gain/src-gui/src/nativeLog.ts rename to packages/wrac-frontend-runtime/src/consoleLogPipe.ts index 7e70fbe5..d14eb21e 100644 --- a/plugins/wrac-gain/src-gui/src/nativeLog.ts +++ b/packages/wrac-frontend-runtime/src/consoleLogPipe.ts @@ -1,20 +1,4 @@ -import { invoke } from "@novonotes/webview-bridge"; - -export type NativeLogLevel = "debug" | "info" | "warn" | "error"; - -export type NativeLogData = - | null - | string - | number - | boolean - | NativeLogData[] - | { [key: string]: NativeLogData }; - -export type NativeLogEntry = { - level: NativeLogLevel; - message: string; - data?: NativeLogData; -}; +import type { NativeLogData, NativeLogEntry, NativeLogLevel } from "./runtime"; type ConsoleMethodName = "debug" | "log" | "info" | "warn" | "error"; @@ -28,15 +12,9 @@ const consoleMethodLevels = { let consoleLogPipeInstalled = false; -export function logNative(entry: NativeLogEntry): void { - try { - void invoke("write_to_log", { entry }).catch(() => undefined); - } catch { - // Logging must never break GUI behavior. - } -} - -export function installConsoleLogPipe(): void { +export function installConsoleLogPipe( + writeToLog: (entry: NativeLogEntry) => Promise | void, +): void { if (consoleLogPipeInstalled) { return; } @@ -55,10 +33,17 @@ export function installConsoleLogPipe(): void { ) as ConsoleMethodName[]) { console[methodName] = (...args: unknown[]) => { originalConsole[methodName](...args); - logNative({ - level: consoleMethodLevels[methodName], - message: formatConsoleArgs(args), - }); + try { + const result = writeToLog({ + level: consoleMethodLevels[methodName], + message: formatConsoleArgs(args), + }); + if (result instanceof Promise) { + void result.catch(() => undefined); + } + } catch { + // Logging must never break GUI behavior. + } }; } } @@ -104,7 +89,7 @@ function stringifyObject(value: object, seen: WeakSet): string { } function createJsonReplacer(seen: WeakSet) { - return (_key: string, value: unknown): unknown => { + return (_key: string, value: unknown): NativeLogData | undefined => { if (typeof value === "bigint") { return `${value.toString()}n`; } @@ -118,7 +103,7 @@ function createJsonReplacer(seen: WeakSet) { return { name: value.name, message: value.message, - stack: value.stack, + stack: value.stack ?? null, }; } if (value && typeof value === "object") { @@ -127,6 +112,17 @@ function createJsonReplacer(seen: WeakSet) { } seen.add(value); } - return value; + if ( + value === undefined || + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + Array.isArray(value) || + typeof value === "object" + ) { + return value as NativeLogData | undefined; + } + return String(value); }; } diff --git a/packages/wrac-frontend-runtime/src/hostFocus.ts b/packages/wrac-frontend-runtime/src/hostFocus.ts new file mode 100644 index 00000000..49fec92d --- /dev/null +++ b/packages/wrac-frontend-runtime/src/hostFocus.ts @@ -0,0 +1,39 @@ +import type { WracFrontendRuntime } from "./runtime"; + +export type HostFocusRestorer = (target?: EventTarget | null) => void; +export type EditableElementPredicate = (target: EventTarget | null) => boolean; + +export type HostFocusRestorerOptions = { + isEditableElement?: EditableElementPredicate; +}; + +export function defaultIsEditableElement(target: EventTarget | null): boolean { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} + +export function createHostFocusRestorer( + runtime: WracFrontendRuntime, + options: HostFocusRestorerOptions = {}, +): HostFocusRestorer { + const isEditableElement = + options.isEditableElement ?? defaultIsEditableElement; + return (target?: EventTarget | null) => { + if ( + isEditableElement(target ?? null) || + isEditableElement(document.activeElement) + ) { + return; + } + window.setTimeout(() => { + if (isEditableElement(document.activeElement)) { + return; + } + void runtime.focusHostWindow().catch(() => undefined); + }, 0); + }; +} diff --git a/packages/wrac-frontend-runtime/src/index.ts b/packages/wrac-frontend-runtime/src/index.ts new file mode 100644 index 00000000..a04b54c3 --- /dev/null +++ b/packages/wrac-frontend-runtime/src/index.ts @@ -0,0 +1,38 @@ +export { + createWracFrontendRuntime, + type ApplyNativeCursorResponse, + type BeginResizeDragRequest, + type EndResizeDragRequest, + type FrontendRuntimeContext, + type NativeCursorIntent, + type NativeLogData, + type NativeLogEntry, + type NativeLogLevel, + type ResizeRequest, + type ResizeResponse, + type RuntimeOkResponse, + type WracFrontendRuntime, +} from "./runtime"; +export { + createResizeController, + type ResizeController, +} from "./resizeController"; +export { + installResizeBridge, + type ResizeBridge, + type ResizeBridgeOptions, +} from "./resizeDomBridge"; +export { + defaultShouldEnableNativeCursorBridge, + installNativeCursorBridge, + type NativeCursorBridge, + type NativeCursorBridgeOptions, +} from "./nativeCursorBridge"; +export { + createHostFocusRestorer, + defaultIsEditableElement, + type EditableElementPredicate, + type HostFocusRestorer, + type HostFocusRestorerOptions, +} from "./hostFocus"; +export { installConsoleLogPipe } from "./consoleLogPipe"; diff --git a/plugins/wrac-gain/src-gui/src/wracRuntime/nativeCursorBridge.ts b/packages/wrac-frontend-runtime/src/nativeCursorBridge.ts similarity index 71% rename from plugins/wrac-gain/src-gui/src/wracRuntime/nativeCursorBridge.ts rename to packages/wrac-frontend-runtime/src/nativeCursorBridge.ts index 7fcbb1f5..a516762a 100644 --- a/plugins/wrac-gain/src-gui/src/wracRuntime/nativeCursorBridge.ts +++ b/packages/wrac-frontend-runtime/src/nativeCursorBridge.ts @@ -1,63 +1,26 @@ -import { invoke } from "@novonotes/webview-bridge"; +import type { + FrontendRuntimeContext, + NativeCursorIntent, + WracFrontendRuntime, +} from "./runtime"; -// Cubase VST3 on macOS can fail to propagate WKWebView's CSS cursor to the -// host-owned Cocoa parent. Keep this bridge CSS-driven so template users only -// need to set `cursor` in styles for new hover targets. -export type FrontendRuntimeContext = { - os?: string; - pluginFormat?: string; - hostFamily?: string; - hostName?: string; - processName?: string; -}; - -type NativeCursorIntent = - | "alias" - | "all-scroll" - | "arrow" - | "cell" - | "col-resize" - | "context-menu" - | "copy" - | "crosshair" - | "e-resize" - | "ew-resize" - | "grab" - | "grabbing" - | "help" - | "move" - | "n-resize" - | "ne-resize" - | "nesw-resize" - | "no-drop" - | "none" - | "not-allowed" - | "ns-resize" - | "nw-resize" - | "nwse-resize" - | "pointer" - | "progress" - | "row-resize" - | "s-resize" - | "se-resize" - | "sw-resize" - | "text" - | "vertical-text" - | "w-resize" - | "wait" - | "zoom-in" - | "zoom-out" - | "unsupported"; - -type NativeCursorBridge = { +export type NativeCursorBridge = { dispose: () => void; refresh: (reason?: string) => void; }; -export function installNativeCursorBridge( - context: FrontendRuntimeContext, -): NativeCursorBridge | undefined { - if (!shouldUseNativeCursorBridge(context)) { +export type NativeCursorBridgeOptions = { + runtime: WracFrontendRuntime; + context: FrontendRuntimeContext; + shouldEnable?: (context: FrontendRuntimeContext) => boolean; +}; + +export function installNativeCursorBridge({ + runtime, + context, + shouldEnable = defaultShouldEnableNativeCursorBridge, +}: NativeCursorBridgeOptions): NativeCursorBridge | undefined { + if (!shouldEnable(context)) { return undefined; } @@ -68,10 +31,7 @@ export function installNativeCursorBridge( cursorIntent: NativeCursorIntent, reason: string, ): void => { - void invoke("apply_native_cursor", { - cursorIntent, - reason, - }).catch(() => undefined); + void runtime.applyNativeCursor(cursorIntent, reason).catch(() => undefined); }; const applyCursorAtPoint = ( @@ -90,7 +50,10 @@ export function installNativeCursorBridge( applyNativeCursor(nativeCursorIntentFromCss(hitCursor), reason); }; - const handlePointerCursor = (event: PointerEvent | MouseEvent): void => { + const handlePointerCursor: EventListener = (event): void => { + if (!(event instanceof MouseEvent)) { + return; + } lastPointer = { clientX: event.clientX, clientY: event.clientY, @@ -148,11 +111,14 @@ export function installNativeCursorBridge( }; } -function shouldUseNativeCursorBridge(context: FrontendRuntimeContext): boolean { +export function defaultShouldEnableNativeCursorBridge( + context: FrontendRuntimeContext, +): boolean { return ( context.os === "macos" && context.pluginFormat === "vst3" && - context.hostFamily === "steinberg-cubase" + (context.hostFamily === "steinberg-cubase" || + context.hostFamily === "steinberg-cubase-bridged") ); } diff --git a/packages/wrac-frontend-runtime/src/resizeController.ts b/packages/wrac-frontend-runtime/src/resizeController.ts new file mode 100644 index 00000000..7ece61c2 --- /dev/null +++ b/packages/wrac-frontend-runtime/src/resizeController.ts @@ -0,0 +1,136 @@ +import type { WracFrontendRuntime } from "./runtime"; + +type ResizeDragState = { + dragId: number; + width: number; + height: number; + lastX: number; + lastY: number; +}; + +export type ResizeController = { + begin: (args: { + dragId: number; + width: number; + height: number; + screenX: number; + screenY: number; + }) => void; + move: (args: { screenX: number; screenY: number }) => boolean; + end: () => void; + cancel: () => void; + requestResize: (width: number, height: number, dragId?: number) => Promise; + flush: () => Promise; +}; + +export function createResizeController( + runtime: WracFrontendRuntime, +): ResizeController { + let dragState: ResizeDragState | null = null; + let inFlight = false; + let drainResizeQueue: Promise | null = null; + let queuedSize: + | { + width: number; + height: number; + dragId?: number; + } + | null = null; + + const flush = () => { + if (inFlight) { + return drainResizeQueue ?? Promise.resolve(); + } + inFlight = true; + drainResizeQueue = (async () => { + try { + while (queuedSize) { + const size = queuedSize; + queuedSize = null; + await runtime.requestGuiResize(size).catch(() => undefined); + } + } finally { + inFlight = false; + } + if (queuedSize) { + await flush(); + } + })().finally(() => { + if (!inFlight && !queuedSize) { + drainResizeQueue = null; + } + }); + return drainResizeQueue; + }; + + const requestResize = (width: number, height: number, dragId?: number) => { + queuedSize = { + width: Math.max(1, Math.round(width)), + height: Math.max(1, Math.round(height)), + dragId, + }; + return flush(); + }; + + const endNativeDragAfterDrain = (dragId: number) => { + void (async () => { + // Keep the native drag snapshot alive until the final queued resize request + // returns; otherwise the last request may fall back to WebView-relative deltas. + await flush(); + await runtime.endGuiResizeDrag({ dragId }).catch(() => undefined); + })(); + }; + + return { + begin({ dragId, width, height, screenX, screenY }) { + dragState = { + dragId, + width, + height, + lastX: screenX, + lastY: screenY, + }; + void runtime + .beginGuiResizeDrag({ + dragId, + width, + height, + }) + .catch(() => undefined); + }, + move({ screenX, screenY }) { + if (!dragState) { + return false; + } + + const deltaX = screenX - dragState.lastX; + const deltaY = screenY - dragState.lastY; + if (deltaX === 0 && deltaY === 0) { + return true; + } + + dragState.width += deltaX; + dragState.height += deltaY; + dragState.lastX = screenX; + dragState.lastY = screenY; + void requestResize(dragState.width, dragState.height, dragState.dragId); + return true; + }, + end() { + const dragId = dragState?.dragId; + dragState = null; + if (dragId !== undefined) { + endNativeDragAfterDrain(dragId); + } + }, + cancel() { + const dragId = dragState?.dragId; + dragState = null; + if (dragId !== undefined) { + void runtime.endGuiResizeDrag({ dragId }).catch(() => undefined); + } + }, + requestResize, + flush, + }; +} diff --git a/packages/wrac-frontend-runtime/src/resizeDomBridge.ts b/packages/wrac-frontend-runtime/src/resizeDomBridge.ts new file mode 100644 index 00000000..e828a471 --- /dev/null +++ b/packages/wrac-frontend-runtime/src/resizeDomBridge.ts @@ -0,0 +1,85 @@ +import { createResizeController, type ResizeController } from "./resizeController"; +import type { WracFrontendRuntime } from "./runtime"; + +export type ResizeBridge = { + dispose: () => void; + controller: ResizeController; +}; + +export type ResizeBridgeOptions = { + runtime: WracFrontendRuntime; + resizeGrip: HTMLElement; + restoreHostFocus?: (target?: EventTarget | null) => void; +}; + +export function installResizeBridge({ + runtime, + resizeGrip, + restoreHostFocus, +}: ResizeBridgeOptions): ResizeBridge { + const controller = createResizeController(runtime); + let pointerId: number | undefined; + let resizeDragSeq = 0; + + const handlePointerDown = (event: PointerEvent) => { + pointerId = event.pointerId; + controller.begin({ + dragId: ++resizeDragSeq, + width: window.innerWidth, + height: window.innerHeight, + screenX: event.screenX, + screenY: event.screenY, + }); + resizeGrip.setPointerCapture(event.pointerId); + event.preventDefault(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (pointerId !== event.pointerId) { + return; + } + controller.move({ + screenX: event.screenX, + screenY: event.screenY, + }); + event.preventDefault(); + }; + + const finishResize = (event: PointerEvent) => { + if (pointerId !== event.pointerId) { + return; + } + pointerId = undefined; + controller.move({ + screenX: event.screenX, + screenY: event.screenY, + }); + controller.end(); + restoreHostFocus?.(event.target); + }; + + const cancelResize = (event: PointerEvent) => { + if (pointerId !== event.pointerId) { + return; + } + pointerId = undefined; + controller.cancel(); + restoreHostFocus?.(event.target); + }; + + resizeGrip.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", finishResize); + window.addEventListener("pointercancel", cancelResize); + + return { + controller, + dispose() { + resizeGrip.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", finishResize); + window.removeEventListener("pointercancel", cancelResize); + controller.cancel(); + }, + }; +} diff --git a/packages/wrac-frontend-runtime/src/runtime.ts b/packages/wrac-frontend-runtime/src/runtime.ts new file mode 100644 index 00000000..d32e225d --- /dev/null +++ b/packages/wrac-frontend-runtime/src/runtime.ts @@ -0,0 +1,144 @@ +import { invoke } from "@novonotes/webview-bridge"; + +export type RuntimeOkResponse = { + ok?: boolean; +}; + +export type FrontendRuntimeContext = { + os?: string; + pluginFormat?: string; + hostFamily?: string; + hostName?: string; + processName?: string; +}; + +export type NativeLogLevel = "debug" | "info" | "warn" | "error"; + +export type NativeLogData = + | null + | string + | number + | boolean + | NativeLogData[] + | { [key: string]: NativeLogData }; + +export type NativeLogEntry = { + level: NativeLogLevel; + message: string; + data?: NativeLogData; +}; + +export type ResizeRequest = { + width: number; + height: number; + dragId?: number; +}; + +export type ResizeResponse = RuntimeOkResponse & { + width?: number; + height?: number; +}; + +export type BeginResizeDragRequest = { + dragId: number; + width: number; + height: number; +}; + +export type EndResizeDragRequest = { + dragId: number; +}; + +export type NativeCursorIntent = + | "alias" + | "all-scroll" + | "arrow" + | "cell" + | "col-resize" + | "context-menu" + | "copy" + | "crosshair" + | "e-resize" + | "ew-resize" + | "grab" + | "grabbing" + | "help" + | "move" + | "n-resize" + | "ne-resize" + | "nesw-resize" + | "no-drop" + | "none" + | "not-allowed" + | "ns-resize" + | "nw-resize" + | "nwse-resize" + | "pointer" + | "progress" + | "row-resize" + | "s-resize" + | "se-resize" + | "sw-resize" + | "text" + | "vertical-text" + | "w-resize" + | "wait" + | "zoom-in" + | "zoom-out" + | "unsupported"; + +export type ApplyNativeCursorResponse = RuntimeOkResponse & { + applied?: boolean; +}; + +export type WracFrontendRuntime = { + invoke: typeof invoke; + writeToLog: (entry: NativeLogEntry) => Promise; + focusHostWindow: () => Promise; + getFrontendRuntimeContext: () => Promise; + beginGuiResizeDrag: ( + request: BeginResizeDragRequest, + ) => Promise; + requestGuiResize: (request: ResizeRequest) => Promise; + endGuiResizeDrag: (request: EndResizeDragRequest) => Promise; + applyNativeCursor: ( + cursorIntent: NativeCursorIntent, + reason: string, + ) => Promise; +}; + +export function createWracFrontendRuntime(): WracFrontendRuntime { + return { + invoke, + writeToLog(entry) { + return invoke("write_to_log", { entry }) as Promise; + }, + focusHostWindow() { + return invoke("focus_host_window") as Promise; + }, + getFrontendRuntimeContext() { + return invoke("get_frontend_runtime_context") as Promise; + }, + beginGuiResizeDrag(request) { + return invoke("begin_gui_resize_drag", { + request, + }) as Promise; + }, + requestGuiResize(request) { + return invoke("request_gui_resize", { + request, + }) as Promise; + }, + endGuiResizeDrag(request) { + return invoke("end_gui_resize_drag", { + request, + }) as Promise; + }, + applyNativeCursor(cursorIntent, reason) { + return invoke("apply_native_cursor", { + cursorIntent, + reason, + }) as Promise; + }, + }; +} diff --git a/packages/wrac-frontend-runtime/tsconfig.json b/packages/wrac-frontend-runtime/tsconfig.json new file mode 100644 index 00000000..205c6d04 --- /dev/null +++ b/packages/wrac-frontend-runtime/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true + }, + "include": ["src"] +} diff --git a/plugins/wrac-gain/src-gui/README.md b/plugins/wrac-gain/src-gui/README.md new file mode 100644 index 00000000..9695a023 --- /dev/null +++ b/plugins/wrac-gain/src-gui/README.md @@ -0,0 +1,20 @@ +# WRAC Gain GUI + +Vite/TypeScript WebView frontend for the WRAC Gain example plugin. + +Debug plugin builds load this package from the Vite dev server. Release builds +use the `dist` output packaged by `wrac_build` from the plugin crate's +`build.rs`. + +This package owns only the product UI: DOM, styling, parameter presentation, and +calls into Rust commands exposed by the plugin. Shared DAW-hosted WebView +behavior such as log forwarding, host focus restoration, native cursor bridging, +and resize handling lives in `@novonotes/wrac-frontend-runtime`. + +## Commands + +```sh +npm install +npm run dev +npm run build +``` diff --git a/plugins/wrac-gain/src-gui/package-lock.json b/plugins/wrac-gain/src-gui/package-lock.json index 4a441077..bbc98810 100644 --- a/plugins/wrac-gain/src-gui/package-lock.json +++ b/plugins/wrac-gain/src-gui/package-lock.json @@ -8,13 +8,27 @@ "name": "wrac-gain-plugin-gui", "version": "0.1.0", "dependencies": { - "@novonotes/webview-bridge": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz" + "@novonotes/webview-bridge": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz", + "@novonotes/wrac-frontend-runtime": "file:../../../packages/wrac-frontend-runtime" }, "devDependencies": { "typescript": "^5.8.3", "vite": "^6.3.5" } }, + "../../../packages/wrac-frontend-runtime": { + "name": "@novonotes/wrac-frontend-runtime", + "version": "0.1.0", + "dependencies": { + "@novonotes/webview-bridge": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "peerDependencies": { + "@novonotes/webview-bridge": "0.1.0-alpha.1" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -462,6 +476,10 @@ "resolved": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz", "license": "MIT" }, + "node_modules/@novonotes/wrac-frontend-runtime": { + "resolved": "../../../packages/wrac-frontend-runtime", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", diff --git a/plugins/wrac-gain/src-gui/package.json b/plugins/wrac-gain/src-gui/package.json index 6efc6f26..35d69217 100644 --- a/plugins/wrac-gain/src-gui/package.json +++ b/plugins/wrac-gain/src-gui/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@novonotes/wrac-frontend-runtime": "file:../../../packages/wrac-frontend-runtime", "@novonotes/webview-bridge": "https://files.novonotes.download/libs/novonotes-webview-bridge-0.1.0-alpha.1.tgz" }, "devDependencies": { diff --git a/plugins/wrac-gain/src-gui/src/main.ts b/plugins/wrac-gain/src-gui/src/main.ts index ca9e1f89..e17379c7 100644 --- a/plugins/wrac-gain/src-gui/src/main.ts +++ b/plugins/wrac-gain/src-gui/src/main.ts @@ -16,14 +16,16 @@ */ import { Channel, invoke } from "@novonotes/webview-bridge"; import { - type FrontendRuntimeContext, + createHostFocusRestorer, + createWracFrontendRuntime, + installConsoleLogPipe, installNativeCursorBridge, installResizeBridge, -} from "./wracRuntime"; -import { installConsoleLogPipe } from "./nativeLog"; +} from "@novonotes/wrac-frontend-runtime"; import "./style.css"; -installConsoleLogPipe(); +const runtime = createWracFrontendRuntime(); +installConsoleLogPipe(runtime.writeToLog); type PluginMetadata = { pluginId: string; @@ -121,29 +123,7 @@ let parameterSubscriptionId: number | undefined; let editorPageSubscriptionId: number | undefined; let pluginMetadata: PluginMetadata | undefined; -function isEditableElement(target: EventTarget | null): boolean { - return ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable) - ); -} - -function restoreHostFocusIfNeeded(target?: EventTarget | null): void { - if ( - isEditableElement(target ?? null) || - isEditableElement(document.activeElement) - ) { - return; - } - window.setTimeout(() => { - if (isEditableElement(document.activeElement)) { - return; - } - void invoke("focus_host_window"); - }, 0); -} +const restoreHostFocusIfNeeded = createHostFocusRestorer(runtime); function editableText(source: string): string { const match = source.match(/[-+]?\d*\.?\d+/); @@ -223,10 +203,13 @@ void (async () => { ); editorPageSubscriptionId = editorPageSubscription.subscriptionId; console.info("GUI initialization completed"); - const runtimeContext = await invoke( - "get_frontend_runtime_context", - ).catch(() => ({})); - installNativeCursorBridge(runtimeContext); + const runtimeContext = await runtime + .getFrontendRuntimeContext() + .catch(() => ({})); + installNativeCursorBridge({ + runtime, + context: runtimeContext, + }); })(); function clamp(value: number): number { @@ -491,6 +474,7 @@ headerAction.addEventListener("click", (event) => { }); installResizeBridge({ + runtime, resizeGrip, restoreHostFocus: restoreHostFocusIfNeeded, }); diff --git a/plugins/wrac-gain/src-gui/src/wracRuntime/index.ts b/plugins/wrac-gain/src-gui/src/wracRuntime/index.ts deleted file mode 100644 index c4b4938c..00000000 --- a/plugins/wrac-gain/src-gui/src/wracRuntime/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - type FrontendRuntimeContext, - installNativeCursorBridge, -} from "./nativeCursorBridge"; -export { installResizeBridge } from "./resizeBridge"; diff --git a/plugins/wrac-gain/src-gui/src/wracRuntime/resizeBridge.ts b/plugins/wrac-gain/src-gui/src/wracRuntime/resizeBridge.ts deleted file mode 100644 index 5d4b10eb..00000000 --- a/plugins/wrac-gain/src-gui/src/wracRuntime/resizeBridge.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { invoke } from "@novonotes/webview-bridge"; - -type ResizeResponse = { - ok?: boolean; - width?: number; - height?: number; -}; - -type ResizeBridgeOptions = { - resizeGrip: HTMLElement; - restoreHostFocus?: (target?: EventTarget | null) => void; -}; - -export function installResizeBridge({ - resizeGrip, - restoreHostFocus, -}: ResizeBridgeOptions): void { - let dragStart: - | { - pointerId: number; - dragId: number; - width: number; - height: number; - lastX: number; - lastY: number; - } - | null = null; - let inFlight = false; - let drainResizeQueue: Promise | null = null; - let resizeDragSeq = 0; - let queuedSize: - | { - width: number; - height: number; - dragId: number; - } - | null = null; - - const flushResize = () => { - if (inFlight) { - return drainResizeQueue ?? Promise.resolve(); - } - inFlight = true; - drainResizeQueue = (async () => { - try { - while (queuedSize) { - const size = queuedSize; - queuedSize = null; - await invoke("request_gui_resize", { - request: size, - }).catch(() => undefined); - } - } finally { - inFlight = false; - } - if (queuedSize) { - await flushResize(); - } - })().finally(() => { - if (!inFlight && !queuedSize) { - drainResizeQueue = null; - } - }); - return drainResizeQueue; - }; - - const requestResize = (width: number, height: number) => { - queuedSize = { - width: Math.max(1, Math.round(width)), - height: Math.max(1, Math.round(height)), - dragId: dragStart?.dragId ?? 0, - }; - return flushResize(); - }; - - const endResizeDragAfterDrain = (dragId: number) => { - void (async () => { - // Keep the native drag snapshot alive until the final queued resize request - // has returned. Otherwise a slow host can make the last request fall back to - // JS coordinates, exactly the coordinate source this path is trying to avoid. - await flushResize(); - await invoke("end_gui_resize_drag", { - request: { dragId }, - }).catch(() => undefined); - })(); - }; - - const applyResizeDelta = (event: PointerEvent) => { - if (!dragStart || dragStart.pointerId !== event.pointerId) { - return false; - } - - // Treat browser pointer events as resize triggers, not the source of truth for - // coordinates. The host can move or relayout this WebView while processing the - // same resize request, so the next browser coordinate may include movement of the - // child view itself. We keep this JS delta only as the non-native fallback; on - // macOS the Rust command uses dragId to replace it with a desktop cursor delta. - const deltaX = event.screenX - dragStart.lastX; - const deltaY = event.screenY - dragStart.lastY; - if (deltaX === 0 && deltaY === 0) { - return true; - } - - dragStart.width += deltaX; - dragStart.height += deltaY; - dragStart.lastX = event.screenX; - dragStart.lastY = event.screenY; - requestResize(dragStart.width, dragStart.height); - return true; - }; - - const finishResize = (event: PointerEvent) => { - if (!applyResizeDelta(event)) { - return; - } - const dragId = dragStart?.dragId; - dragStart = null; - if (dragId !== undefined) { - endResizeDragAfterDrain(dragId); - } - restoreHostFocus?.(event.target); - }; - - const cancelResize = (event: PointerEvent) => { - if (!dragStart || dragStart.pointerId !== event.pointerId) { - return; - } - const dragId = dragStart.dragId; - dragStart = null; - void invoke("end_gui_resize_drag", { - request: { dragId }, - }).catch(() => undefined); - restoreHostFocus?.(event.target); - }; - - resizeGrip.addEventListener("pointerdown", (event) => { - const dragId = ++resizeDragSeq; - dragStart = { - pointerId: event.pointerId, - dragId, - width: window.innerWidth, - height: window.innerHeight, - lastX: event.screenX, - lastY: event.screenY, - }; - void invoke("begin_gui_resize_drag", { - request: { - dragId, - width: dragStart.width, - height: dragStart.height, - }, - }).catch(() => undefined); - resizeGrip.setPointerCapture(event.pointerId); - event.preventDefault(); - }); - - window.addEventListener("pointermove", (event) => { - if (!dragStart || dragStart.pointerId !== event.pointerId) { - return; - } - applyResizeDelta(event); - event.preventDefault(); - }); - - window.addEventListener("pointerup", finishResize); - window.addEventListener("pointercancel", cancelResize); -} diff --git a/plugins/wrac-gain/src-gui/vite.config.ts b/plugins/wrac-gain/src-gui/vite.config.ts index 83add0ce..1f54e5e4 100644 --- a/plugins/wrac-gain/src-gui/vite.config.ts +++ b/plugins/wrac-gain/src-gui/vite.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "vite"; export default defineConfig({ + resolve: { + preserveSymlinks: true, + }, server: { // Debug plugins load the WebView from 127.0.0.1. Vite's default `localhost` // may bind only to the IPv6 loopback in some environments, causing a mismatch diff --git a/plugins/wrac-gain/src-plugin/Cargo.toml b/plugins/wrac-gain/src-plugin/Cargo.toml index 264fe8da..48099791 100644 --- a/plugins/wrac-gain/src-plugin/Cargo.toml +++ b/plugins/wrac-gain/src-plugin/Cargo.toml @@ -8,70 +8,8 @@ repository = "https://github.com/novonotes/wrac-plugin-template" publish = false build = "build.rs" -# WRAC metadata is the single source of truth for host-visible descriptors, -# GUI About metadata, bundle names, wrapper arguments, AUv2 registration, -# WebView data dirs, and logs. Set these explicitly before shipping; changing -# public IDs or FourCC values after release can break host project recall. [package.metadata.wrac] -# User-visible vendor name shown by hosts and the template GUI. -company_name = "Your Company" -# AUv2 manufacturer code. Use the same 4-byte ASCII code across your AUv2 plugin catalog. -auv2_manufacturer_code = "YrCo" -# AAX manufacturer ID registered with Avid. Keep it stable across the AAX product catalog. -aax_manufacturer_id = "YrCo" -# Shared CLAP/VST3/AAX bundle name. Product-specific AU bundles use plugin_name below. -bundle_name = "WRAC Gain" -# Reverse-DNS bundle identifier used by macOS bundles. Keep it stable. -bundle_identifier = "com.your-company.wrac-gain" -# Commercial metadata shown by hosts and About views where supported. -homepage_url = "https://example.com/wrac-gain" -manual_url = "https://example.com/wrac-gain/manual" -support_url = "https://example.com/support" -description = "Simple gain plugin" -copyright = "Copyright 2026 Your Company" -# Product policy for plugin formats. xtask defaults use this list, while -# explicit --target requests fail if the requested format is not declared here. -supported_formats = ["clap", "vst3", "au", "aax"] - -[package.metadata.wrac.validation.disabled_rules.fender-studio-pro-generic-editor-single-knob] -reason = "WRAC Gain is a minimal one-knob starter example. Products that support Fender Studio Pro should add a second real visible control instead of shipping this exception." - -[[package.metadata.wrac.plugins]] -# Reverse-DNS identifier. Use your own domain or another namespace you control. -plugin_id = "com.your-company.wrac-gain" -# User-visible plugin name shown by hosts and the template GUI. -plugin_name = "WRAC Gain" -# CLAP feature identifiers used in the generated CLAP descriptor. -clap_features = ["audio-effect", "utility", "stereo"] -# VST3 PClassInfo2 subCategories string. Keep this explicit because CLAP -# features do not always map cleanly to commercial host browser categories. -vst3_subcategories = "Fx|Tools" -# VST3 component ID. Generate a UUID once before release and keep it stable; -# changing it makes hosts treat the plugin as a different VST3. -vst3_component_id = "822011ca-37ec-5cef-92d7-ec7e67207195" -# User-visible standalone app name. -standalone_name = "WRAC Gain Standalone" -# AUv2 component type. Audio effects usually use "aufx"; instruments usually use "aumu". -auv2_type = "aufx" -# AUv2 subtype. Pick a unique 4-byte ASCII code for each plugin from the same vendor. -auv2_subtype = "WtGn" -# AAX category names are converted to AAX_EPlugInCategory bits. -aax_categories = ["effect"] -# AAX product ID registered with Avid. Usually stable per product. -aax_product_id = "WtGn" - -[[package.metadata.wrac.plugins.aax_stem_configs]] -# Each AAX stem config needs a unique PlugInID_Native for Pro Tools recall. -name = "Mono" -input = "mono" -output = "mono" -plugin_id = "WtGM" - -[[package.metadata.wrac.plugins.aax_stem_configs]] -name = "Stereo" -input = "stereo" -output = "stereo" -plugin_id = "WtGS" +manifest = "wrac-plugin.toml" [lib] crate-type = ["rlib", "staticlib", "cdylib"] @@ -94,6 +32,4 @@ wxp = { workspace = true } unreachable_pub = "deny" [build-dependencies] -serde = { version = "1", features = ["derive"] } -toml = "0.8" wrac_build = { path = "../../../crates/wrac_build" } diff --git a/plugins/wrac-gain/src-plugin/build.rs b/plugins/wrac-gain/src-plugin/build.rs index d7105496..504e6eae 100644 --- a/plugins/wrac-gain/src-plugin/build.rs +++ b/plugins/wrac-gain/src-plugin/build.rs @@ -1,32 +1,19 @@ -//! Build script that bundles `src-gui/dist` into a single zip and writes it to `OUT_DIR` -//! for release builds. -//! -//! The resulting zip is embedded into the plugin binary via `include_bytes!` in `gui.rs` -//! and served at runtime by the WebView under the `wxp-plugin://` scheme. -//! In debug builds Vite's dev server is used instead, so this script does nothing. +//! Build script that generates host-visible plugin descriptors and bundles +//! `src-gui/dist` into a single zip for release builds. -use std::collections::HashSet; use std::env; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use serde::Deserialize; -use wrac_build::{FrontendBundleConfig, build_frontend_bundle}; +use wrac_build::{ + FrontendBundleConfig, PluginDescriptorCodegenConfig, build_frontend_bundle, + generate_plugin_descriptors, +}; fn main() { - println!("cargo:rerun-if-changed=Cargo.toml"); + generate_plugin_descriptors(PluginDescriptorCodegenConfig::default()) + .expect("failed to generate WRAC plugin descriptors"); let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); - let manifest_path = manifest_dir.join("Cargo.toml"); - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); - // Make [package.metadata.wrac] in Cargo.toml the single source of truth for plugin - // identity. The plugin descriptor table is generated from it below, while xtask reads - // the same manifest directly for wrapper and artifact names. - let metadata = read_wrac_metadata(&manifest_path).expect("failed to read WRAC metadata"); - write_plugin_products(&metadata, &out_dir) - .expect("failed to write WRAC plugin product metadata"); - let gui_dist_dir = manifest_dir .parent() .expect("src-plugin must have a parent directory") @@ -46,534 +33,3 @@ fn main() { }) .expect("failed to create frontend zip"); } - -fn write_plugin_products(metadata: &WracMetadata, out_dir: &Path) -> io::Result<()> { - // Rust cannot iterate over Cargo manifest metadata at compile time. Generate the - // descriptor table once in build.rs so the plugin factory still has a static, - // allocation-free source for count/descriptor/create queries. - let mut rust = - String::from("// Generated by build.rs from package.metadata.wrac. Do not edit by hand.\n"); - rust.push_str(&format!( - "pub(crate) const COMPANY_NAME: &str = {:?};\n", - metadata.company_name - )); - rust.push_str(&format!( - "pub(crate) const AUV2_MANUFACTURER_CODE: [u8; 4] = {};\n", - four_ascii_array_literal(&metadata.auv2_manufacturer_code) - )); - rust.push_str(&format!( - "pub(crate) const AAX_MANUFACTURER_ID: u32 = {};\n", - fourcc_literal(&metadata.aax_manufacturer_id) - )); - rust.push_str(&format!( - "pub(crate) const AAX_PACKAGE_VERSION: u32 = {};\n", - aax_package_version_literal()? - )); - // CLAP hosts read feature strings during discovery. Generate them from the - // manifest so the production-readiness checks and runtime descriptor cannot - // drift after a product rename or capability change. - for (index, plugin) in metadata.plugins.iter().enumerate() { - rust.push_str(&format!( - "const PLUGIN_{index}_FEATURES: &[PluginFeature] = &{};\n", - plugin_feature_array_literal(&plugin.clap_features)? - )); - // AAX asks for stem configs through C callbacks during package description, - // before any Rust plugin instance exists. Generate static tables so the - // callback can be lifetime-independent and allocation-free. - write_aax_stem_config_statics(&mut rust, index, plugin)?; - rust.push_str(&format!( - "unsafe extern \"C\" fn plugin_{index}_aax_get_num_stem_configs() -> u32 {{\n" - )); - rust.push_str(&format!( - " PLUGIN_{index}_AAX_STEM_CONFIGS.len() as u32\n" - )); - rust.push_str("}\n"); - rust.push_str(&format!( - "unsafe extern \"C\" fn plugin_{index}_aax_get_stem_config(index: u32) -> *const AaxStemConfig {{\n" - )); - rust.push_str(&format!( - " PLUGIN_{index}_AAX_STEM_CONFIGS.get(index as usize).map_or(core::ptr::null(), |config| config)\n" - )); - rust.push_str("}\n"); - } - rust.push_str("pub(crate) const PLUGIN_DESCRIPTORS: &[PluginDescriptor] = &[\n"); - for (index, plugin) in metadata.plugins.iter().enumerate() { - rust.push_str(" PluginDescriptor {\n"); - rust.push_str(&format!(" id: {:?},\n", plugin.plugin_id)); - rust.push_str(&format!(" name: {:?},\n", plugin.plugin_name)); - rust.push_str(" vendor: COMPANY_NAME,\n"); - rust.push_str(&format!(" url: {:?},\n", metadata.homepage_url)); - rust.push_str(&format!(" manual_url: {:?},\n", metadata.manual_url)); - rust.push_str(&format!( - " support_url: {:?},\n", - metadata.support_url - )); - rust.push_str(" version: env!(\"CARGO_PKG_VERSION\"),\n"); - rust.push_str(&format!( - " description: {:?},\n", - metadata.description - )); - rust.push_str(&format!(" features: PLUGIN_{index}_FEATURES,\n")); - rust.push_str(" auv2: Some(Auv2Descriptor {\n"); - rust.push_str(" manufacturer_code: AUV2_MANUFACTURER_CODE,\n"); - rust.push_str(" manufacturer_name: COMPANY_NAME,\n"); - rust.push_str(&format!( - " plugin_type: {},\n", - four_ascii_array_literal(&plugin.auv2_type) - )); - rust.push_str(&format!( - " plugin_subtype: {},\n", - four_ascii_array_literal(&plugin.auv2_subtype) - )); - rust.push_str(" }),\n"); - rust.push_str(" vst3: Some(Vst3Descriptor {\n"); - rust.push_str(&format!( - " subcategories: {:?},\n", - plugin.vst3_subcategories - )); - rust.push_str(&format!( - " component_id: {},\n", - uuid_array_literal(&plugin.vst3_component_id)? - )); - rust.push_str(" }),\n"); - rust.push_str(" aax: Some(AaxDescriptor {\n"); - rust.push_str(&format!( - " package_name: {:?},\n", - metadata.bundle_name - )); - rust.push_str(" package_version: AAX_PACKAGE_VERSION,\n"); - rust.push_str(&format!( - " categories: {},\n", - aax_categories_literal(&plugin.aax_categories)? - )); - rust.push_str(" manufacturer_id: AAX_MANUFACTURER_ID,\n"); - rust.push_str(&format!( - " product_id: {},\n", - fourcc_literal(&plugin.aax_product_id) - )); - rust.push_str(&format!( - " get_num_stem_configs: plugin_{index}_aax_get_num_stem_configs,\n" - )); - rust.push_str(&format!( - " get_stem_config: plugin_{index}_aax_get_stem_config,\n" - )); - rust.push_str(" }),\n"); - rust.push_str(" },\n"); - } - rust.push_str("];\n"); - fs::write(out_dir.join("wrac_plugin_products.rs"), rust) -} - -fn four_ascii_array_literal(value: &str) -> String { - let bytes = value.as_bytes(); - format!("[{}, {}, {}, {}]", bytes[0], bytes[1], bytes[2], bytes[3]) -} - -fn plugin_feature_array_literal(features: &[String]) -> io::Result { - let mut items = Vec::new(); - for feature in features { - items.push(plugin_feature_literal(feature)?); - } - Ok(format!("[{}]", items.join(", "))) -} - -fn plugin_feature_literal(feature: &str) -> io::Result<&'static str> { - match feature { - "audio-effect" => Ok("PluginFeature::AudioEffect"), - "analyzer" => Ok("PluginFeature::Analyzer"), - "ambisonic" => Ok("PluginFeature::Ambisonic"), - "chorus" => Ok("PluginFeature::Chorus"), - "compressor" => Ok("PluginFeature::Compressor"), - "de-esser" => Ok("PluginFeature::DeEsser"), - "delay" => Ok("PluginFeature::Delay"), - "instrument" => Ok("PluginFeature::Instrument"), - "note-effect" => Ok("PluginFeature::NoteEffect"), - "note-detector" => Ok("PluginFeature::NoteDetector"), - "drum" => Ok("PluginFeature::Drum"), - "drum-machine" => Ok("PluginFeature::DrumMachine"), - "equalizer" => Ok("PluginFeature::Equalizer"), - "expander" => Ok("PluginFeature::Expander"), - "filter" => Ok("PluginFeature::Filter"), - "flanger" => Ok("PluginFeature::Flanger"), - "frequency-shifter" => Ok("PluginFeature::FrequencyShifter"), - "gate" => Ok("PluginFeature::Gate"), - "glitch" => Ok("PluginFeature::Glitch"), - "granular" => Ok("PluginFeature::Granular"), - "distortion" => Ok("PluginFeature::Distortion"), - "limiter" => Ok("PluginFeature::Limiter"), - "mastering" => Ok("PluginFeature::Mastering"), - "mixing" => Ok("PluginFeature::Mixing"), - "mono" => Ok("PluginFeature::Mono"), - "multi-effects" => Ok("PluginFeature::MultiEffects"), - "phaser" => Ok("PluginFeature::Phaser"), - "phase-vocoder" => Ok("PluginFeature::PhaseVocoder"), - "pitch-correction" => Ok("PluginFeature::PitchCorrection"), - "pitch-shifter" => Ok("PluginFeature::PitchShifter"), - "restoration" => Ok("PluginFeature::Restoration"), - "reverb" => Ok("PluginFeature::Reverb"), - "sampler" => Ok("PluginFeature::Sampler"), - "stereo" => Ok("PluginFeature::Stereo"), - "surround" => Ok("PluginFeature::Surround"), - "synthesizer" => Ok("PluginFeature::Synthesizer"), - "transient-shaper" => Ok("PluginFeature::TransientShaper"), - "tremolo" => Ok("PluginFeature::Tremolo"), - "utility" => Ok("PluginFeature::Utility"), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unsupported package.metadata.wrac.plugins.clap_features value: {feature}"), - )), - } -} - -#[derive(Debug, Deserialize)] -struct CargoManifest { - package: CargoPackage, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - metadata: Option, -} - -#[derive(Debug, Deserialize)] -struct PackageMetadata { - wrac: Option, -} - -#[derive(Debug, Deserialize)] -struct WracMetadata { - company_name: String, - auv2_manufacturer_code: String, - aax_manufacturer_id: String, - bundle_name: String, - bundle_identifier: String, - homepage_url: String, - manual_url: String, - support_url: String, - description: String, - copyright: String, - plugins: Vec, -} - -#[derive(Debug, Deserialize)] -struct WracPluginMetadata { - plugin_id: String, - plugin_name: String, - clap_features: Vec, - vst3_subcategories: String, - vst3_component_id: String, - standalone_name: String, - auv2_type: String, - auv2_subtype: String, - aax_categories: Vec, - aax_product_id: String, - aax_stem_configs: Vec, -} - -#[derive(Debug, Deserialize)] -struct AaxStemConfigMetadata { - name: String, - input: String, - output: String, - plugin_id: String, -} - -fn read_wrac_metadata(manifest_path: &Path) -> io::Result { - let manifest = fs::read_to_string(manifest_path)?; - let cargo_manifest: CargoManifest = toml::from_str(&manifest) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let metadata = cargo_manifest - .package - .metadata - .ok_or_else(missing_wrac_metadata)? - .wrac - .ok_or_else(missing_wrac_metadata)?; - validate_required("package.metadata.wrac.company_name", &metadata.company_name)?; - validate_four_ascii("auv2_manufacturer_code", &metadata.auv2_manufacturer_code)?; - validate_four_ascii("aax_manufacturer_id", &metadata.aax_manufacturer_id)?; - validate_required( - "package.metadata.wrac.bundle_identifier", - &metadata.bundle_identifier, - )?; - validate_required("package.metadata.wrac.homepage_url", &metadata.homepage_url)?; - validate_required("package.metadata.wrac.manual_url", &metadata.manual_url)?; - validate_required("package.metadata.wrac.support_url", &metadata.support_url)?; - validate_required("package.metadata.wrac.description", &metadata.description)?; - validate_required("package.metadata.wrac.copyright", &metadata.copyright)?; - if metadata.plugins.is_empty() { - return Err(missing_metadata("plugins")); - } - let mut plugin_ids = HashSet::new(); - let mut standalone_names = HashSet::new(); - let mut auv2_ids = HashSet::new(); - for plugin in &metadata.plugins { - validate_required("package.metadata.wrac.plugins.plugin_id", &plugin.plugin_id)?; - validate_required( - "package.metadata.wrac.plugins.plugin_name", - &plugin.plugin_name, - )?; - validate_non_empty_list( - "package.metadata.wrac.plugins.clap_features", - &plugin.clap_features, - )?; - for feature in &plugin.clap_features { - plugin_feature_literal(feature)?; - } - validate_required( - "package.metadata.wrac.plugins.vst3_subcategories", - &plugin.vst3_subcategories, - )?; - uuid_array_literal(&plugin.vst3_component_id)?; - validate_required( - "package.metadata.wrac.plugins.standalone_name", - &plugin.standalone_name, - )?; - validate_four_ascii("auv2_type", &plugin.auv2_type)?; - validate_four_ascii("auv2_subtype", &plugin.auv2_subtype)?; - validate_non_empty_list( - "package.metadata.wrac.plugins.aax_categories", - &plugin.aax_categories, - )?; - for category in &plugin.aax_categories { - aax_category_literal(category)?; - } - validate_four_ascii("plugins.aax_product_id", &plugin.aax_product_id)?; - if plugin.aax_stem_configs.is_empty() { - return Err(missing_metadata("plugins.aax_stem_configs")); - } - let mut aax_plugin_ids = HashSet::new(); - for stem_config in &plugin.aax_stem_configs { - validate_required( - "package.metadata.wrac.plugins.aax_stem_configs.name", - &stem_config.name, - )?; - aax_stem_format_literal(&stem_config.input)?; - aax_stem_format_literal(&stem_config.output)?; - validate_four_ascii("plugins.aax_stem_configs.plugin_id", &stem_config.plugin_id)?; - validate_unique( - "aax_stem_configs.plugin_id", - &stem_config.plugin_id, - &mut aax_plugin_ids, - )?; - } - validate_unique("plugin_id", &plugin.plugin_id, &mut plugin_ids)?; - validate_unique( - "standalone_name", - &plugin.standalone_name, - &mut standalone_names, - )?; - if !auv2_ids.insert((plugin.auv2_type.as_str(), plugin.auv2_subtype.as_str())) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "package.metadata.wrac.plugins AUv2 type/subtype must be unique: {}/{}", - plugin.auv2_type, plugin.auv2_subtype - ), - )); - } - } - Ok(metadata) -} - -fn validate_non_empty_list(key: &str, values: &[String]) -> io::Result<()> { - if values.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("{key} must not be empty"), - )); - } - for value in values { - validate_required(key, value)?; - } - Ok(()) -} - -fn missing_metadata(key: &str) -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - format!("missing package.metadata.wrac.{key} in src-plugin/Cargo.toml"), - ) -} - -fn missing_wrac_metadata() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - "missing package.metadata.wrac in src-plugin/Cargo.toml", - ) -} - -fn validate_required(key: &str, value: &str) -> io::Result<()> { - if value.is_empty() { - Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("{key} must not be empty"), - )) - } else { - Ok(()) - } -} - -fn validate_four_ascii(key: &str, value: &str) -> io::Result<()> { - if value.len() == 4 && value.is_ascii() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("package.metadata.wrac.{key} must be exactly 4 ASCII bytes"), - )) - } -} - -fn fourcc_literal(value: &str) -> String { - let bytes = value.as_bytes(); - format!( - "0x{:02X}{:02X}{:02X}{:02X}", - bytes[0], bytes[1], bytes[2], bytes[3] - ) -} - -fn aax_categories_literal(categories: &[String]) -> io::Result { - let mut values = Vec::new(); - for category in categories { - values.push(aax_category_literal(category)?); - } - Ok(values.join(" | ")) -} - -fn aax_category_literal(category: &str) -> io::Result<&'static str> { - // Keep the manifest vocabulary human-readable, but emit the AAX bit values - // directly so the runtime crate does not need to link against Avid headers. - match category { - "eq" => Ok("0x0000_0001"), - "dynamics" => Ok("0x0000_0002"), - "pitch-shift" => Ok("0x0000_0004"), - "reverb" => Ok("0x0000_0008"), - "delay" => Ok("0x0000_0010"), - "modulation" => Ok("0x0000_0020"), - "harmonic" => Ok("0x0000_0040"), - "noise-reduction" => Ok("0x0000_0080"), - "dither" => Ok("0x0000_0100"), - "sound-field" => Ok("0x0000_0200"), - "hardware-generator" => Ok("0x0000_0400"), - "software-generator" => Ok("0x0000_0800"), - "wrapped-plugin" => Ok("0x0000_1000"), - "effect" => Ok("0x0000_2000"), - "midi-effect" => Ok("0x0001_0000"), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unsupported package.metadata.wrac.plugins.aax_categories value: {category}"), - )), - } -} - -fn write_aax_stem_config_statics( - rust: &mut String, - plugin_index: usize, - plugin: &WracPluginMetadata, -) -> io::Result<()> { - let mut items = Vec::new(); - for (stem_index, stem_config) in plugin.aax_stem_configs.iter().enumerate() { - rust.push_str(&format!( - "static PLUGIN_{plugin_index}_AAX_STEM_{stem_index}_NAME: &[u8] = &{:?};\n", - nul_terminated(&stem_config.name) - )); - items.push(format!( - "AaxStemConfig {{ name: PLUGIN_{plugin_index}_AAX_STEM_{stem_index}_NAME.as_ptr().cast(), format_in: {}, format_out: {}, plugin_id: {} }}", - aax_stem_format_literal(&stem_config.input)?, - aax_stem_format_literal(&stem_config.output)?, - fourcc_literal(&stem_config.plugin_id) - )); - } - rust.push_str(&format!( - "static PLUGIN_{plugin_index}_AAX_STEM_CONFIGS: &[AaxStemConfig] = &[{}];\n", - items.join(", ") - )); - Ok(()) -} - -fn aax_stem_format_literal(format: &str) -> io::Result<&'static str> { - // The starter template only emits mono/stereo native processing layouts. Add - // more AAX stem constants here only after the Rust audio-port configuration and - // validator coverage for those layouts are added together. - match format { - "mono" => Ok("1"), - "stereo" => Ok("0x0001_0002"), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "package.metadata.wrac.plugins.aax_stem_configs format must be mono or stereo: {format}" - ), - )), - } -} - -fn nul_terminated(value: &str) -> Vec { - // Stem names cross the C ABI as raw pointers; store the terminator in the - // generated static bytes rather than appending it at callback time. - let mut bytes = value.as_bytes().to_vec(); - bytes.push(0); - bytes -} - -fn aax_package_version_literal() -> io::Result { - // AAX package versions are not semantic-version strings. Encoding Cargo's - // MAJOR.MINOR.PATCH as 0xMMmmpp00 keeps package identity deterministic while - // leaving the lowest byte unused. - let major = aax_version_component("CARGO_PKG_VERSION_MAJOR")?; - let minor = aax_version_component("CARGO_PKG_VERSION_MINOR")?; - let patch = aax_version_component("CARGO_PKG_VERSION_PATCH")?; - Ok(format!("0x{major:02X}{minor:02X}{patch:02X}00")) -} - -fn aax_version_component(name: &str) -> io::Result { - let value = env::var(name) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))? - .parse::() - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - if value > 0xFF { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("{name} must fit in one byte for AAX package_version: {value}"), - )); - } - Ok(value) -} - -fn uuid_array_literal(value: &str) -> io::Result { - let hex = value.replace('-', ""); - if hex.len() != 32 || !hex.as_bytes().iter().all(u8::is_ascii_hexdigit) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("package.metadata.wrac.plugins.vst3_component_id must be a UUID: {value}"), - )); - } - let mut bytes = [0_u8; 16]; - for (index, byte) in bytes.iter_mut().enumerate() { - let start = index * 2; - *byte = u8::from_str_radix(&hex[start..start + 2], 16).map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("package.metadata.wrac.plugins.vst3_component_id must be a UUID: {error}"), - ) - })?; - } - // clap-wrapper applies Steinberg's COM-compatible TUID byte flip before exposing - // the VST3 class ID. Store the inverse form here so the host-visible CID matches - // the UUID written in package.metadata.wrac.plugins.vst3_component_id. - bytes.swap(0, 3); - bytes.swap(1, 2); - bytes.swap(4, 5); - bytes.swap(6, 7); - Ok(format!("{bytes:?}")) -} - -fn validate_unique<'a>(key: &str, value: &'a str, seen: &mut HashSet<&'a str>) -> io::Result<()> { - if seen.insert(value) { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("package.metadata.wrac.plugins.{key} must be unique: {value}"), - )) - } -} diff --git a/plugins/wrac-gain/src-plugin/src/audio.rs b/plugins/wrac-gain/src-plugin/src/audio.rs index 2e94f41e..492cedee 100644 --- a/plugins/wrac-gain/src-plugin/src/audio.rs +++ b/plugins/wrac-gain/src-plugin/src/audio.rs @@ -1,7 +1,7 @@ //! DSP running on the audio thread. //! //! This sample simply multiplies the input by a gain and writes it back. -//! [`Processor::process`] is a realtime function called repeatedly for each small buffer, +//! [`ActiveProcessor::process`] is a realtime function called repeatedly for each small buffer, //! so the rule is to **avoid allocations and locks**. Shared state is read lock-free from //! [`SharedState`]. @@ -9,11 +9,14 @@ use std::any::Any; use std::sync::Arc; use wrac_clap_adapter::{ - AudioPairedChannels, AudioPortChannels, AudioProcessBuffer, InputEvent, PluginResult, - ProcessContext, ProcessStatus, Processor, + ActiveProcessor, AudioPairedChannels, AudioPortChannels, AudioProcessBuffer, InactiveProcessor, + InputEvent, ParamFlushContext, PluginResult, ProcessContext, ProcessStatus, }; -use crate::plugin::{PARAM_BYPASS_ID, PARAM_GAIN_ID, parameter_host_input_to_plain}; +use crate::plugin::{ + PARAM_BYPASS_ID, PARAM_GAIN_ID, WracGainParamOutputQueue, apply_param_input_events, + parameter_host_input_to_plain, +}; use crate::state::SharedState; /// The DSP instance created at `activate()` and owned by the host's audio thread. @@ -23,11 +26,12 @@ use crate::state::SharedState; /// `shared` uses atomics and is safe to read during `process()`. /// `audio_channel_count` is a snapshot copied from the plugin's audio layout store at /// activate time. Because the adapter rejects layout changes while active, the running -/// Processor's contract cannot change mid-flight. Even when a product's DSP must vary +/// active processor's contract cannot change mid-flight. Even when a product's DSP must vary /// with layout, it is safer to convert the needed settings at activate time and pass them /// in rather than storing an `Arc>`. pub(crate) struct WracGainAudioProcessor { shared: Arc, + param_output_queue: Arc, // Gain itself does not use channel count, but this field demonstrates the pattern // "snapshot layout at activate time and store it as a field." // In debug builds, the actual buffer is verified to match this snapshot. @@ -35,15 +39,20 @@ pub(crate) struct WracGainAudioProcessor { } impl WracGainAudioProcessor { - pub(crate) fn new(shared: Arc, audio_channel_count: u32) -> Self { + pub(crate) fn new( + shared: Arc, + param_output_queue: Arc, + audio_channel_count: u32, + ) -> Self { Self { shared, + param_output_queue, audio_channel_count, } } } -impl Processor for WracGainAudioProcessor { +impl ActiveProcessor for WracGainAudioProcessor { fn into_any(self: Box) -> Box { self } @@ -67,6 +76,12 @@ impl Processor for WracGainAudioProcessor { self.process_no_alloc(context) } } + + fn flush_params(&mut self, mut context: ParamFlushContext<'_>) -> PluginResult<()> { + apply_param_input_events(&self.shared, &context.events.input); + self.param_output_queue.drain(&mut context.events.output); + Ok(()) + } } impl WracGainAudioProcessor { @@ -77,6 +92,8 @@ impl WracGainAudioProcessor { self.audio_channel_count, ); + self.param_output_queue.drain(&mut context.events.output); + // Gain at the start of this block; updated each time a parameter event arrives. let mut gain = self.shared.gain(); let mut bypass = self.shared.bypass(); @@ -127,6 +144,35 @@ impl WracGainAudioProcessor { } } +pub(crate) struct WracGainInactiveProcessor { + shared: Arc, + param_output_queue: Arc, +} + +impl WracGainInactiveProcessor { + pub(crate) fn new( + shared: Arc, + param_output_queue: Arc, + ) -> Self { + Self { + shared, + param_output_queue, + } + } +} + +impl InactiveProcessor for WracGainInactiveProcessor { + fn into_any(self: Box) -> Box { + self + } + + fn flush_params(&mut self, mut context: ParamFlushContext<'_>) -> PluginResult<()> { + apply_param_input_events(&self.shared, &context.events.input); + self.param_output_queue.drain(&mut context.events.output); + Ok(()) + } +} + fn apply_realtime_param_event( shared: &SharedState, parameter_id: u32, diff --git a/plugins/wrac-gain/src-plugin/src/commands.rs b/plugins/wrac-gain/src-plugin/src/commands.rs index e0e22007..722ed631 100644 --- a/plugins/wrac-gain/src-plugin/src/commands.rs +++ b/plugins/wrac-gain/src-plugin/src/commands.rs @@ -9,16 +9,16 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::json; -use wrac_clap_adapter::{ - HostContext, HostFamily, HostGuiResizeRequester, HostParamsEditNotifier, PluginDescriptor, -}; +use wrac_clap_adapter::{HostContext, HostFamily, HostGui, PluginDescriptor}; use wrac_wxp_gui::{ WxpGuiResizeHandle, register_native_cursor_bridge_commands, register_resize_commands, }; use wxp::{Channel, WxpCommandHandler}; use crate::gui::{GuiStateNotifier, GuiSubscriptionId, editor_page_payload, parameter_payload}; -use crate::plugin::{parameter_default_value, parameter_host_value, parameter_text_value}; +use crate::plugin::{ + WracGainParamOutputQueue, parameter_default_value, parameter_host_value, parameter_text_value, +}; use crate::state::{EditorPage, ProjectStateStore, SharedState}; #[derive(Debug, Deserialize)] @@ -44,8 +44,8 @@ pub(crate) struct CommandRegistrationDependencies { pub(crate) shared: Arc, pub(crate) gui_notifier: Arc, pub(crate) descriptor: PluginDescriptor, - pub(crate) host_parameter_edit_notifier: Arc, - pub(crate) host_gui_resize_requester: Arc, + pub(crate) param_output_queue: Arc, + pub(crate) host_gui: Arc, pub(crate) gui_resize_handle: WxpGuiResizeHandle, pub(crate) host_context: HostContext, } @@ -63,8 +63,8 @@ pub(crate) fn register_commands( shared, gui_notifier, descriptor, - host_parameter_edit_notifier, - host_gui_resize_requester, + param_output_queue, + host_gui, gui_resize_handle, host_context, } = dependencies; @@ -134,22 +134,22 @@ pub(crate) fn register_commands( { let shared = shared.clone(); let gui_notifier = gui_notifier.clone(); - let host_parameter_edit_notifier = host_parameter_edit_notifier.clone(); + let param_output_queue = param_output_queue.clone(); command_handler.register_sync("set_parameter_text", move |ctx| { let parameter_id = ctx.arg::("parameterId").map_err(|e| e.to_string())?; let text = ctx.arg::("text").map_err(|e| e.to_string())?; let value = parameter_text_value(parameter_id, &text).map_err(|e| e.to_string())?; - host_parameter_edit_notifier.begin_edit(parameter_id); + param_output_queue.begin_edit(parameter_id); let applied = shared .set_parameter_value(parameter_id, value) .ok_or_else(|| "invalid parameter id".to_string())?; gui_notifier.notify_parameter(parameter_id, applied); - host_parameter_edit_notifier.update_edit( + param_output_queue.update_edit( parameter_id, parameter_host_value(parameter_id, applied) .map_err(|_| "invalid parameter id".to_string())?, ); - host_parameter_edit_notifier.end_edit(parameter_id); + param_output_queue.end_edit(parameter_id); Ok::<_, String>(parameter_payload(parameter_id, applied)) }); } @@ -158,31 +158,31 @@ pub(crate) fn register_commands( { let shared = shared.clone(); let gui_notifier = gui_notifier.clone(); - let host_parameter_edit_notifier = host_parameter_edit_notifier.clone(); + let param_output_queue = param_output_queue.clone(); command_handler.register_sync("reset_parameter_to_default", move |ctx| { let parameter_id = ctx.arg::("parameterId").map_err(|e| e.to_string())?; let value = parameter_default_value(parameter_id).map_err(|e| e.to_string())?; - host_parameter_edit_notifier.begin_edit(parameter_id); + param_output_queue.begin_edit(parameter_id); let applied = shared .set_parameter_value(parameter_id, value) .ok_or_else(|| "invalid parameter id".to_string())?; gui_notifier.notify_parameter(parameter_id, applied); - host_parameter_edit_notifier.update_edit( + param_output_queue.update_edit( parameter_id, parameter_host_value(parameter_id, applied) .map_err(|_| "invalid parameter id".to_string())?, ); - host_parameter_edit_notifier.end_edit(parameter_id); + param_output_queue.end_edit(parameter_id); Ok::<_, String>(parameter_payload(parameter_id, applied)) }); } // Called when the user first touches a control. Signals the start of an undo unit to the host. { - let host_parameter_edit_notifier = host_parameter_edit_notifier.clone(); + let param_output_queue = param_output_queue.clone(); command_handler.register_sync("begin_parameter_gesture", move |ctx| { let parameter_id = ctx.arg::("parameterId").map_err(|e| e.to_string())?; - host_parameter_edit_notifier.begin_edit(parameter_id); + param_output_queue.begin_edit(parameter_id); Ok::<_, String>(json!({ "ok": true })) }); } @@ -191,7 +191,7 @@ pub(crate) fn register_commands( { let shared = shared.clone(); let gui_notifier = gui_notifier.clone(); - let host_parameter_edit_notifier = host_parameter_edit_notifier.clone(); + let param_output_queue = param_output_queue.clone(); command_handler.register_sync("set_parameter_value", move |ctx| { let parameter_id = ctx.arg::("parameterId").map_err(|e| e.to_string())?; let value = ctx.arg::("value").map_err(|e| e.to_string())?; @@ -199,7 +199,7 @@ pub(crate) fn register_commands( .set_parameter_value(parameter_id, value) .ok_or_else(|| "invalid parameter id".to_string())?; gui_notifier.notify_parameter(parameter_id, applied); - host_parameter_edit_notifier.update_edit( + param_output_queue.update_edit( parameter_id, parameter_host_value(parameter_id, applied) .map_err(|_| "invalid parameter id".to_string())?, @@ -210,10 +210,10 @@ pub(crate) fn register_commands( // Called when the user releases the control. Signals the end of the undo unit to the host. { - let host_parameter_edit_notifier = host_parameter_edit_notifier.clone(); + let param_output_queue = param_output_queue.clone(); command_handler.register_sync("end_parameter_gesture", move |ctx| { let parameter_id = ctx.arg::("parameterId").map_err(|e| e.to_string())?; - host_parameter_edit_notifier.end_edit(parameter_id); + param_output_queue.end_edit(parameter_id); Ok::<_, String>(json!({ "ok": true })) }); } @@ -270,11 +270,7 @@ pub(crate) fn register_commands( // Window-management commands are shared wxp GUI plumbing, not WRAC Gain product state. // Register them here so the product command list stays the single Rust/TS rendezvous point. - register_resize_commands( - &command_handler, - host_gui_resize_requester, - gui_resize_handle, - ); + register_resize_commands(&command_handler, host_gui, gui_resize_handle); register_native_cursor_bridge_commands(&command_handler); } diff --git a/plugins/wrac-gain/src-plugin/src/gui.rs b/plugins/wrac-gain/src-plugin/src/gui.rs index 73e873e7..1c3d67da 100644 --- a/plugins/wrac-gain/src-plugin/src/gui.rs +++ b/plugins/wrac-gain/src-plugin/src/gui.rs @@ -23,14 +23,13 @@ use novonotes_run_loop::RunLoopLocal; use runtime::{ DEFAULT_GUI_SIZE, GuiRuntimeDependencies, MAX_GUI_SIZE, MIN_GUI_SIZE, WracGainGuiRuntime, }; -use wrac_clap_adapter::{ - GuiConfig, GuiSize, HostContext, HostGuiResizeRequester, HostParamsEditNotifier, PluginResult, -}; +use wrac_clap_adapter::{GuiConfig, GuiSize, HostContext, HostGui, PluginResult}; use wrac_wxp_gui::{ GuiSizeLimits, ParentWindowHandle, WxpGuiController, WxpGuiFactory, WxpGuiResizeHandle, WxpGuiRuntime, }; +use crate::plugin::WracGainParamOutputQueue; use crate::state::{ProjectStateStore, SharedState}; pub(crate) struct GuiIntegration { @@ -67,8 +66,8 @@ pub(crate) fn create_gui_integration( descriptor: wrac_clap_adapter::PluginDescriptor, project_state: Arc, shared: Arc, - host_parameter_edit_notifier: Arc, - host_gui_resize_requester: Arc, + param_output_queue: Arc, + host_gui: Arc, host_context: HostContext, ) -> GuiIntegration { let notifier = Arc::new(GuiStateNotifier::new()); @@ -84,8 +83,8 @@ pub(crate) fn create_gui_integration( project_state, shared, gui_notifier: notifier.clone(), - host_parameter_edit_notifier, - host_gui_resize_requester, + param_output_queue, + host_gui, resize_handle: resize_handle.clone(), host_context: host_context.clone(), }; diff --git a/plugins/wrac-gain/src-plugin/src/gui/runtime.rs b/plugins/wrac-gain/src-plugin/src/gui/runtime.rs index a013cd61..b3b392dd 100644 --- a/plugins/wrac-gain/src-plugin/src/gui/runtime.rs +++ b/plugins/wrac-gain/src-plugin/src/gui/runtime.rs @@ -5,8 +5,7 @@ use std::time::Duration; use novonotes_run_loop::RunLoopLocal; use run_loop_timer::Timer; use wrac_clap_adapter::{ - GuiConfig, GuiSize, HostContext, HostGuiResizeRequester, HostParamsEditNotifier, - PluginDescriptor, PluginError, PluginResult, + GuiConfig, GuiSize, HostContext, HostGui, PluginDescriptor, PluginError, PluginResult, }; use wrac_wxp_gui::{ GuiSizeLimits, ParentWindowHandle, WxpFrontendSource, WxpGuiResizeHandle, WxpGuiRuntime, @@ -16,7 +15,7 @@ use wxp::WxpCommandHandler; use crate::commands::{CommandRegistrationDependencies, register_commands}; use crate::gui::GuiStateNotifier; -use crate::plugin::notify_gui_parameters; +use crate::plugin::{WracGainParamOutputQueue, notify_gui_parameters}; use crate::state::{ProjectStateStore, SharedState}; // GUI window size bounds (pixels). The host opens at the default; resize is clamped to min..=max. @@ -43,8 +42,8 @@ pub(super) struct GuiRuntimeDependencies { pub(super) project_state: Arc, pub(super) shared: Arc, pub(super) gui_notifier: Arc, - pub(super) host_parameter_edit_notifier: Arc, - pub(super) host_gui_resize_requester: Arc, + pub(super) param_output_queue: Arc, + pub(super) host_gui: Arc, pub(super) resize_handle: WxpGuiResizeHandle, pub(super) host_context: HostContext, } @@ -91,8 +90,8 @@ impl WracGainGuiRuntime { shared: dependencies.shared.clone(), gui_notifier: dependencies.gui_notifier.clone(), descriptor: dependencies.descriptor, - host_parameter_edit_notifier: dependencies.host_parameter_edit_notifier, - host_gui_resize_requester: dependencies.host_gui_resize_requester, + param_output_queue: dependencies.param_output_queue, + host_gui: dependencies.host_gui, gui_resize_handle: dependencies.resize_handle, host_context: dependencies.host_context, }, diff --git a/plugins/wrac-gain/src-plugin/src/plugin.rs b/plugins/wrac-gain/src-plugin/src/plugin.rs index 3f452245..1cccba77 100644 --- a/plugins/wrac-gain/src-plugin/src/plugin.rs +++ b/plugins/wrac-gain/src-plugin/src/plugin.rs @@ -3,7 +3,7 @@ //! What is declared here: //! 1. Plugin self-descriptions generated from package metadata //! 2. [`SharedState`] shared by the audio thread, GUI, and host -//! 3. The audio [`Processor`] handed over at activation, and how host extension +//! 3. The audio [`ActiveProcessor`] handed over at activation, and how host extension //! capabilities are bundled //! //! Parameter, audio port, and state-persistence implementations live under `plugin/`. @@ -17,19 +17,21 @@ mod params; mod state; pub(crate) use params::{ - DEFAULT_GAIN, PARAM_BYPASS_ID, PARAM_GAIN_ID, clamp_gain, notify_gui_parameters, - parameter_default_value, parameter_host_input_to_plain, parameter_host_value, parameter_infos, - parameter_text_value, parameter_value_text, + DEFAULT_GAIN, PARAM_BYPASS_ID, PARAM_GAIN_ID, WracGainParamOutputQueue, + apply_param_input_events, clamp_gain, notify_gui_parameters, parameter_default_value, + parameter_host_input_to_plain, parameter_host_value, parameter_infos, parameter_text_value, + parameter_value_text, }; use audio_ports::{AudioLayoutStore, WracGainAudioPorts, WracGainConfigurableAudioPorts}; use params::WracGainParamsExtension; use state::WracGainStateExtension; use wrac_clap_adapter::{ - AaxDescriptor, AaxStemConfig, ActivateContext, Auv2Descriptor, PluginAudioPortsExtension, - PluginConfigurableAudioPortsExtension, PluginCore, PluginCoreContext, PluginDescriptor, - PluginEntry, PluginFactory, PluginFeature, PluginGuiExtension, PluginParamsExtension, - PluginResult, PluginStateExtension, Processor, Vst3Descriptor, + AaxDescriptor, AaxStemConfig, ActivateContext, ActiveProcessor, Auv2Descriptor, + InactiveProcessor, PluginAudioPortsExtension, PluginConfigurableAudioPortsExtension, + PluginDescriptor, PluginEntry, PluginFactory, PluginFeature, PluginGuiExtension, + PluginInstance, PluginInstanceContext, PluginParamsQuery, PluginResult, PluginStateExtension, + Vst3Descriptor, }; use wrac_wxp_gui::WxpGuiController; @@ -37,7 +39,7 @@ use crate::audio::WracGainAudioProcessor; use crate::gui::create_gui_integration; use crate::state::{ProjectStateStore, SharedState}; -// Generated from [package.metadata.wrac] in src-plugin/Cargo.toml. The manifest is +// Generated from wrac-plugin.toml. The manifest is // the single source of truth for product identity across descriptors, GUI metadata, // wrapper arguments, AUv2 registration, WebView data dirs, and logs. include!(concat!(env!("OUT_DIR"), "/wrac_plugin_products.rs")); @@ -68,8 +70,8 @@ impl PluginFactory for WracGainFactory { fn create_plugin( &self, plugin_id: &str, - context: PluginCoreContext, - ) -> Option> { + context: PluginInstanceContext, + ) -> Option> { // The host creates by descriptor id after discovery. Carry the matched descriptor // into the instance so logs, WebView identity, and About metadata follow the product // actually requested instead of falling back to the first manifest entry. @@ -82,13 +84,13 @@ impl PluginFactory for WracGainFactory { /// One instance of the plugin, created each time the host loads the plugin. /// -/// The audio processing core is split into a [`Processor`] by [`PluginCore::activate`], +/// The audio processing core is split into an [`ActiveProcessor`] by [`PluginInstance::activate`], /// so this struct is responsible only for lifecycle management and holding the host /// extension capabilities. /// /// Capabilities are held behind `Arc` because the host (wrapper) may query them /// re-entrantly during lifecycle callbacks, requiring them to be reachable without -/// acquiring the `&mut self` lock on `PluginCore`. +/// acquiring the `&mut self` lock on `PluginInstance`. pub(crate) struct WracGainPlugin { // The descriptor is instance data, not a global primary descriptor. This matters for // multi-product bundles where the same binary can expose more than one plugin id. @@ -100,6 +102,7 @@ pub(crate) struct WracGainPlugin { audio_ports: Arc, configurable_audio_ports: Arc, params: Arc, + param_output_queue: Arc, gui: Arc, // Project state save/restore. A dedicated capability independent of the lifecycle // lock so that a committed snapshot can be returned even while active or during a @@ -108,20 +111,22 @@ pub(crate) struct WracGainPlugin { } impl WracGainPlugin { - pub(crate) fn new(context: PluginCoreContext, descriptor: PluginDescriptor) -> Self { + pub(crate) fn new(context: PluginInstanceContext, descriptor: PluginDescriptor) -> Self { let shared = Arc::new(SharedState::new()); let audio_layout = Arc::new(AudioLayoutStore::new(2)); let audio_ports = Arc::new(WracGainAudioPorts::new(audio_layout.clone())); let configurable_audio_ports = Arc::new(WracGainConfigurableAudioPorts::new(audio_layout.clone())); let params = Arc::new(WracGainParamsExtension::new(shared.clone())); + let param_output_queue = + Arc::new(WracGainParamOutputQueue::new(context.host_params.clone())); let project_state = Arc::new(ProjectStateStore::new()); let gui = create_gui_integration( descriptor, project_state.clone(), shared.clone(), - context.host_parameter_edit_notifier, - context.host_gui_resize_requester, + param_output_queue.clone(), + context.host_gui, context.host_context, ); let state_extension = Arc::new(WracGainStateExtension::new( @@ -137,6 +142,7 @@ impl WracGainPlugin { audio_ports, configurable_audio_ports, params, + param_output_queue, gui: gui.controller, state_extension, } @@ -144,11 +150,11 @@ impl WracGainPlugin { } /// Called from this product's [`PluginFactory`] implementation. -/// Called each time the host requests a new instance; returns a [`PluginCore`]. +/// Called each time the host requests a new instance; returns a [`PluginInstance`]. pub(crate) fn create_plugin_core( - context: PluginCoreContext, + context: PluginInstanceContext, descriptor: PluginDescriptor, -) -> Box { +) -> Box { wrac_log::init!(descriptor.name); log::debug!( @@ -174,12 +180,23 @@ pub(crate) fn create_plugin_core( } // --------------------------------------------------------------------------- -// PluginCore: plugin lifecycle and the extension capabilities offered +// PluginInstance: plugin lifecycle and the extension capabilities offered // --------------------------------------------------------------------------- -impl PluginCore for WracGainPlugin { +impl PluginInstance for WracGainPlugin { + fn initialize_processor(&mut self) -> PluginResult> { + Ok(Box::new(crate::audio::WracGainInactiveProcessor::new( + self.shared.clone(), + self.param_output_queue.clone(), + ))) + } + /// Called just before the host starts audio processing. - /// The returned [`Processor`] is subsequently `process()`-ed on the audio thread. - fn activate(&mut self, context: ActivateContext) -> PluginResult> { + /// The returned [`ActiveProcessor`] is subsequently `process()`-ed on the audio thread. + fn activate( + &mut self, + context: ActivateContext, + _processor: Box, + ) -> PluginResult> { // Boundary between the non-RT layout store and the RT processor. // // The adapter rejects layout changes while active, so the channel count @@ -198,15 +215,18 @@ impl PluginCore for WracGainPlugin { ); Ok(Box::new(WracGainAudioProcessor::new( self.shared.clone(), + self.param_output_queue.clone(), audio_channel_count, ))) } - /// Called when the host stops audio processing. `_processor` is the value returned - /// from `activate`; dropping it is sufficient cleanup. - fn deactivate(&mut self, _processor: Box) -> PluginResult<()> { + /// Called when the host stops audio processing. + fn deactivate( + &mut self, + _processor: Box, + ) -> PluginResult> { log::debug!("deactivating audio processor"); - Ok(()) + self.initialize_processor() } // Extension declarations. Some = implemented, None = unsupported. Implementations live in separate modules. @@ -219,8 +239,8 @@ impl PluginCore for WracGainPlugin { Some(self.configurable_audio_ports.clone()) } - fn params(&self) -> Option> { - Some(self.params.clone()) + fn params(&self) -> Arc { + self.params.clone() } fn state(&self) -> Option> { diff --git a/plugins/wrac-gain/src-plugin/src/plugin/audio_ports.rs b/plugins/wrac-gain/src-plugin/src/plugin/audio_ports.rs index 19b80b59..1cdafb7f 100644 --- a/plugins/wrac-gain/src-plugin/src/plugin/audio_ports.rs +++ b/plugins/wrac-gain/src-plugin/src/plugin/audio_ports.rs @@ -9,7 +9,7 @@ use wrac_clap_adapter::{ /// Source of truth for the audio layout negotiated with the host. /// /// Host port queries and configurable-audio-ports apply operations read/write this store, -/// and wrappers may issue those queries from audio/render workers. `Processor::process()` +/// and wrappers may issue those queries from audio/render workers. `ActiveProcessor::process()` /// still uses the snapshot captured at `activate()`, so layout changes cannot alter the /// running processor's buffer contract. pub(super) struct AudioLayoutStore { @@ -84,7 +84,7 @@ impl PluginAudioPortsExtension for WracGainAudioPorts { /// Mutation via `&self` is intentional: the adapter calls this without acquiring the /// `&mut self` lock (see [`WracGainPlugin`](super::WracGainPlugin)). This does not mean /// changes are allowed while active — the adapter enforces that this is only called when -/// no `Processor` exists (inactive). +/// no active processor exists. pub(super) struct WracGainConfigurableAudioPorts { layout: Arc, } @@ -107,7 +107,7 @@ impl PluginConfigurableAudioPortsExtension for WracGainConfigurableAudioPorts { &self, requests: &[AudioPortConfigRequest], ) -> PluginResult<()> { - // The adapter rejects configuration apply while a Processor exists. This updates + // The adapter rejects configuration apply while an active processor exists. This updates // only the non-RT query store; the audio thread uses the snapshot captured at activate. let previous_channel_count = self.layout.channel_count(); let channel_count = diff --git a/plugins/wrac-gain/src-plugin/src/plugin/params.rs b/plugins/wrac-gain/src-plugin/src/plugin/params.rs index f46e59a3..6666a465 100644 --- a/plugins/wrac-gain/src-plugin/src/plugin/params.rs +++ b/plugins/wrac-gain/src-plugin/src/plugin/params.rs @@ -1,7 +1,10 @@ +use std::collections::VecDeque; use std::sync::Arc; +use parking_lot::Mutex; use wrac_clap_adapter::{ - ParamFlags, ParamInfo, ParamInputEvents, PluginError, PluginParamsExtension, PluginResult, + HostParams, InputEvents, OutputEvent, OutputEvents, ParamFlags, ParamGestureEvent, ParamInfo, + ParamValueEvent, PluginError, PluginParamsQuery, PluginResult, }; use crate::state::SharedState; @@ -121,17 +124,17 @@ impl WracGainParamsExtension { } } -impl PluginParamsExtension for WracGainParamsExtension { - fn param_count(&self) -> u32 { +impl PluginParamsQuery for WracGainParamsExtension { + fn count(&self) -> u32 { PARAM_SPECS.len() as u32 } - fn param_info(&self, index: u32) -> Option { + fn get_info(&self, index: u32) -> Option { PARAM_SPECS.get(index as usize).map(|spec| spec.info) } /// Answers the host's query for the current value of a parameter. - fn param_value(&self, param_id: u32) -> PluginResult { + fn get_value(&self, param_id: u32) -> PluginResult { let spec = param_spec(param_id)?; let value = self .shared @@ -140,32 +143,6 @@ impl PluginParamsExtension for WracGainParamsExtension { Ok(plain_to_host(spec, value)) } - /// Called when parameter values arrive from the host as input events. - fn apply_param_events(&self, events: ParamInputEvents<'_>) -> PluginResult<()> { - for event in events.values() { - let Ok(plain_value) = parameter_host_input_to_plain(event.param_id, event.value) else { - wrac_log::rtwarn!( - "params.flush: ignoring invalid parameter input param_id={} value={}", - event.param_id, - event.value - ); - continue; - }; - if self - .shared - .set_parameter_value(event.param_id, plain_value) - .is_none() - { - wrac_log::rtwarn!( - "params.flush: ignoring unknown parameter input param_id={} value={}", - event.param_id, - event.value - ); - } - } - Ok(()) - } - /// Converts a host-domain value to a display string. Example: 0.5 -> "0.0 dB". fn value_to_text(&self, param_id: u32, value: f64) -> PluginResult { let spec = param_spec(param_id)?; @@ -180,6 +157,92 @@ impl PluginParamsExtension for WracGainParamsExtension { } } +/// Product-owned queue for GUI-originated parameter events. +/// +/// CLAP output event queues only exist during `process` and `flush_params`, so GUI +/// commands store typed CLAP events here and ask the host for a params flush. +pub(crate) struct WracGainParamOutputQueue { + pending: Mutex>, + flush_requester: Arc, +} + +impl WracGainParamOutputQueue { + pub(crate) fn new(flush_requester: Arc) -> Self { + Self { + pending: Mutex::new(VecDeque::new()), + flush_requester, + } + } + + pub(crate) fn begin_edit(&self, param_id: u32) { + self.push(OutputEvent::ParamGestureBegin(ParamGestureEvent { + time: 0, + param_id, + })); + } + + pub(crate) fn update_edit(&self, param_id: u32, value: f64) { + self.push(OutputEvent::ParamValue(ParamValueEvent { + time: 0, + param_id, + value, + note_id: -1, + port_index: -1, + channel: -1, + key: -1, + })); + } + + pub(crate) fn end_edit(&self, param_id: u32) { + self.push(OutputEvent::ParamGestureEnd(ParamGestureEvent { + time: 0, + param_id, + })); + } + + pub(crate) fn drain(&self, events: &mut OutputEvents<'_>) { + let Some(mut pending) = self.pending.try_lock() else { + wrac_log::rtdebug!("param_output_queue.drain: pending queue is busy"); + return; + }; + + while let Some(event) = pending.pop_front() { + if !events.try_push(event.clone()) { + pending.push_front(event); + break; + } + } + } + + fn push(&self, event: OutputEvent) { + self.pending.lock().push_back(event); + self.flush_requester.request_flush(); + } +} + +pub(crate) fn apply_param_input_events(shared: &SharedState, events: &InputEvents<'_>) { + for event in events.parameter_values() { + let Ok(plain_value) = parameter_host_input_to_plain(event.param_id, event.value) else { + wrac_log::rtwarn!( + "params.input: ignoring invalid parameter input param_id={} value={}", + event.param_id, + event.value + ); + continue; + }; + if shared + .set_parameter_value(event.param_id, plain_value) + .is_none() + { + wrac_log::rtwarn!( + "params.input: ignoring unknown parameter input param_id={} value={}", + event.param_id, + event.value + ); + } + } +} + fn plain_to_host(spec: &ParameterSpec, value: f32) -> f64 { match spec.kind { ParameterKind::Gain => gain_to_host_value(value), diff --git a/plugins/wrac-gain/src-plugin/wrac-plugin.toml b/plugins/wrac-gain/src-plugin/wrac-plugin.toml new file mode 100644 index 00000000..a100500b --- /dev/null +++ b/plugins/wrac-gain/src-plugin/wrac-plugin.toml @@ -0,0 +1,43 @@ +# Product-owned WRAC manifest for the example plugin. +# +# This file is the source of truth for host-visible metadata and wrapper +# descriptors. Build scripts and xtask read it through the `wrac_manifest` crate +# to generate CLAP/VST3/AU/AAX descriptors and decide the default build targets. +# Keep public IDs stable after release; changing them can break host project +# recall. +schema_version = 1 + +[package] +version_source = "cargo" + +[bundle] +company_name = "Your Company" +auv2_manufacturer_code = "YrCo" +aax_manufacturer_id = "YrCo" +bundle_name = "WRAC Gain" +bundle_identifier = "com.your-company.wrac-gain" +homepage_url = "https://example.com/wrac-gain" +manual_url = "https://example.com/wrac-gain/manual" +support_url = "https://example.com/support" +description = "Simple gain plugin" +copyright = "Copyright 2026 Your Company" +supported_formats = ["clap", "vst3", "au", "aax"] + +[validation.disabled_rules.fender-studio-pro-generic-editor-single-knob] +reason = "WRAC Gain is a minimal one-knob starter example. Products that support Fender Studio Pro should add a second real visible control instead of shipping this exception." + +[[plugins]] +plugin_id = "com.your-company.wrac-gain" +plugin_name = "WRAC Gain" +clap_features = ["audio-effect", "utility", "stereo"] +vst3_subcategories = "Fx|Tools" +vst3_component_id = "822011ca-37ec-5cef-92d7-ec7e67207195" +standalone_name = "WRAC Gain Standalone" +auv2_type = "aufx" +auv2_subtype = "WtGn" +aax_categories = ["effect"] +aax_product_id = "WtGn" +aax_stem_configs = [ + { name = "Mono", input = "mono", output = "mono", plugin_id = "WtGM" }, + { name = "Stereo", input = "stereo", output = "stereo", plugin_id = "WtGS" }, +] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2dda7ba2..1eaff03c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,15 +1,23 @@ +//! Repository-local entry point for `cargo xtask`. +//! +//! This binary only wires the WRAC template's root paths into `wrac_xtask`; the +//! reusable command behavior lives in the `wrac_xtask` crate. + use std::path::Path; -use wrac_xtask::{XtaskConfig, run}; +use wrac_xtask::{WracWorkspace, XtaskConfig}; fn main() -> wrac_xtask::Result<()> { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("xtask must be a direct child of the repository root") .to_path_buf(); - run(XtaskConfig { + + let workspace = WracWorkspace::new(XtaskConfig { wrapper_dir: root.join("clap_wrapper_builder"), target_namespace: "wrac-plugins".to_string(), + default_aax_sdk_root: None, root, - }) + })?; + workspace.run(wrac_xtask::command_from_args()) }