From f38d658850d8e1cba2513788f4dd806cc8875db8 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:59:11 +0300 Subject: [PATCH 1/4] feat(plugins): host Linux plugin editors in plain X11 windows --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 + src-tauri/src/audio/plugins/au_host.rs | 7 +- src-tauri/src/audio/plugins/clap_host.rs | 11 +- src-tauri/src/audio/plugins/clap_registry.rs | 6 +- src-tauri/src/audio/plugins/editor.rs | 205 +++++++++++++--- src-tauri/src/audio/plugins/editor_x11.rs | 246 +++++++++++++++++++ src-tauri/src/audio/plugins/host_api.rs | 3 +- src-tauri/src/audio/plugins/main_thread.rs | 2 + src-tauri/src/audio/plugins/mod.rs | 2 + src-tauri/src/audio/plugins/vst3_registry.rs | 20 +- 11 files changed, 445 insertions(+), 60 deletions(-) create mode 100644 src-tauri/src/audio/plugins/editor_x11.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ec092546..81d8625e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6291,6 +6291,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "winreg 0.55.0", + "x11rb", "zip 2.4.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5e2e5400..e157be9c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -107,6 +107,8 @@ raw-window-handle = "0.6" vst3 = "0.3" [target.'cfg(target_os = "linux")'.dependencies] +# Plugin editor windows are plain X11 windows, see `editor_x11`. +x11rb = "0.13" pipewire = { version = "0.9", features = ["v0_3_49"] } # Native volume read/set and event-driven change notifications. Talks to # pipewire-pulse; high-level, so no subprocess or polling like wpctl/pactl. diff --git a/src-tauri/src/audio/plugins/au_host.rs b/src-tauri/src/audio/plugins/au_host.rs index 3faecfd9..a38ffd03 100644 --- a/src-tauri/src/audio/plugins/au_host.rs +++ b/src-tauri/src/audio/plugins/au_host.rs @@ -1415,10 +1415,15 @@ impl PluginHost for AuHost { /// AppKit is main-thread only, so the view work is marshalled there while /// the caller blocks -- the interface promises a blocking answer on any /// non-main thread, and hiding this is exactly its job. - fn embed_editor(&self, node_id: &str, window: &tauri::Window) -> Result { + fn embed_editor( + &self, + node_id: &str, + window: &editor::EditorWindow, + ) -> Result { // A raw pointer is not `Send`; the address is, and it stays valid // because the window outlives the editor. let view_addr = window + .tauri() .ns_view() .map_err(|e| format!("au {node_id}: content view: {e}"))? as usize; diff --git a/src-tauri/src/audio/plugins/clap_host.rs b/src-tauri/src/audio/plugins/clap_host.rs index 8f80c734..e0ebe6a9 100644 --- a/src-tauri/src/audio/plugins/clap_host.rs +++ b/src-tauri/src/audio/plugins/clap_host.rs @@ -28,7 +28,6 @@ use clack_extensions::params::{ParamInfoBuffer, ParamInfoFlags, PluginParams}; use clack_extensions::state::{HostState as HostStateExt, HostStateImpl, PluginState}; use clack_extensions::timer::{HostTimer, HostTimerImpl, PluginTimer, TimerId}; use clack_host::prelude::*; -use raw_window_handle::HasWindowHandle; use super::host_api::{AliveFlag, EditorSize, PluginParamInfo}; use super::node::PluginNode; @@ -428,7 +427,7 @@ impl ClapInstance { pub fn embed_editor( &mut self, node_id: &str, - window: &tauri::Window, + window: &editor::EditorWindow, ) -> Result { // Every failure below names the bundle: an editor error that says only // what went wrong is unusable when several plugins are in the graph. @@ -460,13 +459,11 @@ impl ClapInstance { #[cfg(target_os = "macos")] let scale = 1.0; #[cfg(not(target_os = "macos"))] - let scale = window.scale_factor().unwrap_or(1.0); + let scale = window.scale_factor(); let _ = gui.set_scale(&mut self.instance.plugin_handle(), scale); - let handle = window - .window_handle() - .map_err(|e| at("window handle", e.to_string()))?; - let clap_window = ClackWindow::from_window_handle(handle.as_raw()).ok_or_else(|| { + let handle = window.raw_handle().map_err(|e| at("window handle", e))?; + let clap_window = ClackWindow::from_window_handle(handle).ok_or_else(|| { at( "window handle", "unsupported handle for this platform".into(), diff --git a/src-tauri/src/audio/plugins/clap_registry.rs b/src-tauri/src/audio/plugins/clap_registry.rs index 2115b9b8..4860d802 100644 --- a/src-tauri/src/audio/plugins/clap_registry.rs +++ b/src-tauri/src/audio/plugins/clap_registry.rs @@ -168,7 +168,11 @@ impl PluginHost for ClapHost { }) } - fn embed_editor(&self, node_id: &str, window: &tauri::Window) -> Result { + fn embed_editor( + &self, + node_id: &str, + window: &editor::EditorWindow, + ) -> Result { let id = node_id.to_string(); let window = window.clone(); main_thread::run(move || { diff --git a/src-tauri/src/audio/plugins/editor.rs b/src-tauri/src/audio/plugins/editor.rs index 325df1e9..828392b3 100644 --- a/src-tauri/src/audio/plugins/editor.rs +++ b/src-tauri/src/audio/plugins/editor.rs @@ -13,16 +13,114 @@ use super::host_api::EditorSize; /// Fallback editor size for plugins that report a nonsensical one. pub const FALLBACK_EDITOR_SIZE: EditorSize = (800, 600); +/// The native window a plugin view is parented into. A Tauri window on macOS +/// and Windows; on Linux a bare X11 one, since plugins there can only embed +/// into X11 and the app itself may be on native Wayland. +#[derive(Clone)] +pub struct EditorWindow { + #[cfg(not(target_os = "linux"))] + window: tauri::Window, + #[cfg(target_os = "linux")] + window: super::editor_x11::X11Window, +} + +impl EditorWindow { + #[cfg(target_os = "macos")] + pub fn tauri(&self) -> &tauri::Window { + &self.window + } + + pub fn raw_handle(&self) -> Result { + #[cfg(not(target_os = "linux"))] + { + use raw_window_handle::HasWindowHandle; + self.window + .window_handle() + .map(|h| h.as_raw()) + .map_err(|e| format!("window handle: {e}")) + } + #[cfg(target_os = "linux")] + { + Ok(raw_window_handle::XlibWindowHandle::new(self.window.id() as _).into()) + } + } + + pub fn scale_factor(&self) -> f64 { + #[cfg(not(target_os = "linux"))] + return self.window.scale_factor().unwrap_or(1.0); + #[cfg(target_os = "linux")] + return self.window.scale_factor(); + } + + /// Content area size in the unit plugins report: logical px through Tauri, + /// X11 px on Linux. + fn content_size(&self) -> Option { + #[cfg(not(target_os = "linux"))] + { + let inner = self.window.inner_size().ok()?; + let scale = self.scale_factor(); + Some(( + (inner.width as f64 / scale).round() as u32, + (inner.height as f64 / scale).round() as u32, + )) + } + #[cfg(target_os = "linux")] + return self.window.size(); + } + + fn set_size(&self, w: f64, h: f64) { + #[cfg(not(target_os = "linux"))] + let _ = self.window.set_size(tauri::LogicalSize::new(w, h)); + #[cfg(target_os = "linux")] + self.window.set_size(w.round() as u32, h.round() as u32); + } + + fn show(&self) { + #[cfg(not(target_os = "linux"))] + let _ = self.window.show(); + #[cfg(target_os = "linux")] + self.window.show(); + } + + fn hide(&self) { + #[cfg(not(target_os = "linux"))] + let _ = self.window.hide(); + #[cfg(target_os = "linux")] + self.window.hide(); + } + + fn focus(&self) { + #[cfg(not(target_os = "linux"))] + let _ = self.window.set_focus(); + #[cfg(target_os = "linux")] + self.window.raise(); + } + + fn is_visible(&self) -> bool { + #[cfg(not(target_os = "linux"))] + return self.window.is_visible().unwrap_or(false); + #[cfg(target_os = "linux")] + return self.window.is_visible(); + } + + fn destroy(&self) { + #[cfg(not(target_os = "linux"))] + let _ = self.window.destroy(); + #[cfg(target_os = "linux")] + self.window.destroy(); + } +} + /// Native host windows that plugin editors are embedded into, keyed by node id. -/// `tauri::Window` is `Send + Sync`, so this lives outside any main-thread state +/// `EditorWindow` is `Send + Sync`, so this lives outside any main-thread state /// and can be created/closed from the command thread. -fn windows() -> &'static Mutex> { - static WINDOWS: OnceLock>> = OnceLock::new(); +fn windows() -> &'static Mutex> { + static WINDOWS: OnceLock>> = OnceLock::new(); WINDOWS.get_or_init(|| Mutex::new(HashMap::new())) } /// The window hosting this node's editor, if one is open. -pub fn window_for(node_id: &str) -> Option { +pub fn window_for(node_id: &str) -> Option { windows().lock().unwrap().get(node_id).cloned() } @@ -32,8 +130,7 @@ pub fn is_open(node_id: &str) -> bool { .lock() .unwrap() .get(node_id) - .and_then(|w| w.is_visible().ok()) - .unwrap_or(false) + .is_some_and(EditorWindow::is_visible) } /// Closes a node's editor window if one is open. Shared with the format hosts, @@ -43,7 +140,7 @@ pub fn close_window(node_id: &str) { host.destroy_editor(node_id); } if let Some(w) = windows().lock().unwrap().remove(node_id) { - let _ = w.destroy(); + w.destroy(); } } @@ -56,13 +153,13 @@ pub fn valid_gui_size(w: u32, h: u32) -> Option { /// Returns window decoration overhead. Because `window.set_size` in Tauri 2 /// sets the inner (client) size directly across all platforms, content area /// matches the requested dimensions 1:1 without adding synthetic padding. -pub fn decoration_overhead(_window: &tauri::Window) -> (f64, f64) { +pub fn decoration_overhead(_window: &EditorWindow) -> (f64, f64) { (0.0, 0.0) } -/// Sizes the window so its content area is `w` x `h` logical px. -pub fn set_content_size(window: &tauri::Window, w: f64, h: f64) { - let _ = window.set_size(tauri::LogicalSize::new(w, h)); +/// Sizes the window so its content area is `w` x `h`. +pub fn set_content_size(window: &EditorWindow, w: f64, h: f64) { + window.set_size(w, h); } /// Requests a window resize from a plugin. If the window's content area already @@ -71,41 +168,26 @@ pub fn set_content_size(window: &tauri::Window, w: f64, h: f64) { /// resized synchronously on the main thread so plugin internal layout engines /// (JUCE, VST3, AU) immediately see the updated parent bounds without frame /// tearing or asynchronous jitter. -pub fn request_resize(window: &tauri::Window, _node_id: &str, w: u32, h: u32) { - if let Ok(inner) = window.inner_size() { - let scale = window.scale_factor().unwrap_or(1.0); - let cur_w = (inner.width as f64 / scale).round() as u32; - let cur_h = (inner.height as f64 / scale).round() as u32; - if cur_w == w && cur_h == h { - return; - } +pub fn request_resize(window: &EditorWindow, _node_id: &str, w: u32, h: u32) { + if window.content_size() == Some((w, h)) { + return; } - let _ = window.set_size(tauri::LogicalSize::new(w as f64, h as f64)); + window.set_size(w as f64, h as f64); } -/// Opens the plugin editor embedded in a native host window. The tested plugins -/// only support embedded GUIs, so the host must own the window and hand its -/// native handle to the plugin. -pub fn open(node_id: &str, title: &str) -> Result<(), String> { - tracing::debug!(node_id, title, "opening plugin editor"); +const MIN_EDITOR_SIZE: EditorSize = (200, 150); + +#[cfg(not(target_os = "linux"))] +fn create_window(node_id: &str, title: &str) -> Result { let app = crate::app_handle().ok_or("app handle not ready")?; - let existing = windows().lock().unwrap().get(node_id).cloned(); - if let Some(w) = existing { - if let Some(host) = super::registry::for_node(node_id) { - let _ = host.show_editor(node_id); - } - let _ = w.show(); - let _ = w.set_focus(); - return Ok(()); - } let builder = tauri::WindowBuilder::new(app, format!("plugin-editor-{node_id}")) - .title(if title.is_empty() { "Plugin" } else { title }) + .title(title) .inner_size(FALLBACK_EDITOR_SIZE.0 as f64, FALLBACK_EDITOR_SIZE.1 as f64) .visible(false) // Always resizable with a small floor: even when a plugin reports a bad // size or does not reflow, the user can enlarge the window to reveal it. .resizable(true) - .min_inner_size(200.0, 150.0); + .min_inner_size(MIN_EDITOR_SIZE.0 as f64, MIN_EDITOR_SIZE.1 as f64); #[cfg(target_os = "macos")] let builder = builder.title_bar_style(tauri::TitleBarStyle::Visible); @@ -145,6 +227,49 @@ pub fn open(node_id: &str, title: &str) -> Result<(), String> { }); } }); + Ok(EditorWindow { window }) +} + +#[cfg(target_os = "linux")] +fn create_window(node_id: &str, title: &str) -> Result { + let window = super::editor_x11::X11Window::create(title, FALLBACK_EDITOR_SIZE, MIN_EDITOR_SIZE) + .map_err(|e| format!("editor window for {node_id}: {e}"))?; + Ok(EditorWindow { window }) +} + +/// Main thread, on the shared plugin tick. The user closing an X11 editor +/// window arrives as an event on our connection rather than through Tauri, so +/// it is turned into the same `close` the Tauri windows get. +#[cfg(target_os = "linux")] +pub fn tick() { + for id in super::editor_x11::take_close_requests() { + let node_id = windows() + .lock() + .unwrap() + .iter() + .find(|(_, w)| w.window.id() == id) + .map(|(nid, _)| nid.clone()); + if let Some(node_id) = node_id { + let _ = close(&node_id); + } + } +} + +/// Opens the plugin editor embedded in a native host window. The tested plugins +/// only support embedded GUIs, so the host must own the window and hand its +/// native handle to the plugin. +pub fn open(node_id: &str, title: &str) -> Result<(), String> { + tracing::debug!(node_id, title, "opening plugin editor"); + let existing = windows().lock().unwrap().get(node_id).cloned(); + if let Some(w) = existing { + if let Some(host) = super::registry::for_node(node_id) { + let _ = host.show_editor(node_id); + } + w.show(); + w.focus(); + return Ok(()); + } + let window = create_window(node_id, if title.is_empty() { "Plugin" } else { title })?; windows() .lock() .unwrap() @@ -176,7 +301,7 @@ pub fn open(node_id: &str, title: &str) -> Result<(), String> { unsafe { use objc2::msg_send; use objc2_foundation::{NSPoint, NSRect, NSSize}; - if let Ok(parent) = window.ns_view() { + if let Ok(parent) = window.tauri().ns_view() { if let Some(view) = last_subview(parent) { let frame = NSRect::new( NSPoint::new(0.0, 0.0), @@ -188,8 +313,8 @@ pub fn open(node_id: &str, title: &str) -> Result<(), String> { } } - let _ = window.show(); - let _ = window.set_focus(); + window.show(); + window.focus(); Ok(()) } @@ -200,7 +325,7 @@ pub fn close(node_id: &str) -> Result<(), String> { } let existing = windows().lock().unwrap().get(node_id).cloned(); if let Some(w) = existing { - let _ = w.hide(); + w.hide(); } if let Some(app) = crate::app_handle() { let _ = app.emit(super::host_api::EDITOR_CLOSED_EVENT, node_id); diff --git a/src-tauri/src/audio/plugins/editor_x11.rs b/src-tauri/src/audio/plugins/editor_x11.rs new file mode 100644 index 00000000..4c92b891 --- /dev/null +++ b/src-tauri/src/audio/plugins/editor_x11.rs @@ -0,0 +1,246 @@ +//! Plugin editor windows on Linux, created straight on the X server. +//! +//! VST3 and CLAP editors can only be parented into an X11 window, but the app +//! itself may run on native Wayland, where GTK has no X11 window to give. So +//! editor windows bypass GTK and live on their own connection to `$DISPLAY`, +//! which under a Wayland session is XWayland. The rest of the UI stays native. +//! +//! Sizes here are X11 pixels, the unit plugins use on this platform. + +use std::sync::OnceLock; + +use x11rb::connection::Connection; +use x11rb::properties::WmSizeHints; +use x11rb::protocol::xproto::{ + AtomEnum, ConfigureWindowAux, ConnectionExt as _, CreateWindowAux, EventMask, MapState, + PropMode, StackMode, WindowClass, +}; +use x11rb::protocol::Event; +use x11rb::rust_connection::RustConnection; +use x11rb::wrapper::ConnectionExt as _; +use x11rb::COPY_DEPTH_FROM_PARENT; + +x11rb::atom_manager! { + Atoms: AtomsCookie { + WM_PROTOCOLS, + WM_DELETE_WINDOW, + _NET_WM_NAME, + UTF8_STRING, + } +} + +struct Display { + conn: RustConnection, + root: u32, + black_pixel: u32, + atoms: Atoms, +} + +/// X11's own baseline DPI, which `Xft.dpi` is a multiple of when the desktop +/// asks X11 clients to scale themselves. +const BASE_DPI: f64 = 96.0; + +fn connect() -> Result { + let (conn, screen_num) = RustConnection::connect(None) + .map_err(|e| format!("plugin editors need an X11 display (XWayland under Wayland): {e}"))?; + let screen = &conn.setup().roots[screen_num]; + let (root, black_pixel) = (screen.root, screen.black_pixel); + let atoms = Atoms::new(&conn) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(Display { + conn, + root, + black_pixel, + atoms, + }) +} + +/// One connection for the whole process, opened by the first editor. `$DISPLAY` +/// does not change under a running app, so a failure is final too. +static DISPLAY: OnceLock> = OnceLock::new(); + +fn display() -> Result<&'static Display, String> { + DISPLAY.get_or_init(connect).as_ref().map_err(Clone::clone) +} + +#[derive(Clone, Copy, Debug)] +pub struct X11Window { + id: u32, +} + +impl X11Window { + pub fn create( + title: &str, + (w, h): (u32, u32), + (min_w, min_h): (u32, u32), + ) -> Result { + let d = display()?; + let err = |e: &dyn std::fmt::Display| format!("x11 editor window: {e}"); + let id = d.conn.generate_id().map_err(|e| err(&e))?; + d.conn + .create_window( + COPY_DEPTH_FROM_PARENT, + id, + d.root, + 0, + 0, + w as u16, + h as u16, + 0, + WindowClass::INPUT_OUTPUT, + x11rb::NONE, + &CreateWindowAux::new() + .background_pixel(d.black_pixel) + .event_mask(EventMask::STRUCTURE_NOTIFY), + ) + .map_err(|e| err(&e))?; + d.conn + .change_property32( + PropMode::REPLACE, + id, + d.atoms.WM_PROTOCOLS, + AtomEnum::ATOM, + &[d.atoms.WM_DELETE_WINDOW], + ) + .map_err(|e| err(&e))?; + d.conn + .change_property8( + PropMode::REPLACE, + id, + d.atoms._NET_WM_NAME, + d.atoms.UTF8_STRING, + title.as_bytes(), + ) + .map_err(|e| err(&e))?; + let hints = WmSizeHints { + min_size: Some((min_w as i32, min_h as i32)), + ..Default::default() + }; + hints.set_normal_hints(&d.conn, id).map_err(|e| err(&e))?; + // The plugin reparents into this window over its own connection, so the + // window has to exist on the server before its id is handed out. + d.conn.sync().map_err(|e| err(&e))?; + Ok(Self { id }) + } + + pub fn id(&self) -> u32 { + self.id + } + + /// `Xft.dpi / 96`. A desktop that scales X11 clients itself leaves it at 96, + /// so the plugin draws at 1x; one that wants clients to scale raises it. + pub fn scale_factor(&self) -> f64 { + let Ok(d) = display() else { return 1.0 }; + let dpi = d + .conn + .get_property( + false, + d.root, + AtomEnum::RESOURCE_MANAGER, + AtomEnum::STRING, + 0, + u32::MAX, + ) + .ok() + .and_then(|c| c.reply().ok()) + .and_then(|r| xft_dpi(&String::from_utf8_lossy(&r.value))); + dpi.map_or(1.0, |dpi| dpi / BASE_DPI) + } + + pub fn size(&self) -> Option<(u32, u32)> { + let d = display().ok()?; + let g = d.conn.get_geometry(self.id).ok()?.reply().ok()?; + Some((g.width as u32, g.height as u32)) + } + + pub fn set_size(&self, w: u32, h: u32) { + self.request(|d| { + d.conn + .configure_window(self.id, &ConfigureWindowAux::new().width(w).height(h)) + .map(drop) + }); + } + + pub fn show(&self) { + self.request(|d| d.conn.map_window(self.id).map(drop)); + } + + pub fn hide(&self) { + self.request(|d| d.conn.unmap_window(self.id).map(drop)); + } + + pub fn raise(&self) { + self.request(|d| { + d.conn + .configure_window( + self.id, + &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE), + ) + .map(drop) + }); + } + + pub fn is_visible(&self) -> bool { + display() + .ok() + .and_then(|d| d.conn.get_window_attributes(self.id).ok()) + .and_then(|c| c.reply().ok()) + .is_some_and(|a| a.map_state != MapState::UNMAPPED) + } + + pub fn destroy(&self) { + self.request(|d| d.conn.destroy_window(self.id).map(drop)); + } + + fn request(&self, f: impl FnOnce(&Display) -> Result<(), x11rb::errors::ConnectionError>) { + let Ok(d) = display() else { return }; + if let Err(e) = f(d).and_then(|()| d.conn.flush()) { + tracing::warn!(window = self.id, error = %e, "x11 editor window request failed"); + } + } +} + +/// Drains pending events without blocking, returning the windows the user asked +/// to close. Main thread, on the shared plugin tick. No-op until an editor has +/// opened the connection. +pub fn take_close_requests() -> Vec { + let Some(Ok(d)) = DISPLAY.get() else { + return Vec::new(); + }; + let mut closed = Vec::new(); + while let Ok(Some(event)) = d.conn.poll_for_event() { + if let Event::ClientMessage(msg) = event { + if msg.type_ == d.atoms.WM_PROTOCOLS + && msg.data.as_data32()[0] == d.atoms.WM_DELETE_WINDOW + { + closed.push(msg.window); + } + } + } + closed +} + +fn xft_dpi(resources: &str) -> Option { + resources.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + (key.trim() == "Xft.dpi").then(|| value.trim().parse().ok())? + }) +} + +#[cfg(test)] +mod tests { + use super::xft_dpi; + + #[test] + fn reads_xft_dpi_among_other_resources() { + assert_eq!(xft_dpi("Xft.antialias:\t1\nXft.dpi:\t192\n"), Some(192.0)); + } + + #[test] + fn no_xft_dpi_means_none() { + assert_eq!(xft_dpi("Xcursor.size:\t24\n"), None); + assert_eq!(xft_dpi(""), None); + } +} diff --git a/src-tauri/src/audio/plugins/host_api.rs b/src-tauri/src/audio/plugins/host_api.rs index abfc7be0..53bb857c 100644 --- a/src-tauri/src/audio/plugins/host_api.rs +++ b/src-tauri/src/audio/plugins/host_api.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD, Engine as _}; +use super::editor::EditorWindow; use super::{ParamRing, PluginFormat, PluginNode}; use crate::audio::effects::offload::{BlockProcessor, Offload}; use crate::audio::effects::Effect; @@ -335,7 +336,7 @@ pub trait PluginHost: Sync { /// Builds the plugin's view into `window`, returning the view's own size so /// the caller can fit the window to it. - fn embed_editor(&self, node_id: &str, window: &tauri::Window) -> Result; + fn embed_editor(&self, node_id: &str, window: &EditorWindow) -> Result; /// Notifies the plugin that its editor window was shown (unhidden) or focused. fn show_editor(&self, node_id: &str) -> Result<(), String>; diff --git a/src-tauri/src/audio/plugins/main_thread.rs b/src-tauri/src/audio/plugins/main_thread.rs index 8f8d0af3..ea71a4dc 100644 --- a/src-tauri/src/audio/plugins/main_thread.rs +++ b/src-tauri/src/audio/plugins/main_thread.rs @@ -70,6 +70,8 @@ pub fn ensure_ticker() { std::thread::sleep(Duration::from_millis(16)); if let Some(app) = crate::app_handle() { let _ = app.run_on_main_thread(|| { + #[cfg(target_os = "linux")] + super::editor::tick(); for host in super::registry::hosts() { host.tick_and_reclaim(); } diff --git a/src-tauri/src/audio/plugins/mod.rs b/src-tauri/src/audio/plugins/mod.rs index 1c5c2792..b5aa803d 100644 --- a/src-tauri/src/audio/plugins/mod.rs +++ b/src-tauri/src/audio/plugins/mod.rs @@ -10,6 +10,8 @@ mod clap_backend; pub mod clap_host; pub mod clap_registry; pub mod editor; +#[cfg(target_os = "linux")] +mod editor_x11; pub mod host_api; pub mod main_thread; mod node; diff --git a/src-tauri/src/audio/plugins/vst3_registry.rs b/src-tauri/src/audio/plugins/vst3_registry.rs index 8e5fe5ca..eb562851 100644 --- a/src-tauri/src/audio/plugins/vst3_registry.rs +++ b/src-tauri/src/audio/plugins/vst3_registry.rs @@ -177,7 +177,7 @@ fn activate_on_main( fn attach_slot_editor( slot: &mut Slot, node_id: &str, - window: &tauri::Window, + window: &editor::EditorWindow, ) -> Result { let view_addr = parent_handle(window).map_err(|e| format!("vst3 {node_id}: {e}"))?; let (_, titlebar) = editor::decoration_overhead(window); @@ -217,27 +217,23 @@ fn attach_slot_editor( /// The window a plugin view is parented to, as the address VST3 expects for /// this platform's `attached` type. -fn parent_handle(window: &tauri::Window) -> Result { +fn parent_handle(window: &editor::EditorWindow) -> Result { #[cfg(target_os = "macos")] { window + .tauri() .ns_view() .map(|view| view as usize) .map_err(|e| format!("content view: {e}")) } #[cfg(not(target_os = "macos"))] { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use raw_window_handle::RawWindowHandle; - let handle = window - .window_handle() - .map_err(|e| format!("window handle: {e}"))?; - match handle.as_raw() { + match window.raw_handle()? { RawWindowHandle::Win32(h) => Ok(h.hwnd.get() as usize), RawWindowHandle::Xlib(h) => Ok(h.window as usize), RawWindowHandle::Xcb(h) => Ok(h.window.get() as usize), - // VST3 defines no Wayland platform type, so there is nothing to - // parent into; running the session under XWayland is the way out. other => Err(format!( "VST3 editors need an X11 or Win32 window, got {other:?}" )), @@ -324,7 +320,11 @@ impl PluginHost for Vst3Host { Ok(()) } - fn embed_editor(&self, node_id: &str, window: &tauri::Window) -> Result { + fn embed_editor( + &self, + node_id: &str, + window: &editor::EditorWindow, + ) -> Result { let id = node_id.to_string(); let win = window.clone(); From 020ac18dd239863c3a8b468cdaa9d843b71ab480 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:10:01 +0300 Subject: [PATCH 2/4] fix(plugins): start the plugin tick for VST3-only graphs --- src-tauri/src/audio/plugins/vst3_registry.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/audio/plugins/vst3_registry.rs b/src-tauri/src/audio/plugins/vst3_registry.rs index eb562851..b376fb5a 100644 --- a/src-tauri/src/audio/plugins/vst3_registry.rs +++ b/src-tauri/src/audio/plugins/vst3_registry.rs @@ -245,6 +245,7 @@ pub struct Vst3Host; impl PluginHost for Vst3Host { fn activate(&self, req: ActivateRequest<'_>) -> Result { + main_thread::ensure_ticker(); let (node_id, path, plugin_id) = ( req.node_id.to_string(), req.path.to_string(), From 1950153e096c30c9001928b80c9e6b50cae7bea2 Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:19:43 +0300 Subject: [PATCH 3/4] fix(plugins): group, activate and watch Linux editor windows --- src-tauri/src/audio/plugins/editor_x11.rs | 47 ++++++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/audio/plugins/editor_x11.rs b/src-tauri/src/audio/plugins/editor_x11.rs index 4c92b891..723b1f92 100644 --- a/src-tauri/src/audio/plugins/editor_x11.rs +++ b/src-tauri/src/audio/plugins/editor_x11.rs @@ -12,8 +12,8 @@ use std::sync::OnceLock; use x11rb::connection::Connection; use x11rb::properties::WmSizeHints; use x11rb::protocol::xproto::{ - AtomEnum, ConfigureWindowAux, ConnectionExt as _, CreateWindowAux, EventMask, MapState, - PropMode, StackMode, WindowClass, + AtomEnum, ClientMessageEvent, ConfigureWindowAux, ConnectionExt as _, CreateWindowAux, + EventMask, MapState, PropMode, StackMode, WindowClass, }; use x11rb::protocol::Event; use x11rb::rust_connection::RustConnection; @@ -25,6 +25,7 @@ x11rb::atom_manager! { WM_PROTOCOLS, WM_DELETE_WINDOW, _NET_WM_NAME, + _NET_ACTIVE_WINDOW, UTF8_STRING, } } @@ -114,6 +115,16 @@ impl X11Window { title.as_bytes(), ) .map_err(|e| err(&e))?; + // Groups editors with the app in taskbars and window switchers. + d.conn + .change_property8( + PropMode::REPLACE, + id, + AtomEnum::WM_CLASS, + AtomEnum::STRING, + b"splitwave\0Splitwave\0", + ) + .map_err(|e| err(&e))?; let hints = WmSizeHints { min_size: Some((min_w as i32, min_h as i32)), ..Default::default() @@ -171,12 +182,28 @@ impl X11Window { self.request(|d| d.conn.unmap_window(self.id).map(drop)); } + /// Restacking alone does not focus the window, and a WM that manages the + /// stack may ignore it, so the WM is also asked to activate it. Source 2 (a + /// pager) marks the request as the user's own, which focus-stealing + /// prevention lets through. pub fn raise(&self) { self.request(|d| { + d.conn.configure_window( + self.id, + &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE), + )?; + let activate = ClientMessageEvent::new( + 32, + self.id, + d.atoms._NET_ACTIVE_WINDOW, + [2, x11rb::CURRENT_TIME, 0, 0, 0], + ); d.conn - .configure_window( - self.id, - &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE), + .send_event( + false, + d.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + activate, ) .map(drop) }); @@ -210,7 +237,15 @@ pub fn take_close_requests() -> Vec { return Vec::new(); }; let mut closed = Vec::new(); - while let Ok(Some(event)) = d.conn.poll_for_event() { + loop { + let event = match d.conn.poll_for_event() { + Ok(Some(event)) => event, + Ok(None) => break, + Err(e) => { + tracing::warn!(error = %e, "x11 editor connection lost, close buttons stop working"); + break; + } + }; if let Event::ClientMessage(msg) = event { if msg.type_ == d.atoms.WM_PROTOCOLS && msg.data.as_data32()[0] == d.atoms.WM_DELETE_WINDOW From 77249f4a5f50c9b87c40d28ef0b816a416eadefc Mon Sep 17 00:00:00 2001 From: Horuse <39675195+Horuse@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:19:44 +0300 Subject: [PATCH 4/4] fix(plugins): pass the display scale to VST3 editors --- src-tauri/src/audio/plugins/vst3_editor.rs | 11 +++++++++++ src-tauri/src/audio/plugins/vst3_registry.rs | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/audio/plugins/vst3_editor.rs b/src-tauri/src/audio/plugins/vst3_editor.rs index 404e9377..c450d491 100644 --- a/src-tauri/src/audio/plugins/vst3_editor.rs +++ b/src-tauri/src/audio/plugins/vst3_editor.rs @@ -121,6 +121,7 @@ impl EditorView { view: ComPtr, parent: *mut c_void, titlebar: f64, + scale: f64, resize: ResizeRequest, ) -> Result<(Self, EditorSize), String> { // SAFETY: main thread, and `parent` is a live window handle owned by a @@ -142,6 +143,14 @@ impl EditorView { let _: () = msg_send![parent_obj, setWantsLayer: true]; } + // Before the first `getSize`, so the size it reports is already scaled. + // Cocoa scales for Retina itself, and the SDK says not to call it there. + #[cfg(not(target_os = "macos"))] + if let Some(scaling) = view.cast::() { + use vst3::Steinberg::IPlugViewContentScaleSupportTrait; + let _ = scaling.setContentScaleFactor(scale as f32); + } + let mut rect = ViewRect { left: 0, top: 0, @@ -229,6 +238,8 @@ impl EditorView { } } let _ = titlebar; + #[cfg(target_os = "macos")] + let _ = scale; let size = ( (rect.right - rect.left).max(1) as u32, diff --git a/src-tauri/src/audio/plugins/vst3_registry.rs b/src-tauri/src/audio/plugins/vst3_registry.rs index b376fb5a..ff4d1678 100644 --- a/src-tauri/src/audio/plugins/vst3_registry.rs +++ b/src-tauri/src/audio/plugins/vst3_registry.rs @@ -208,7 +208,8 @@ fn attach_slot_editor( let resize = Box::new(move |w: u32, h: u32| { editor::request_resize(&resize_target, &nid, w, h); }); - let (view, size) = EditorView::attach(view, view_addr as *mut c_void, titlebar, resize) + let scale = window.scale_factor(); + let (view, size) = EditorView::attach(view, view_addr as *mut c_void, titlebar, scale, resize) .map_err(|e| format!("vst3 {node_id}: {e}"))?; slot.editor = Some(view);