diff --git a/Cargo.lock b/Cargo.lock index 3228925..4b20fab 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 450d454..1c1583a 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 884eb52..cfce193 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1853,8 +1853,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 b872dec..2e3181d 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 diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index b6fbaa5..6bc732c 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_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index b36e9bb..317fdb1 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -237,8 +237,8 @@ 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 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()); @@ -247,7 +247,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");