From b1cb6cb36873da5e224bfc7a6ed9a840c7793198 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 17:24:26 +0200 Subject: [PATCH] vst3-host: host plugin editors on Windows Adds a Windows implementation of the editor host window: a top-level HWND owned by the app's active window (no taskbar button), sized from the view's rect, resized on IPlugFrame::resizeView, hidden on close and torn down by the editor link's idle like on macOS. The main thread is the one Rust names "main", where the app runs its message loop. Also fixes two Windows-only build problems that kept the VST3 tests from running there: the fixture's bus flags (the vst3 bindings use i32 enums with MSVC) and an unused variable in a unix-only test. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/vst3-host/Cargo.toml | 4 + crates/vst3-host/src/ffi.rs | 175 ++++++++++++++++++- crates/vst3-host/src/lib.rs | 11 +- crates/vst3-host/tests/editor_main_thread.rs | 4 +- crates/vst3-host/tests/fixture/src/lib.rs | 4 +- crates/vst3-host/tests/fixture_e2e.rs | 6 +- 7 files changed, 194 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32289258..4b20fab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3640,6 +3640,7 @@ dependencies = [ "soundcraft-dsp", "thiserror 2.0.21", "vst3", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 450d454b..1c1583a6 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -23,6 +23,10 @@ objc2 = "0.6" objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSView", "NSWindow", "NSGraphics"] } +# The Windows editor host window (a top-level HWND the plugin view's child window lives in). +[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"] } + [[test]] name = "editor_main_thread" harness = false diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad534..5a25191d 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1844,8 +1844,181 @@ mod window { } } +/// The Windows host window: a top-level window owned by the app's active window (so it stays +/// above it and has no taskbar button) whose client area is the plugin view's `HWND` parent. +/// Closing it only hides it; the editor link notices in `idle`, detaches the view, then destroys +/// the window. Messages are dispatched by the app's own event loop on the main thread. +#[cfg(windows)] +mod window { + use std::ffi::{c_char, c_void}; + use std::sync::OnceLock; + use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE, + GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE, + SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU, + }; + + pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND); + + /// Rust names the process's first thread "main"; the app runs its UI (and message loop) there. + pub fn on_main_thread() -> bool { + std::thread::current().name() == Some("main") + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect() + } + + const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; + + unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if msg == WM_CLOSE { + // SAFETY: `hwnd` is the window this procedure was called for. + unsafe { ShowWindow(hwnd, SW_HIDE) }; + return 0; + } + // SAFETY: default handling of a message for the window it was sent to. + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + + /// Registers the window class once; the name lives for the whole process. + fn class_name() -> Option<&'static [u16]> { + static CLASS: OnceLock>> = OnceLock::new(); + CLASS + .get_or_init(|| { + let name = wide("SoundCraftVst3Editor"); + // SAFETY: null asks for this process's module handle. + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: instance, + hIcon: std::ptr::null_mut(), + // SAFETY: a system cursor (null instance + predefined id). + hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) }, + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // SAFETY: a fully initialised class description whose strings outlive the call + // (the name is kept for the process lifetime in this `OnceLock`). + (unsafe { RegisterClassExW(&wc) } != 0).then_some(name) + }) + .as_deref() + } + + /// Outer window size for a client area of `w`×`h`. + fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) { + let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 }; + // SAFETY: `r` is a valid, writable rect. + unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) }; + (r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top)) + } + + /// Owns the window handle; destroyed in `close` (main thread only). + pub struct HostWindow { + hwnd: HWND, + } + + // SAFETY: the handle is only used on the main thread (every method checks); the struct + // merely carries it between threads' locks. + unsafe impl Send for HostWindow {} + + impl HostWindow { + fn live(&self) -> Option { + // SAFETY: `IsWindow` accepts any value and only reports whether it names a window. + (on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd) + } + + pub fn new(title: &str, w: f64, h: f64) -> Result { + if !on_main_thread() { + return Err("not on the main thread".into()); + } + let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?; + let title = wide(title); + let (ow, oh) = outer_size(STYLE, 0, w, h); + // SAFETY: main thread; the active window (or null) of this thread's queue. + let owner = unsafe { GetActiveWindow() }; + // SAFETY: registered class and NUL-terminated strings that outlive the call; the + // owner is null or a window of this thread. + let hwnd = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + title.as_ptr(), + STYLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + ow, + oh, + owner, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + if hwnd.is_null() { + return Err("cannot create the editor window".into()); + } + Ok(HostWindow { hwnd }) + } + + /// The window handle (for the plug frame's resizes). + pub fn raw(&self) -> *mut c_void { + self.hwnd + } + + /// The `HWND` the plugin view creates its child window in. + pub fn parent(&self) -> *mut c_void { + self.live().unwrap_or(std::ptr::null_mut()) + } + + pub fn raise(&self) { + if let Some(h) = self.live() { + // SAFETY: a live window of this thread. + unsafe { + ShowWindow(h, SW_SHOW); + SetForegroundWindow(h); + } + } + } + + pub fn is_visible(&self) -> bool { + // SAFETY: a live window of this thread. + self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0) + } + + pub fn close(self) { + if let Some(h) = self.live() { + // SAFETY: our own live window; the view was detached before (see `teardown`). + unsafe { DestroyWindow(h) }; + } + } + } + + /// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only. + pub fn resize(window: *mut c_void, w: f64, h: f64) { + // SAFETY: `IsWindow` accepts any value. + if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 { + return; + } + // SAFETY: a live window of this thread; style values are read, not changed. + let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) }; + let (ow, oh) = outer_size(style, ex, w, h); + // SAFETY: as above; only the size changes. + unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) }; + } +} + /// Other platforms: no editor windows yet (opening reports "unsupported"). -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", windows)))] 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 index 0cfd3abb..93b68a43 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -25,13 +25,15 @@ //! component state is accepted too) and re-reads the parameter values. //! //! Plugin editors (`IPlugView`): on macOS the view is attached to the content `NSView` of a host -//! `NSWindow` (titled and closable, floating above the app, resized on `IPlugFrame::resizeView`). +//! `NSWindow` (titled and closable, floating above the app, resized on `IPlugFrame::resizeView`); +//! on Windows it is attached to a host `HWND` owned by the app's window (no taskbar button, +//! closing it hides it until the link tears it down). //! [`Plugin::open_editor`] opens it directly; [`Plugin::editor`] returns a handle usable on the //! 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. Windows (HWND) and Linux (X11) editor windows are not hosted yet: -//! opening reports "unsupported". +//! them into the session. Linux (X11) editor windows are not hosted yet: opening reports +//! "unsupported". //! //! 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 kept @@ -314,6 +316,9 @@ mod tests { let e = load_bundle(&d.join("z.vst3")).unwrap_err(); assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); } + // Elsewhere there is no library to point at (and no symlink without privileges). + #[cfg(not(unix))] + let _ = lib; let _ = std::fs::remove_dir_all(&d); } } diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index b6fbaa50..6bc732c7 100644 --- a/crates/vst3-host/tests/editor_main_thread.rs +++ b/crates/vst3-host/tests/editor_main_thread.rs @@ -9,8 +9,8 @@ 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 only; elsewhere the probe reports "unsupported". - if cfg!(target_os = "macos") { + // 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()); diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c61..2d2d10ce 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -52,7 +52,7 @@ fn audio_bus(bus: *mut BusInfo, dir: BusDirection, name: &str) { b.channelCount = 2; copy_c16(name, &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as _; } unsafe fn channels<'a>(bus: &AudioBusBuffers, frames: usize) -> Vec<&'a mut [f32]> { @@ -461,7 +461,7 @@ impl IComponentTrait for Synth { b.channelCount = 16; copy_c16("Notes", &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as _; return kResultOk; } kInvalidArgument diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7a..88fad232 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -212,8 +212,8 @@ fn state_saves_component_and_controller_and_restores_them() { 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 only; elsewhere the probe reports "unsupported". - if cfg!(target_os = "macos") { + // 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()); @@ -222,7 +222,7 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() { // 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!(target_os = "macos") { + 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");