collective

Browse / github.com/storytold/soundcraft / patch

vst3-host: host VST3 editors on Linux (X11)

vst3-host: host VST3 editors on Linux (X11) ## Summary On Linux the VST3 host reported **"unsupported"** for editor windows: the plugin's `IPlugView` was probed (size read) but never opened. This adds the Linux host window so VST3 plugins actually open their editor on Linux, matching macOS (`NSView`) and Windows (`HWND`). ## What it does - **`crates/vst3-host/src/ffi.rs`** — a Linux `window` module (built on `x11rb`, pure Rust, no FFI): - advertises `X11EmbedWindowID`, so `isPlatformTypeSupported` now succeeds on Linux; - creates a titled top-level X11 window the plugin view attaches to (the same floating-window model as macOS/Wi

Status
open
Score
0.00
Adopters
0 other operators
Author
pavlenkosa (imported from pull request #1127, open)
Base
7dc56a4dbeebf57eacece09536726c57844fc59d
Size
+206 −30 in 9 files
Review
pending
Shared
2026-10-28
Id
8c34a08a9923efe8158f7a9f

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/vst3-host/Cargo.toml crates/vst3-host/src/ffi.rs crates/vst3-host/src/lib.rs crates/vst3-host/tests/editor_main_thread.rs crates/vst3-host/tests/it/fixture_e2e.rs docs/gaps.md docs/plugin-parity.md docs/target-app-parity.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -4193,6 +4193,7 @@ dependencies = [ name = "soundcraft-vst3-host" version = "0.13.1" dependencies = [+ "libc",  "libloading 0.9.0",  "log",  "objc2 0.6.5",@@ -4203,6 +4204,7 @@ dependencies = [  "thiserror 2.0.21",  "vst3",  "windows-sys 0.61.2",+ "x11rb", ]  [[package]]diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml--- a/crates/vst3-host/Cargo.toml+++ b/crates/vst3-host/Cargo.toml@@ -27,6 +27,11 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } +# The Linux editor host window (a top-level X11 window the plugin view's child window lives in).+[target.'cfg(target_os = "linux")'.dependencies]+x11rb = "0.13"+libc = "0.2"+ [[test]] name = "editor_main_thread" harness = falsediff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs--- a/crates/vst3-host/src/ffi.rs+++ b/crates/vst3-host/src/ffi.rs@@ -2319,8 +2319,171 @@ mod window {     } } +/// The Linux (X11) host window: a top-level window whose content area is the plugin view's+/// `X11EmbedWindowID` parent. The plugin creates its own child window inside it and draws there.+/// Closing the window unmaps or destroys it; the editor link notices in `idle` and tears the view+/// down. Window calls run on the UI thread (the crate's threading model), and the one process-wide+/// X11 connection is opened on first use and shared.+#[cfg(target_os = "linux")]+mod window {+    use std::collections::HashMap;+    use std::ffi::{c_char, c_void};+    use std::sync::atomic::{AtomicBool, Ordering};+    use std::sync::{Arc, Mutex, OnceLock, PoisonError};+    use x11rb::connection::Connection;+    use x11rb::protocol::Event;+    use x11rb::protocol::xproto::{Atom, AtomEnum, ConfigureWindowAux, ConnectionExt as _, CreateWindowAux, PropMode, WindowClass};+    use x11rb::rust_connection::RustConnection;+    use x11rb::wrapper::ConnectionExt as _;++    pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeX11EmbedWindowID);++    pub fn on_main_thread() -> bool {+        // On Linux the main thread's kernel TID equals the process PID (gettid() == getpid());+        // any other thread differs. VST3 drives its controllers and views on one UI thread, and+        // this is the portable probe for "are we on it".+        // SAFETY: both are plain libc calls with no preconditions.+        unsafe { libc::gettid() == libc::getpid() }+    }++    /// The process-wide X11 display (one connection per app), opened on first use. It owns the+    /// shared connection and a registry of every editor window's visible flag, so one event drain+    /// routes a close signal to the right window even when several editors are open at once.+    struct Display {+        conn: RustConnection,+        screen: usize,+        wm_protocols: Atom,+        wm_delete_window: Atom,+        windows: Mutex<HashMap<u32, Arc<AtomicBool>>>,+    }++    impl Display {+        /// Drains pending events, routing close signals (WM_DELETE_WINDOW, Unmap, Destroy) to the+        /// matching window's visible flag. Every editor shares this connection, so any window's+        /// `is_visible` drains for all of them.+        fn drain(&self) {+            let conn = &self.conn;+            while let Ok(Some(event)) = conn.poll_for_event() {+                match event {+                    Event::ClientMessage(e) if e.type_ == self.wm_protocols && e.data.as_data32()[0] == self.wm_delete_window => {+                        let _ = conn.destroy_window(e.window);+                        let _ = conn.flush();+                        self.mark(e.window, false);+                    }+                    Event::UnmapNotify(e) => self.mark(e.window, false),+                    Event::DestroyNotify(e) => self.mark(e.window, false),+                    _ => {}+                }+            }+        }++        fn mark(&self, window: u32, visible: bool) {+            let windows = self.windows.lock().unwrap_or_else(PoisonError::into_inner);+            if let Some(flag) = windows.get(&window) {+                flag.store(visible, Ordering::Relaxed);+            }+        }+    }++    fn display() -> Result<Arc<Display>, String> {+        static DISPLAY: OnceLock<Result<Arc<Display>, String>> = OnceLock::new();+        DISPLAY.get_or_init(init_display).clone()+    }++    fn init_display() -> Result<Arc<Display>, String> {+        let (conn, screen) = x11rb::connect(None).map_err(|e| format!("cannot open the X11 display: {e}"))?;+        let wm_protocols = intern(&conn, b"WM_PROTOCOLS")?;+        let wm_delete_window = intern(&conn, b"WM_DELETE_WINDOW")?;+        Ok(Arc::new(Display { conn, screen, wm_protocols, wm_delete_window, windows: Mutex::new(HashMap::new()) }))+    }++    fn intern(conn: &RustConnection, name: &[u8]) -> Result<Atom, String> {+        conn.intern_atom(false, name).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string()).map(|r| r.atom)+    }++    pub struct HostWindow {+        display: Arc<Display>,+        window: u32,+        visible: Arc<AtomicBool>,+    }++    impl HostWindow {+        pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {+            let d = display()?;+            let conn = &d.conn;+            let root = conn.setup().roots[d.screen].root;+            let window = conn.generate_id().map_err(|e| e.to_string())?;+            let (w, h) = (w.max(1.0).round() as u16, h.max(1.0).round() as u16);+            conn.create_window(+                x11rb::COPY_DEPTH_FROM_PARENT,+                window,+                root,+                0,+                0,+                w,+                h,+                0,+                WindowClass::INPUT_OUTPUT,+                x11rb::COPY_FROM_PARENT,+                &CreateWindowAux::new(),+            )+            .map_err(|e| e.to_string())?;+            conn.change_property8(PropMode::REPLACE, window, AtomEnum::WM_NAME, AtomEnum::STRING, title.as_bytes()).map_err(|e| e.to_string())?;+            // Ask the window manager for a graceful close (WM_DELETE_WINDOW) instead of a kill.+            conn.change_property32(PropMode::REPLACE, window, d.wm_protocols, AtomEnum::ATOM, &[d.wm_delete_window]).map_err(|e| e.to_string())?;+            conn.map_window(window).map_err(|e| e.to_string())?;+            conn.flush().map_err(|e| e.to_string())?;+            let visible = Arc::new(AtomicBool::new(true));+            d.windows.lock().unwrap_or_else(PoisonError::into_inner).insert(window, visible.clone());+            Ok(HostWindow { display: d, window, visible })+        }++        /// The raw window id (for the plug frame's resizes).+        pub fn raw(&self) -> *mut c_void {+            self.window as usize as *mut c_void+        }++        /// The window id the plugin view attaches to (its child window's parent).+        pub fn parent(&self) -> *mut c_void {+            self.raw()+        }++        pub fn raise(&self) {+            let _ = self.display.conn.map_window(self.window);+            let _ = self.display.conn.flush();+        }++        /// Whether the window is still shown. The flag is set on map and cleared only by a close+        /// signal (WM_DELETE_WINDOW, Unmap or Destroy), never by a map that a window manager has+        /// not finished yet.+        pub fn is_visible(&self) -> bool {+            self.display.drain();+            self.visible.load(Ordering::Relaxed)+        }++        pub fn close(self) {+            let _ = self.display.conn.destroy_window(self.window);+            let _ = self.display.conn.flush();+            self.visible.store(false, Ordering::Relaxed);+            self.display.windows.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.window);+        }+    }++    /// Resizes a host window (`window` from [`HostWindow::raw`]).+    pub fn resize(window: *mut c_void, w: f64, h: f64) {+        let Ok(d) = display() else { return };+        let window = window as usize as u32;+        if window == 0 {+            return;+        }+        let values = ConfigureWindowAux::new().width(w.max(1.0).round() as u32).height(h.max(1.0).round() as u32);+        let _ = d.conn.configure_window(window, &values);+        let _ = d.conn.flush();+    }+}+ /// Other platforms: no editor windows yet (opening reports "unsupported").-#[cfg(not(any(target_os = "macos", windows)))]+#[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] mod window {     use std::ffi::{c_char, c_void}; diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs--- a/crates/vst3-host/src/lib.rs+++ b/crates/vst3-host/src/lib.rs@@ -32,8 +32,8 @@ //! main thread while the instance processes on the audio thread (VST3's own threading model: //! the controller and its views live on the UI thread). Parameter edits made in the editor //! (`IComponentHandler::performEdit`) are reported by the handle's `idle` so the host can write-//! them into the session. Linux (X11) editor windows are not hosted yet: opening reports-//! "unsupported".+//! them into the session. On Linux the editor is a floating X11 window (`X11EmbedWindowID`) that+//! the plugin's view attaches to. //! //! Not hosted yet: `IMessage`/`IAttributeList` creation through the host (plugins that need it //! for processor ↔ controller messaging may lose those messages), sidechain (aux buses are keptdiff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs--- a/crates/vst3-host/tests/editor_main_thread.rs+++ b/crates/vst3-host/tests/editor_main_thread.rs@@ -14,12 +14,9 @@ fn main() {     let dir = common::fixture_dir();     let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;     let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");-    // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported".-    if cfg!(any(target_os = "macos", windows)) {-        assert_eq!(p.editor_size(), Ok((320, 240)));-    } else {-        assert!(p.editor_size().is_err());-    }+    // Editor windows are hosted on macOS, Windows and Linux; the probe reads the size without+    // opening a window, so it needs no display.+    assert_eq!(p.editor_size(), Ok((320, 240)));     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");     assert!(!ed.is_open());     assert_eq!(ed.idle().len(), 1, "the fixture's setup edit");@@ -36,6 +33,23 @@ fn main() {     assert_eq!(ed.value_text("7", 1.5), None);     assert_eq!(ed.value_text("3", 1.0), None);     assert_eq!(ed.value_text("nope", 0.5), None);+    // On Linux with a live X server, actually open the editor window and check it maps and tears+    // down. Skipped headlessly, or when `DISPLAY` is set but not connectable (e.g. over SSH);+    // other platforms are exercised manually.+    #[cfg(target_os = "linux")]+    if std::env::var_os("DISPLAY").is_some() {+        match ed.open() {+            Ok(()) => {+                assert!(ed.is_open(), "the editor window is mapped");+                ed.open().expect("opening again just raises it");+                assert!(ed.is_open());+                ed.close();+                assert!(!ed.is_open(), "closing unmaps the editor window");+            }+            Err(e) if e.starts_with("cannot open the X11 display") => eprintln!("skipped the editor window check: {e}"),+            Err(e) => panic!("open the editor on X11: {e}"),+        }+    }     drop(p);     assert_eq!(ed.value_text("13", 0.375), None, "the plugin is gone");     assert!(!ed.is_open());diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs--- a/crates/vst3-host/tests/it/fixture_e2e.rs+++ b/crates/vst3-host/tests/it/fixture_e2e.rs@@ -64,11 +64,8 @@ fn identity_mapped_param_text_is_read_on_the_main_thread_only() {     assert_eq!((level.min, level.max, level.unit), (0.0, 1.0, Unit::Db));     let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");-    if cfg!(any(target_os = "macos", windows)) {-        assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread");-    } else {-        assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB"));-    }+    // Test threads are not the process main thread, so the controller is not read.+    assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread"); }  #[test]@@ -315,21 +312,14 @@ fn host_set_controller_param_survives_state_round_trip() { fn editor_view_is_probed_and_edits_come_back_as_plain_values() {     let id = id_of("Fixture Gain");     let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();-    // Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported".-    if cfg!(any(target_os = "macos", windows)) {-        assert_eq!(p.editor_size(), Ok((320, 240)));-    } else {-        assert!(p.editor_size().is_err());-    }+    // Editor windows are hosted on macOS, Windows and Linux; the probe reads the size without+    // opening a window, so it needs no display on any of them.+    assert_eq!(p.editor_size(), Ok((320, 240)));     let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");     // The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler.     assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]);     assert!(ed.idle().is_empty());-    if cfg!(any(target_os = "macos", windows)) {-        assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread");-    } else {-        assert!(ed.open().is_err(), "unsupported elsewhere");-    }+    assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread");     assert!(!ed.is_open());     // The synth's controller has no view.     let synth = soundcraft_vst3_host::instantiate_plugin(&id_of("Fixture Synth")).unwrap();diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -119,8 +119,9 @@ launch #142; Linux #45, #107. - **Missing:** the `com.apple.security.cs.disable-library-validation` entitlement in the signed   build, so hardened runtime refuses third-party VST3 bundles (#153, #149). AU editor views   (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). Embedded CLAP-  GUIs; VST3 editors on Windows/Linux.-- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it).+  GUIs (macOS/Linux); VST3 editors on Windows/Linux were completed later (Windows 2026-10-12,+  Linux 2026-10-27).+- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views most of it).  ### G-06. Memory-bound media diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md--- a/docs/plugin-parity.md+++ b/docs/plugin-parity.md@@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (VST3 editors on Linux; previously trivial: Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from@@ -45,7 +45,7 @@ User report #152: a saved instrument level does not reach the bounce. | State saved in sessions, presets | ✅ | ✅ | | | Create/destroy off the audio thread | ✅ | ✅ | | | Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows | Embedded on macOS/Linux missing |-| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | Linux views missing |+| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | 🟡 Linux: a floating X11 window the plugin view attaches to (`X11EmbedWindowID`), verified with the test fixture; not yet against a commercial plugin | | Editors: AU | — | ❌ | Cocoa view hosting missing | | Multiple editor pages | ✅ | 🟡 | #6 | | macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 |@@ -67,4 +67,5 @@ User report #152: a saved instrument level does not reach the bounce. | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) | | 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) | | 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |+| 2026-10-27 | minor | Editors: VST3 on Linux — a floating X11 window the plugin view attaches to (`X11EmbedWindowID`), verified with the test fixture | | 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md--- a/docs/target-app-parity.md+++ b/docs/target-app-parity.md@@ -182,7 +182,7 @@ Weights by what Pro Tools users actually spend time on. **Breadth** = the featur | Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 | | Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 | | Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 27 processors + 3 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) |-| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) |+| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS, Windows and Linux. Missing: AU editors, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) | | MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (no piano, organ or wavetable instrument), bug #98 | | Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) | | Elastic Audio, TCE, Beat Detective, pitch | 5 % | 70 % | **35 %** | 40–60 | Pitch-preserving warp on Elastic tracks, TCE, conform to tempo, Beat Detective and Identify Beat, audio-to-MIDI. Missing: per-algorithm choice (polyphonic/rhythmic/monophonic/varispeed/X-Form), warp markers UI depth, Elastic Pitch, quality vs Pro Tools unmeasured |