collective

Browse / github.com/storytold/soundcraft / patch

CLAP host: embedded editors on macOS

macOS: CLAP plugins with only an embedded editor (e.g. FabFilter) can't open their GUI ## Summary On macOS, a CLAP plugin that offers only an embedded editor can't show its own GUI. **Open Plugin Editor** does nothing visible; the status line (Edit window only) says: > Pro-Q 4: this plugin offers only an embedded editor, which SoundCraft hosts on Windows only Many CLAP plugins only offer an embedded editor (`clap.gui` with `is_floating = false`), so on macOS they can only be used through the generic parameter sliders. VST3 editors work on macOS, so the same plugin works as VST3 but not as CLAP. ## Environment - SoundCraft 0.6.1 (main @ c0cb993), macOS (aarch64) - FabFilter

Status
open
Score
0.00
Adopters
0 other operators
Author
forwardaudio (imported from pull request #1048, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+1782 −67 in 18 files
Review
pending
Shared
2026-10-28
Id
c3b8943cb9d9bf8cd540cc55

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/clap-host/Cargo.toml crates/clap-host/src/ffi.rs crates/clap-host/src/lib.rs crates/clap-host/tests/common/mod.rs crates/clap-host/tests/editor_main_thread.rs crates/clap-host/tests/fixture/src/lib.rs crates/clap-host/tests/it/fixture_e2e.rs crates/engine/tests/it/clap_e2e.rs crates/vst3-host/Cargo.toml crates/vst3-host/src/ffi.rs crates/vst3-host/src/lib.rs crates/vst3-host/src/plugin.rs crates/vst3-host/tests/editor_main_thread.rs crates/vst3-host/tests/fixture/src/lib.rs crates/vst3-host/tests/it/fixture_e2e.rs docs/gaps.md docs/plugin-parity.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -3661,6 +3661,9 @@ dependencies = [  "clap-sys",  "libloading 0.9.0",  "log",+ "objc2 0.6.5",+ "objc2-app-kit 0.3.2",+ "objc2-foundation 0.3.2",  "serde",  "soundcraft-dsp",  "thiserror 2.0.21",diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml--- a/crates/clap-host/Cargo.toml+++ b/crates/clap-host/Cargo.toml@@ -13,6 +13,18 @@ serde.workspace = true thiserror.workspace = true log.workspace = true +# The macOS host window for embedded editors (an NSWindow whose content view the plugin's GUI+# attaches its NSView to).+[target.'cfg(target_os = "macos")'.dependencies]+objc2 = "0.6"+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSNotification", "NSObject", "NSString"] }+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSView", "NSWindow", "NSGraphics"] }++# The editor test inspects the host window (NSApp's window list, the plugin's view).+[target.'cfg(target_os = "macos")'.dev-dependencies]+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator"] }+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSScreen"] }+ # The Windows host window for embedded editors (a top-level HWND the plugin parents its GUI 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"] }diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs--- a/crates/clap-host/src/ffi.rs+++ b/crates/clap-host/src/ffi.rs@@ -40,7 +40,7 @@ use soundcraft_dsp::Transport; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf};-use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};+use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError};  /// Most plugins one factory may report (hostile-input cap).@@ -279,7 +279,16 @@ impl Bundle {         inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);         let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);         inst.gui = Arc::new(GuiLink {-            inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),+            inner: Mutex::new(GuiInner {+                plugin,+                gui,+                state: inst.state_ext,+                alive: true,+                created: false,+                visible: false,+                window: None,+                sizer: None,+            }),             host: Arc::clone(&inst.state),         });         Ok(inst)@@ -742,6 +751,201 @@ fn window_api() -> Option<&'static CStr> {     } } +/// Negotiates user resizes of an embedded editor's host window with the plugin (CLAP: when+/// `can_resize`, the host asks `adjust_size`, sizes the window to the answer, then `set_size`).+///+/// Shared by the editor link and the host window's procedure. Everything runs on the main thread;+/// the lock only guards against the instance clearing the plugin from another thread while a+/// resize calls into it. Loops are cut by `applying`: while the host applies a size (a plugin's+/// `request_resize`, or a user resize being constrained and handed to `set_size`), the window+/// notifications that this causes are ignored rather than negotiated again.+pub(crate) struct GuiSizer {+    target: Mutex<Option<SizeTarget>>,+    /// The thread inside a call into the plugin under `target`'s lock (see `with_target`).+    holder: Mutex<Option<std::thread::ThreadId>>,+    /// A target change requested re-entrantly by the holder thread, applied when its call returns.+    pending: Mutex<Option<Option<SizeTarget>>>,+    /// The plugin said `can_resize`: user resizes are negotiated (the window has a sizing border).+    resizable: AtomicBool,+    /// Nesting depth of resizes being applied by the host.+    applying: AtomicU32,+    /// The size last given to the plugin (`width << 32 | height`), to skip repeated `set_size`.+    last: AtomicU64,+}++// User resizes come from the Windows and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), allow(dead_code))]+/// The plugin and the two `clap.gui` calls a user resize needs.+#[derive(Clone, Copy)]+struct SizeTarget {+    plugin: *const clap_plugin,+    adjust_size: Option<unsafe extern "C" fn(*const clap_plugin, *mut u32, *mut u32) -> bool>,+    set_size: Option<unsafe extern "C" fn(*const clap_plugin, u32, u32) -> bool>,+}++// SAFETY: the plugin pointer is only called through on the main thread (the window procedure),+// under the sizer's lock, and the editor link clears it under that lock (or, re-entrantly, right+// after the call holding it) before the GUI goes.+unsafe impl Send for SizeTarget {}++/// Ends an "applying" section of a [`GuiSizer`] when dropped.+pub(crate) struct Applying<'a>(&'a AtomicU32);++impl Drop for Applying<'_> {+    fn drop(&mut self) {+        // Paired with the increment in `GuiSizer::applying`: never below zero.+        self.0.fetch_sub(1, Ordering::Relaxed);+    }+}++// User resizes come from the Windows and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), allow(dead_code))]+/// A host-window size in whole pixels (non-finite or out-of-range sizes are clamped).+fn px(v: f64) -> u32 {+    if v.is_finite() { v.round().clamp(1.0, 16_384.0) as u32 } else { 1 }+}++// User resizes come from the Windows and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), allow(dead_code))]+impl GuiSizer {+    pub fn new(w: u32, h: u32) -> GuiSizer {+        let s = GuiSizer {+            target: Mutex::new(None),+            holder: Mutex::new(None),+            pending: Mutex::new(None),+            resizable: AtomicBool::new(false),+            applying: AtomicU32::new(0),+            last: AtomicU64::new(0),+        };+        s.set_last(w, h);+        s+    }++    fn holder(&self) -> std::sync::MutexGuard<'_, Option<std::thread::ThreadId>> {+        self.holder.lock().unwrap_or_else(PoisonError::into_inner)+    }++    fn pending(&self) -> std::sync::MutexGuard<'_, Option<Option<SizeTarget>>> {+        self.pending.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Runs `f` on the plugin under the target lock (`None` without one). A target change+    /// requested from inside `f` on this thread (a plugin pumping a nested event loop that closes+    /// the editor) is deferred until `f` returns instead of deadlocking on the lock.+    fn with_target<R>(&self, f: impl FnOnce(&SizeTarget) -> R) -> Option<R> {+        let mut g = self.target.lock().unwrap_or_else(PoisonError::into_inner);+        *self.holder() = Some(std::thread::current().id());+        let out = g.as_ref().map(f);+        *self.holder() = None;+        if let Some(t) = self.pending().take() {+            *g = t;+        }+        out+    }++    /// Hands the plugin over once its GUI is parented and shown (`can_resize` decides whether+    /// user resizes are negotiated); `None` takes it away again (before the GUI is destroyed).+    fn set_target(&self, target: Option<SizeTarget>, resizable: bool) {+        self.resizable.store(resizable && target.is_some(), Ordering::Relaxed);+        if *self.holder() == Some(std::thread::current().id()) {+            *self.pending() = Some(target);+            return;+        }+        *self.target.lock().unwrap_or_else(PoisonError::into_inner) = target;+    }++    pub fn resizable(&self) -> bool {+        self.resizable.load(Ordering::Relaxed)+    }++    /// Marks a host-applied resize; window notifications are ignored until the guard drops.+    pub fn applying(&self) -> Applying<'_> {+        self.applying.fetch_add(1, Ordering::Relaxed);+        Applying(&self.applying)+    }++    fn is_applying(&self) -> bool {+        self.applying.load(Ordering::Relaxed) > 0+    }++    fn set_last(&self, w: u32, h: u32) {+        self.last.store((u64::from(w) << 32) | u64::from(h), Ordering::Relaxed);+    }++    fn last(&self) -> (u32, u32) {+        let v = self.last.load(Ordering::Relaxed);+        ((v >> 32) as u32, v as u32)+    }++    /// The size the plugin accepts instead of `w`×`h` (`adjust_size`), or `None` when no resize+    /// should be negotiated (it can't resize, is gone, or a resize is being applied).+    pub fn constrain(&self, w: f64, h: f64) -> Option<(f64, f64)> {+        if !self.resizable() || self.is_applying() {+            return None;+        }+        let _guard = self.applying();+        let (w, h) = self.with_target(|t| adjust(t, px(w), px(h)))?;+        Some((f64::from(w), f64::from(h)))+    }++    /// The window's client area was resized to `w`×`h` by the user (or the window manager):+    /// settles on the plugin's adjusted size, makes the window follow it through `set_window`+    /// when it differs, and tells the plugin with `set_size`. Ignored while the host applies a+    /// resize itself.+    pub fn user_resized(&self, w: f64, h: f64, set_window: impl FnOnce(f64, f64)) {+        if !self.resizable() || self.is_applying() {+            return;+        }+        let _guard = self.applying();+        self.with_target(|t| {+            let (w, h) = (px(w), px(h));+            let (aw, ah) = adjust(t, w, h);+            let last = self.last();+            let accepted = last == (aw, ah)+                || match t.set_size {+                    // SAFETY: live plugin with a created, parented GUI (the editor link takes the+                    // target away before destroying it, deferred to here when that happens inside+                    // this call); main thread.+                    Some(f) => unsafe { f(t.plugin, aw, ah) },+                    None => false,+                };+            if accepted {+                self.set_last(aw, ah);+                if (aw, ah) != (w, h) {+                    set_window(f64::from(aw), f64::from(ah));+                }+            } else {+                // The plugin refused the size: the window goes back to the last one it took.+                set_window(f64::from(last.0), f64::from(last.1));+            }+        });+    }+}++// User resizes come from the Windows and macOS host windows only; elsewhere the plugin side is+// unused.+#[cfg_attr(not(any(windows, target_os = "macos")), allow(dead_code))]+/// `adjust_size` for a proposed `w`×`h`; the proposal stands when the plugin can't adjust it.+fn adjust(t: &SizeTarget, w: u32, h: u32) -> (u32, u32) {+    let Some(f) = t.adjust_size else { return (w, h) };+    let (mut aw, mut ah) = (w, h);+    // SAFETY: live plugin with a created GUI (see `user_resized`); both out-pointers are locals.+    if unsafe { f(t.plugin, &mut aw, &mut ah) } && aw > 0 && ah > 0 { (aw.min(16_384), ah.min(16_384)) } else { (w, h) }+}++/// The window rect while the user drags `edge` (a Windows `WMSZ_*` value): moves only the dragged+/// edges so the outer size becomes `w`×`h`. Pure, so it's testable on every platform.+#[cfg_attr(not(windows), allow(dead_code))]+fn sizing_rect(edge: usize, (left, top, right, bottom): (i32, i32, i32, i32), (w, h): (i32, i32)) -> (i32, i32, i32, i32) {+    // WMSZ_LEFT = 1, WMSZ_TOP = 3, WMSZ_TOPLEFT = 4, WMSZ_TOPRIGHT = 5, WMSZ_BOTTOMLEFT = 7.+    let (l, r) = if matches!(edge, 1 | 4 | 7) { (right.saturating_sub(w), right) } else { (left, left.saturating_add(w)) };+    let (t, b) = if matches!(edge, 3..=5) { (bottom.saturating_sub(h), bottom) } else { (top, top.saturating_add(h)) };+    (l, t, r, b)+}+ struct GuiInner {     plugin: *const clap_plugin,     gui: *const clap_plugin_gui,@@ -752,6 +956,8 @@ struct GuiInner {     visible: bool,     /// The host window of an embedded editor (`None` for floating editors).     window: Option<hostwin::HostWindow>,+    /// Negotiates user resizes of `window` (`None` for floating editors).+    sizer: Option<Arc<GuiSizer>>, }  /// The link between an instance and its editor and state handles. The instance may process on@@ -776,6 +982,7 @@ impl Default for GuiLink {                 created: false,                 visible: false,                 window: None,+                sizer: None,             }),             host: Arc::new(HostState::default()),         }@@ -812,6 +1019,12 @@ impl GuiLink {         }         let mut g = self.lock();         let vt = GuiLink::vt(&g).ok_or_else(|| "this plugin has no editor".to_string())?;+        // The user closed the editor (its host window, or the plugin's floating window) since the+        // last `idle`: free it and open a fresh one rather than reviving a closed window.+        if g.created && (g.window.as_ref().is_some_and(|w| !w.is_visible()) || self.host.gui_closed.swap(false, Ordering::Relaxed)) {+            self.host.gui_destroyed.store(false, Ordering::Relaxed);+            GuiLink::destroy(&mut g);+        }         if g.created {             if let Some(show) = vt.show {                 // SAFETY: live plugin with a created GUI; main thread (checked).@@ -831,7 +1044,7 @@ impl GuiLink {             if hostwin::SUPPORTED && unsafe { supported(g.plugin, api.as_ptr(), false) } {                 return self.open_embedded(&mut g, vt, api, title);             }-            return Err("this plugin offers only an embedded editor, which SoundCraft hosts on Windows only".into());+            return Err("this plugin offers only an embedded editor, which SoundCraft hosts on macOS and Windows only".into());         }         self.host.gui_closed.store(false, Ordering::Relaxed);         self.host.gui_destroyed.store(false, Ordering::Relaxed);@@ -874,8 +1087,10 @@ impl GuiLink {             return Err("the plugin could not create its editor".into());         }         g.created = true;-        if let Some(f) = vt.set_scale {-            // SAFETY: live plugin with a created GUI.+        if !cfg!(target_os = "macos")+            && let Some(f) = vt.set_scale+        {+            // SAFETY: live plugin with a created GUI. (Cocoa uses logical pixels: not called.)             unsafe { f(g.plugin, 1.0) };         }         let (mut w, mut h) = (640u32, 480u32);@@ -893,7 +1108,13 @@ impl GuiLink {                 return Err(e);             }         };-        let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } };+        let handle = if cfg!(target_os = "macos") { clap_window_handle { cocoa: win.parent() } } else { clap_window_handle { win32: win.parent() } };+        if win.parent().is_null() {+            win.close();+            GuiLink::destroy(g);+            return Err("the editor window has no view to attach to".into());+        }+        let parent = clap_window { api: api.as_ptr(), specific: handle };         // SAFETY: live plugin with a created embedded GUI; `parent` names our live window and         // outlives the call.         if !unsafe { set_parent(g.plugin, &parent) } {@@ -911,14 +1132,30 @@ impl GuiLink {             GuiLink::destroy(g);             return Err("the plugin could not show its editor".into());         }-        if let Some(win) = &g.window {+        // The window follows `can_resize`: a sizing border, with user resizes negotiated through+        // `adjust_size` and `set_size`.+        // SAFETY: live plugin with a created, parented GUI; main thread.+        let resizable = vt.can_resize.is_some_and(|f| unsafe { f(g.plugin) });+        let sizer = Arc::new(GuiSizer::new(w, h));+        sizer.set_target(Some(SizeTarget { plugin: g.plugin, adjust_size: vt.adjust_size, set_size: vt.set_size }), resizable);+        if let Some(win) = &mut g.window {+            win.set_sizer(&sizer);+            // The frame change of the new sizing border is not a user resize: the window gives+            // the client area back to the plugin's size, with nothing negotiated meanwhile.+            let _applying = sizer.applying();+            win.set_resizable(resizable);             win.raise();         }+        g.sizer = Some(sizer);         g.visible = true;         Ok(())     }      fn destroy(g: &mut GuiInner) {+        // No more user resizes reach the plugin once its GUI is going.+        if let Some(s) = g.sizer.take() {+            s.set_target(None, false);+        }         if g.created             && let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy)         {@@ -985,7 +1222,12 @@ impl GuiLink {                 && let Some(win) = &g.window             {                 let (w, h) = ((resize >> 32) as u32, resize as u32);+                // The window notifications this resize causes are not user resizes.+                let _applying = g.sizer.as_ref().map(|s| s.applying());                 hostwin::resize(win.raw(), f64::from(w), f64::from(h));+                if let Some(s) = &g.sizer {+                    s.set_last(w, h);+                }                 if let Some(f) = GuiLink::vt(&g).and_then(|v| v.set_size) {                     // SAFETY: live plugin with a created embedded GUI, main thread.                     unsafe { f(g.plugin, w, h) };@@ -1030,8 +1272,12 @@ impl GuiLink {             } else {                 log::warn!("clap: editor still open while its plugin is destroyed off the main thread");                 g.created = false;-                // Window calls are main-thread only: leave the host window rather than crash.+                // Window calls are main-thread only: leave the host window rather than crash, but+                // cut its resize handling off from the plugin.                 g.window = None;+                if let Some(s) = g.sizer.take() {+                    s.set_target(None, false);+                }             }         }         g.alive = false;@@ -1443,15 +1689,18 @@ impl Drop for Instance { /// Messages are dispatched by the app's own event loop on the main thread. #[cfg(windows)] mod hostwin {+    use super::{GuiSizer, sizing_rect};     use std::ffi::c_void;-    use std::sync::OnceLock;+    use std::sync::{Arc, 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,+        GWLP_USERDATA, GetClientRect, GetWindowLongPtrW, GetWindowRect, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW,+        SIZE_MINIMIZED, SW_HIDE, SW_SHOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetForegroundWindow,+        SetWindowLongPtrW, SetWindowPos, ShowWindow, WM_CLOSE, WM_NCDESTROY, WM_SIZE, WM_SIZING, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN,+        WS_OVERLAPPED, WS_SYSMENU, WS_THICKFRAME,     };      pub const SUPPORTED: bool = true;@@ -1467,11 +1716,83 @@ mod hostwin {      const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; +    /// The sizer stored in the window's user data by `set_sizer` (one `Arc` reference, released+    /// on `WM_NCDESTROY`), borrowed for one message.+    fn window_sizer(hwnd: HWND) -> Option<Arc<GuiSizer>> {+        // SAFETY: `hwnd` is the window the procedure was called for; its user data is 0 or a+        // pointer from `Arc::into_raw`, kept alive until `WM_NCDESTROY` (the last message).+        let p = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const GuiSizer;+        if p.is_null() {+            return None;+        }+        // SAFETY: as above; this message takes a reference of its own, so the sizer outlives it+        // even when a plugin destroys the window from a nested event loop meanwhile.+        unsafe { Arc::increment_strong_count(p) };+        // SAFETY: balances the increment just made.+        Some(unsafe { Arc::from_raw(p) })+    }++    /// The window's frame (outer minus client size), measured: right at any monitor's DPI, unlike+    /// `AdjustWindowRectEx` (system DPI). `None` when the window can't be measured.+    fn frame_size(hwnd: HWND) -> Option<(i32, i32)> {+        let (mut w, mut c) = (RECT { left: 0, top: 0, right: 0, bottom: 0 }, RECT { left: 0, top: 0, right: 0, bottom: 0 });+        // SAFETY: a window of this thread (or a stale handle, which fails); both rects are writable.+        if unsafe { GetWindowRect(hwnd, &mut w) } == 0 || unsafe { GetClientRect(hwnd, &mut c) } == 0 {+            return None;+        }+        let fw = w.right.saturating_sub(w.left).saturating_sub(c.right.saturating_sub(c.left));+        let fh = w.bottom.saturating_sub(w.top).saturating_sub(c.bottom.saturating_sub(c.top));+        Some((fw.max(0), fh.max(0)))+    }++    /// Outer size for a client area of `w`×`h`: measured frame, else `AdjustWindowRectEx`.+    fn outer_for(hwnd: HWND, w: f64, h: f64) -> (i32, i32) {+        match frame_size(hwnd) {+            Some((fw, fh)) => ((w as i32).saturating_add(fw), (h as i32).saturating_add(fh)),+            None => {+                // SAFETY: a window of this thread; style values are only read.+                let (style, ex) = unsafe { (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32, GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32) };+                outer_size(style, ex, w, h)+            }+        }+    }+     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;+        match msg {+            WM_CLOSE => {+                // SAFETY: `hwnd` is the window this procedure was called for.+                unsafe { ShowWindow(hwnd, SW_HIDE) };+                return 0;+            }+            // Live user resizes: keep the dragged edges at a size the plugin accepts.+            WM_SIZING => {+                // SAFETY: for WM_SIZING, `lparam` points to the window's writable drag rect.+                if let (Some(s), Some(r), Some((fw, fh))) = (window_sizer(hwnd), unsafe { (lparam as *mut RECT).as_mut() }, frame_size(hwnd)) {+                    let (w, h) = (r.right.saturating_sub(r.left).saturating_sub(fw), r.bottom.saturating_sub(r.top).saturating_sub(fh));+                    if let Some((cw, ch)) = s.constrain(f64::from(w.max(1)), f64::from(h.max(1))) {+                        let (ow, oh) = ((cw as i32).saturating_add(fw), (ch as i32).saturating_add(fh));+                        (r.left, r.top, r.right, r.bottom) = sizing_rect(wparam, (r.left, r.top, r.right, r.bottom), (ow, oh));+                        return 1;+                    }+                }+            }+            // Every completed resize: settle on the adjusted size and tell the plugin.+            WM_SIZE if wparam != SIZE_MINIMIZED as usize => {+                if let Some(s) = window_sizer(hwnd) {+                    let (w, h) = (f64::from((lparam & 0xFFFF) as u16), f64::from(((lparam >> 16) & 0xFFFF) as u16));+                    s.user_resized(w, h, |cw, ch| resize(hwnd, cw, ch));+                }+            }+            WM_NCDESTROY => {+                // SAFETY: the window's last message: take the user data back and release the+                // `Arc` reference `set_sizer` stored there; nothing reads it afterwards.+                let p = unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) } as *const GuiSizer;+                if !p.is_null() {+                    // SAFETY: `p` came from `Arc::into_raw` in `set_sizer`, released exactly once.+                    drop(unsafe { Arc::from_raw(p) });+                }+            }+            _ => {}         }         // SAFETY: default handling of a message for the window it was sent to.         unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }@@ -1560,6 +1881,8 @@ mod hostwin {             if hwnd.is_null() {                 return Err("cannot create the editor window".into());             }+            // `AdjustWindowRectEx` assumed the system DPI; size the client area by the real frame.+            resize(hwnd, w, h);             Ok(HostWindow { hwnd })         } @@ -1568,6 +1891,42 @@ mod hostwin {             self.hwnd         } +        /// Routes the window's resizes through `sizer` (the window procedure reads it from the+        /// window's user data). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<GuiSizer>) {+            let Some(h) = self.live() else { return };+            if window_sizer(h).is_some() {+                return;+            }+            let p = Arc::into_raw(sizer.clone());+            // SAFETY: a live window of this thread; the pointer stays valid until `WM_NCDESTROY`+            // releases it.+            unsafe { SetWindowLongPtrW(h, GWLP_USERDATA, p as _) };+        }++        /// Gives the window a sizing border (the plugin said `can_resize`), or takes it away,+        /// keeping the client size.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(h) = self.live() else { return };+            // SAFETY: a live window of this thread; the style is only read.+            if ((unsafe { GetWindowLongPtrW(h, GWL_STYLE) } as u32 & WS_THICKFRAME) != 0) == resizable {+                return;+            }+            let mut c = RECT { left: 0, top: 0, right: 0, bottom: 0 };+            // SAFETY: a live window of this thread; `c` is writable.+            unsafe { GetClientRect(h, &mut c) };+            // SAFETY: as above; only the sizing-border bit of the style changes.+            unsafe {+                let style = GetWindowLongPtrW(h, GWL_STYLE) as u32;+                let style = if resizable { style | WS_THICKFRAME } else { style & !WS_THICKFRAME };+                SetWindowLongPtrW(h, GWL_STYLE, style as _);+            }+            // SAFETY: as above; the frame is recomputed for the new style, nothing else changes.+            unsafe { SetWindowPos(h, std::ptr::null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) };+            // The new frame took room from the client area: give it back.+            resize(h, f64::from(c.right), f64::from(c.bottom));+        }+         /// The `HWND` the plugin creates its child window in.         pub fn parent(&self) -> *mut c_void {             self.live().unwrap_or(std::ptr::null_mut())@@ -1602,16 +1961,247 @@ mod hostwin {         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.+        let (ow, oh) = outer_for(window, w, h);+        // SAFETY: a live window of this thread; only the size changes.         unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };     } } +/// The macOS host window for embedded editors: a floating, transient `NSWindow` whose content view+/// is the `NSView` the plugin adds its GUI's view to. Closing it only orders it out; the editor+/// link notices in `idle`, destroys the GUI, then closes the window. Modelled on the VST3 host's+/// window (same behaviour, `GuiSizer` in place of its `ViewSizer`).+#[cfg(target_os = "macos")]+mod hostwin {+    use super::GuiSizer;+    use objc2::rc::Retained;+    use objc2::runtime::ProtocolObject;+    use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};+    use objc2_app_kit::{+        NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowDelegate, NSWindowStyleMask,+    };+    use objc2_foundation::{NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};+    use std::ffi::c_void;+    use std::sync::Arc;+    use std::sync::atomic::{AtomicBool, Ordering};++    pub const SUPPORTED: bool = true;++    /// The delegate's state: the sizer, and whether the user closed the window.+    struct Ivars {+        sizer: Arc<GuiSizer>,+        closed: Arc<AtomicBool>,+    }++    define_class!(+        // SAFETY: NSObject has no subclassing requirements, and `ResizeDelegate` implements no+        // `Drop` (its ivar, an `Arc`, is dropped by objc2 when the object is deallocated).+        // No `#[name]`: objc2 then generates a crate- and version-specific name and reuses an+        // already registered class of that name (e.g. a second copy of this crate in the process)+        // instead of panicking, and never collides with the VST3 host's delegate class.+        #[unsafe(super(NSObject))]+        #[thread_kind = MainThreadOnly]+        #[ivars = Ivars]+        struct ResizeDelegate;++        // SAFETY: NSObjectProtocol has no safety requirements.+        unsafe impl NSObjectProtocol for ResizeDelegate {}++        // SAFETY: the methods below match the NSWindowDelegate selectors' signatures.+        unsafe impl NSWindowDelegate for ResizeDelegate {+            /// Live user resizes: the window only ever takes a size the plugin accepts.+            #[unsafe(method(windowWillResize:toSize:))]+            fn window_will_resize(&self, sender: &NSWindow, frame_size: NSSize) -> NSSize {+                // Our own reference: the sizer stays alive even if the plugin closes the editor+                // from a nested event loop during this call.+                let sizer = self.ivars().sizer.clone();+                constrained_frame(&sizer, sender, NSRect::new(NSPoint::new(0.0, 0.0), frame_size)).size+            }++            /// The zoom button: zoom to the largest size the plugin accepts within the default+            /// (screen) frame, keeping the window's top edge.+            #[unsafe(method(windowWillUseStandardFrame:defaultFrame:))]+            fn window_standard_frame(&self, window: &NSWindow, default_frame: NSRect) -> NSRect {+                let sizer = self.ivars().sizer.clone();+                let mut frame = constrained_frame(&sizer, window, default_frame);+                frame.origin.y = default_frame.origin.y + default_frame.size.height - frame.size.height;+                frame+            }++            /// Every completed resize (drags, zoom): settles on an adjusted size and tells the+            /// plugin (`set_size`). Resizes the host applies itself are ignored by the sizer.+            #[unsafe(method(windowDidResize:))]+            fn window_did_resize(&self, notification: &NSNotification) {+                let sizer = self.ivars().sizer.clone();+                let Some(window) = notification.object().and_then(|o| o.downcast::<NSWindow>().ok()) else { return };+                let Some(view) = window.contentView() else { return };+                let size = view.frame().size;+                sizer.user_resized(size.width, size.height, |w, h| window.setContentSize(NSSize::new(w, h)));+            }++            /// The user closed the window (the host's own `close` clears the delegate first).+            /// Tracked here rather than read from `isVisible`, which is also false while the app+            /// is inactive and AppKit has hidden the window (`hidesOnDeactivate`).+            #[unsafe(method(windowWillClose:))]+            fn window_will_close(&self, _notification: &NSNotification) {+                self.ivars().closed.store(true, Ordering::Relaxed);+            }+        }+    );++    /// `frame` with its content size adjusted by the plugin (unchanged when nothing is+    /// negotiated); the origin stays.+    fn constrained_frame(sizer: &GuiSizer, window: &NSWindow, frame: NSRect) -> NSRect {+        let content = window.contentRectForFrameRect(frame);+        match sizer.constrain(content.size.width, content.size.height) {+            Some((w, h)) => {+                let size = window.frameRectForContentRect(NSRect::new(content.origin, NSSize::new(w, h))).size;+                NSRect::new(frame.origin, size)+            }+            None => frame,+        }+    }++    impl ResizeDelegate {+        fn new(mtm: MainThreadMarker, sizer: Arc<GuiSizer>, closed: Arc<AtomicBool>) -> Retained<ResizeDelegate> {+            let this = ResizeDelegate::alloc(mtm).set_ivars(Ivars { sizer, closed });+            // SAFETY: `init` of NSObject on a freshly allocated instance whose ivars are set.+            unsafe { msg_send![super(this), init] }+        }+    }++    /// An `NSWindow` (titled, closable, floating above the app's windows) whose content view is+    /// the plugin GUI's parent. Holds one retain of the window; main thread only.+    pub struct HostWindow {+        window: *mut NSWindow,+        /// The window's delegate (one retain; the window only keeps a weak reference), or null.+        delegate: *mut ResizeDelegate,+        /// Set by the delegate when the user closes the window.+        closed: Arc<AtomicBool>,+    }++    // SAFETY: the pointers are only dereferenced on the main thread (every method checks+    // `MainThreadMarker`); the struct merely carries the retains between threads' locks.+    unsafe impl Send for HostWindow {}++    impl HostWindow {+        fn get(&self) -> Option<&NSWindow> {+            MainThreadMarker::new()?;+            // SAFETY: we hold a retain of this window (from `new`) until `close`.+            unsafe { self.window.as_ref() }+        }++        pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {+            let mtm = MainThreadMarker::new().ok_or_else(|| "plugin editors must be opened on the main thread".to_string())?;+            let _app = NSApplication::sharedApplication(mtm);+            let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h));+            // Plugin editors are transient windows. In particular they must not be minimizable:+            // minimized NSWindows become persistent thumbnails in the macOS Dock.+            let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable;+            // SAFETY: a freshly allocated window on the main thread; valid rect and style.+            let window = unsafe {+                NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false)+            };+            // SAFETY: we keep our own retain and release it in `close`, so AppKit must not+            // release the window when the user closes it.+            unsafe { window.setReleasedWhenClosed(false) };+            window.setTitle(&NSString::from_str(title));+            window.setLevel(NSFloatingWindowLevel);+            window.setHidesOnDeactivate(true);+            window.setExcludedFromWindowsMenu(true);+            window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);+            window.center();+            Ok(HostWindow { window: Retained::into_raw(window), delegate: std::ptr::null_mut(), closed: Arc::new(AtomicBool::new(false)) })+        }++        /// The raw window pointer (for resizes).+        pub fn raw(&self) -> *mut c_void {+            self.window.cast()+        }++        /// Routes the window's resizes through `sizer` (an `NSWindowDelegate`). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<GuiSizer>) {+            let (Some(mtm), Some(w)) = (MainThreadMarker::new(), self.get()) else { return };+            if !self.delegate.is_null() {+                return;+            }+            let delegate = ResizeDelegate::new(mtm, sizer.clone(), self.closed.clone());+            w.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));+            self.delegate = Retained::into_raw(delegate);+        }++        /// Gives the window a resize handle (the plugin said `can_resize`), or takes it away,+        /// keeping the content size.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(w) = self.get() else { return };+            let style = w.styleMask();+            let style = if resizable { style | NSWindowStyleMask::Resizable } else { style & !NSWindowStyleMask::Resizable };+            if style == w.styleMask() {+                return;+            }+            let size = w.contentView().map(|v| v.frame().size);+            w.setStyleMask(style);+            // A resizable window gets a zoom button; keep it out of full screen (the editor is a+            // transient panel, and the sizer would fight a full-screen size the plugin refuses).+            if resizable {+                w.setCollectionBehavior(w.collectionBehavior() | NSWindowCollectionBehavior::FullScreenNone);+            }+            if let Some(size) = size {+                w.setContentSize(size);+            }+        }++        /// The content view (`NSView*`) the plugin adds its view to; null when unavailable.+        pub fn parent(&self) -> *mut c_void {+            self.get().and_then(|w| w.contentView()).map_or(std::ptr::null_mut(), |v| Retained::as_ptr(&v).cast_mut().cast())+        }++        pub fn raise(&self) {+            if let Some(w) = self.get() {+                w.makeKeyAndOrderFront(None);+            }+        }++        /// The window is open: not closed by the user. (Before the delegate is set, the window's+        /// own visibility.)+        pub fn is_visible(&self) -> bool {+            self.get().is_some_and(|w| if self.delegate.is_null() { w.isVisible() } else { !self.closed.load(Ordering::Relaxed) })+        }++        pub fn close(self) {+            if let Some(w) = self.get() {+                w.setDelegate(None);+                w.orderOut(None);+                w.close();+            }+            if MainThreadMarker::new().is_some() {+                // SAFETY: balances the retain taken in `new`; nothing uses the pointer after this.+                drop(unsafe { Retained::from_raw(self.window) });+                // SAFETY: null, or balances the retain taken in `set_sizer`; the window no longer+                // refers to it (delegate cleared above).+                if let Some(d) = unsafe { Retained::from_raw(self.delegate) } {+                    // Released when the event loop's autorelease pool drains, not now: `close` may+                    // run inside one of the delegate's own methods (a plugin pumping a nested loop).+                    let _ = Retained::autorelease_ptr(d);+                }+            }+        }+    }++    /// Resizes a host window's content (`window` from [`HostWindow::raw`]); main thread only.+    pub fn resize(window: *mut c_void, w: f64, h: f64) {+        if MainThreadMarker::new().is_none() {+            return;+        }+        // SAFETY: null or a window the editor link keeps retained while the GUI is embedded.+        if let Some(win) = unsafe { window.cast::<NSWindow>().as_ref() } {+            win.setContentSize(NSSize::new(w, h));+        }+    }+}+ /// Other platforms: embedded editors are not hosted (floating ones still are).-#[cfg(not(windows))]+#[cfg(not(any(windows, target_os = "macos")))] mod hostwin {     use std::ffi::c_void; @@ -1626,6 +2216,8 @@ mod hostwin {         pub fn raw(&self) -> *mut c_void {             std::ptr::null_mut()         }+        pub fn set_sizer(&mut self, _sizer: &std::sync::Arc<super::GuiSizer>) {}+        pub fn set_resizable(&self, _resizable: bool) {}         pub fn parent(&self) -> *mut c_void {             std::ptr::null_mut()         }@@ -1643,6 +2235,113 @@ mod hostwin { mod tests {     use super::*; +    /// `set_size` calls of the fake plugin below (one test uses it).+    static SET_SIZES: Mutex<Vec<(u32, u32)>> = Mutex::new(Vec::new());+    /// When set, the fake `set_size` calls back into the sizer, the way a plugin resizing its+    /// parent from `set_size` makes the window notify the host again.+    static NESTED: Mutex<Option<Arc<GuiSizer>>> = Mutex::new(None);+    /// When set, the fake `set_size` closes the editor from inside the call (takes the target+    /// away), the way a plugin pumping a nested event loop can.+    static CLOSE_INSIDE: Mutex<Option<Arc<GuiSizer>>> = Mutex::new(None);+    /// The fake `set_size` refuses every size.+    static REFUSE: AtomicBool = AtomicBool::new(false);++    /// Accepts 200..=800 × 100..=600.+    unsafe extern "C" fn fake_adjust(_p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {+        // SAFETY: the sizer passes valid out-pointers.+        unsafe {+            *w = (*w).clamp(200, 800);+            *h = (*h).clamp(100, 600);+        }+        true+    }++    unsafe extern "C" fn fake_set_size(_p: *const clap_plugin, w: u32, h: u32) -> bool {+        SET_SIZES.lock().unwrap().push((w, h));+        if let Some(s) = NESTED.lock().unwrap().clone() {+            s.user_resized(999.0, 999.0, |_, _| panic!("a nested notification is not negotiated"));+        }+        if let Some(s) = CLOSE_INSIDE.lock().unwrap().take() {+            s.set_target(None, false);+        }+        !REFUSE.load(Ordering::Relaxed)+    }++    #[test]+    fn user_resizes_are_negotiated_with_the_plugin_without_loops() {+        let target = SizeTarget { plugin: std::ptr::null(), adjust_size: Some(fake_adjust), set_size: Some(fake_set_size) };+        let sizes = || SET_SIZES.lock().unwrap().clone();+        let sizer = Arc::new(GuiSizer::new(320, 240));+        // `can_resize` false: nothing is negotiated.+        sizer.set_target(Some(target), false);+        assert_eq!(sizer.constrain(500.0, 400.0), None);+        sizer.user_resized(500.0, 400.0, |_, _| panic!("not resizable"));+        assert!(sizes().is_empty());++        sizer.set_target(Some(target), true);+        assert_eq!(sizer.constrain(50.0, 5000.0), Some((200.0, 600.0)), "adjust_size shapes the drag");+        let mut set = None;+        sizer.user_resized(1000.0, 50.0, |w, h| set = Some((w, h)));+        assert_eq!(set, Some((800.0, 100.0)), "the window follows the adjusted size");+        assert_eq!(sizes(), vec![(800, 100)]);+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        assert_eq!(sizes(), vec![(800, 100), (640, 480)], "the same size is not repeated");++        // A plugin `request_resize` being applied: its window notifications are ignored.+        {+            let _applying = sizer.applying();+            assert_eq!(sizer.constrain(300.0, 300.0), None);+            sizer.user_resized(300.0, 300.0, |_, _| panic!("ignored while applying"));+            sizer.set_last(300, 300);+        }+        sizer.user_resized(300.0, 300.0, |_, _| panic!("accepted as is"));+        assert_eq!(sizes().len(), 2);++        // A plugin that resizes its parent from `set_size` doesn't start a loop (or deadlock).+        *NESTED.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(400.0, 400.0, |_, _| panic!("accepted as is"));+        *NESTED.lock().unwrap() = None;+        assert_eq!(sizes(), vec![(800, 100), (640, 480), (400, 400)]);++        // A plugin that refuses the size: the window goes back to the last accepted one.+        REFUSE.store(true, Ordering::Relaxed);+        let mut set = None;+        sizer.user_resized(500.0, 500.0, |w, h| set = Some((w, h)));+        REFUSE.store(false, Ordering::Relaxed);+        assert_eq!(set, Some((400.0, 400.0)));+        assert_eq!(sizer.last(), (400, 400));+        SET_SIZES.lock().unwrap().pop();++        // Closing the editor from inside `set_size` neither deadlocks nor leaves the plugin+        // reachable: the removal is applied when the call returns.+        *CLOSE_INSIDE.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(450.0, 450.0, |_, _| panic!("accepted as is"));+        assert!(!sizer.resizable());+        assert!(sizer.target.lock().unwrap().is_none());+        sizer.set_target(Some(target), true);++        // A plugin without `adjust_size` gets the user's size as is.+        sizer.set_target(Some(SizeTarget { adjust_size: None, ..target }), true);+        assert_eq!(sizer.constrain(5000.0, 10.0), Some((5000.0, 10.0)));++        // Once the GUI goes, nothing reaches the plugin any more.+        sizer.set_target(None, true);+        assert!(!sizer.resizable());+        sizer.user_resized(500.0, 500.0, |_, _| panic!("no plugin"));+        assert_eq!(sizes().len(), 4);+    }++    #[test]+    fn sizing_moves_only_the_dragged_edges() {+        let r = (100, 100, 500, 400);+        assert_eq!(sizing_rect(8, r, (300, 200)), (100, 100, 400, 300), "bottom-right");+        assert_eq!(sizing_rect(4, r, (300, 200)), (200, 200, 500, 400), "top-left");+        assert_eq!(sizing_rect(5, r, (300, 200)), (100, 200, 400, 400), "top-right");+        assert_eq!(sizing_rect(7, r, (300, 200)), (200, 100, 500, 300), "bottom-left");+        assert_eq!(sizing_rect(2, (i32::MAX - 1, 0, i32::MAX, 10), (100, 10)), (i32::MAX - 1, 0, i32::MAX, 10), "saturates");+    }+     #[test]     fn the_transport_reaches_clap_in_fixed_point() {         let t = Transport {diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs--- a/crates/clap-host/src/lib.rs+++ b/crates/clap-host/src/lib.rs@@ -21,9 +21,12 @@ //! [`Plugin::load_state`] as an opaque blob. Plugin editors (`clap.gui`) open as the plugin's own //! floating window ([`Plugin::open_editor`], or [`Plugin::editor`] for a handle usable on the main //! thread while the instance processes on the audio thread). Plugins that offer only an embedded-//! editor get a host window of SoundCraft's on Windows (resized on `request_resize`, closed by-//! its title bar); elsewhere they are not hosted yet, and SoundCraft's generic parameter editor-//! still works for them.+//! editor get a host window of SoundCraft's on macOS (an `NSWindow` whose content view is the+//! Cocoa parent; no `set_scale`, Cocoa uses logical pixels) and Windows (resized on+//! `request_resize`, closed by its title bar; when the plugin `can_resize` the window gets a+//! sizing border, and user resizes are shaped by `adjust_size` and applied with `set_size`, the+//! window going back to the last size when the plugin refuses one); elsewhere (X11) they are not+//! hosted yet, and SoundCraft's generic parameter editor still works for them. //! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes //! and are reported by the editor's `idle`. diff --git a/crates/clap-host/tests/common/mod.rs b/crates/clap-host/tests/common/mod.rs--- a/crates/clap-host/tests/common/mod.rs+++ b/crates/clap-host/tests/common/mod.rs@@ -5,6 +5,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; +/// How many plugins the fixture library exports (`tests/fixture/src/lib.rs`, `factory_count`).+pub const FIXTURE_PLUGINS: usize = 6;+ /// Builds the fixture once and returns a folder containing `Fixture.clap`. pub fn fixture_dir() -> &'static Path {     static DIR: OnceLock<PathBuf> = OnceLock::new();@@ -40,7 +43,7 @@ pub fn fixture_dir() -> &'static Path {             std::fs::create_dir_all(dir.join("vendor")).unwrap();             std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();         }-        assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);+        assert_eq!(soundcraft_clap_host::add_search_dir(&dir), FIXTURE_PLUGINS);         dir     }) }diff --git a/crates/clap-host/tests/editor_main_thread.rs b/crates/clap-host/tests/editor_main_thread.rs--- a/crates/clap-host/tests/editor_main_thread.rs+++ b/crates/clap-host/tests/editor_main_thread.rs@@ -1,12 +1,15 @@ //! Plugin editors must be driven from the process main thread, which the standard test harness-//! never uses, so this test binary has its own `main` (`harness = false`). The fixture's GUI is-//! window-less: nothing appears on screen.+//! never uses, so this test binary has its own `main` (`harness = false`). The gain fixture's+//! floating GUI is window-less; the embedded fixture's GUI lives in a real host window (macOS and+//! Windows), which briefly appears on screen. Set `SOUNDCRAFT_SKIP_GUI_TESTS=1` to skip that part+//! (it is skipped anyway on macOS without a screen, e.g. outside a login session).  mod common;  use soundcraft_dsp::Plugin;  const GAIN: &str = "clap:org.soundcraft.test.gain";+const EMBEDDED: &str = "clap:org.soundcraft.test.embedded";  fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {     let mut io = vec![vec![1.0f32; 64]; 2];@@ -44,5 +47,116 @@ fn main() {     assert!(ed.open().is_err());     assert!(ed.idle().is_empty());     ed.close();+    embedded();     println!("editor_main_thread: ok"); }++/// The visible host window of the embedded fixture, its content size and its child view's size.+#[cfg(target_os = "macos")]+fn host_window() -> Option<(objc2::rc::Retained<objc2_app_kit::NSWindow>, (f64, f64), (f64, f64))> {+    let mtm = objc2::MainThreadMarker::new().expect("main thread");+    let w = objc2_app_kit::NSApplication::sharedApplication(mtm)+        .windows()+        .iter()+        .find(|w| w.isVisible() && w.title().to_string() == "Fixture Embedded")?;+    let content = w.contentView()?;+    let child = content.subviews().firstObject()?;+    let (c, v) = (content.frame().size, child.frame().size);+    Some((w, (c.width, c.height), (v.width, v.height)))+}++/// Real windows can be opened: not opted out, and (macOS) a screen to put them on.+fn gui_available() -> bool {+    if std::env::var_os("SOUNDCRAFT_SKIP_GUI_TESTS").is_some_and(|v| !v.is_empty() && v != "0") {+        return false;+    }+    #[cfg(target_os = "macos")]+    {+        let mtm = objc2::MainThreadMarker::new().expect("main thread");+        objc2_app_kit::NSScreen::mainScreen(mtm).is_some()+    }+    #[cfg(not(target_os = "macos"))]+    true+}++/// An embedded-only CLAP editor (no floating window): hosted in a window of ours on macOS and+/// Windows, refused elsewhere.+fn embedded() {+    let mut p = soundcraft_clap_host::create(EMBEDDED).expect("embedded fixture");+    p.prepare(48_000.0, 64, 2);+    let mut ed = p.editor().expect("editor handle");+    if !cfg!(any(target_os = "macos", windows)) {+        assert!(ed.open().is_err(), "embedded editors are not hosted here");+        return;+    }+    if !gui_available() {+        println!("editor_main_thread: no GUI session; embedded editor windows skipped");+        return;+    }+    objc_pool(|| {+        ed.open().expect("embedded editor opens");+        assert!(ed.is_open());+        #[cfg(target_os = "macos")]+        {+            use objc2_app_kit::NSWindowStyleMask;+            use objc2_foundation::NSSize;+            let (w, content, child) = host_window().expect("host window with the plugin's view");+            assert_eq!((content, child), ((400.0, 300.0), (400.0, 300.0)), "sized by get_size");+            assert!(w.styleMask().contains(NSWindowStyleMask::Resizable), "can_resize gives a resize handle");+            // The fixture's `show` asked for 300×200 (`request_resize`): applied by idle.+            let io = block(&mut p);+            assert!((io[0][10] - 0.25).abs() < 1e-6);+            assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);+            let (_, content, child) = host_window().expect("still open");+            assert_eq!((content, child), ((300.0, 200.0), (300.0, 200.0)), "request_resize applied");+            // A user resize is negotiated: adjust_size clamps it, the window follows, set_size+            // resizes the plugin's view.+            w.setContentSize(NSSize::new(1000.0, 1000.0));+            let (_, content, child) = host_window().expect("still open");+            assert_eq!((content, child), ((800.0, 600.0), (800.0, 600.0)), "user resize adjusted");+            // Hidden (as AppKit does when the app deactivates) is not closed.+            w.orderOut(None);+            assert!(ed.idle().is_empty());+            assert!(ed.is_open(), "a hidden editor stays open");+            ed.open().expect("raise");+            assert!(host_window().is_some(), "raised again");+            // The user closes the window: the editor notices and frees the GUI in idle.+            w.performClose(None);+            assert!(!ed.is_open());+            assert!(ed.idle().is_empty());+            assert!(host_window().is_none());+            // Closed by the user and reopened before the next idle: a fresh editor, not the+            // closed window brought back (and then freed by idle).+            ed.open().expect("reopen");+            let (w, _, _) = host_window().expect("reopened");+            w.performClose(None);+            assert!(!ed.is_open());+            ed.open().expect("reopen before idle");+            assert!(ed.idle().is_empty());+            assert!(ed.is_open(), "the reopened editor survives idle");+            assert!(host_window().is_some());+        }+        ed.close();+        assert!(!ed.is_open());+        // Reopen, then drop the instance with the editor open: the GUI goes first (the fixture+        // asserts that), and the window with it.+        ed.open().expect("reopen");+        assert!(ed.is_open());+        drop(p);+        assert!(!ed.is_open());+        #[cfg(target_os = "macos")]+        assert!(host_window().is_none(), "the host window closed with the instance");+        assert!(ed.open().is_err());+    });+}++/// Runs `f` inside an autorelease pool (the app's event loop provides one; this test has none).+#[cfg(target_os = "macos")]+fn objc_pool(f: impl FnOnce()) {+    objc2::rc::autoreleasepool(|_| f());+}++#[cfg(not(target_os = "macos"))]+fn objc_pool(f: impl FnOnce()) {+    f();+}diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs--- a/crates/clap-host/tests/fixture/src/lib.rs+++ b/crates/clap-host/tests/fixture/src/lib.rs@@ -14,6 +14,11 @@ //! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a //! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI, //! reported as an output parameter event by the next `process`.+//!+//! - `org.soundcraft.test.embedded`: the gain plugin with an embedded-only `clap.gui` instead+//!   (Cocoa on macOS): `set_parent` adds a child `NSView` to the host's view, `get_size` starts at+//!   400×300, `can_resize` is true, `adjust_size` clamps to 200..=800 × 150..=600 and `set_size`+//!   resizes the child view, so a test can read the negotiated size off the view. #![allow(clippy::missing_safety_doc, non_upper_case_globals)]  use clap_sys::audio_buffer::clap_audio_buffer;@@ -75,6 +80,19 @@ static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {     features: GAIN_FEATURES.0.as_ptr(), }; +static EMBEDDED_DESC: clap_plugin_descriptor = clap_plugin_descriptor {+    clap_version: CLAP_VERSION,+    id: c"org.soundcraft.test.embedded".as_ptr(),+    name: c"Fixture Embedded".as_ptr(),+    vendor: c"SoundCraft tests".as_ptr(),+    url: std::ptr::null(),+    manual_url: std::ptr::null(),+    support_url: std::ptr::null(),+    version: c"1.0.0".as_ptr(),+    description: std::ptr::null(),+    features: GAIN_FEATURES.0.as_ptr(),+};+ static SYNTH_DESC: clap_plugin_descriptor = clap_plugin_descriptor {     clap_version: CLAP_VERSION,     id: c"org.soundcraft.test.synth".as_ptr(),@@ -102,6 +120,10 @@ struct Fx {     gui_created: bool,     gui_visible: bool,     gui_edit_pending: bool,+    /// The embedded-only GUI variant.+    embedded: bool,+    /// The embedded GUI's child view (macOS), owned (one retain) until `gui_destroy`.+    gui_view: *mut c_void, }  unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {@@ -469,14 +491,19 @@ unsafe extern "C" fn state_load(p: *const clap_plugin, stream: *const clap_istre  static STATE: clap_plugin_state = clap_plugin_state { save: Some(state_save), load: Some(state_load) }; -unsafe extern "C" fn gui_is_api_supported(_p: *const clap_plugin, api: *const c_char, floating: bool) -> bool {+unsafe extern "C" fn gui_is_api_supported(p: *const clap_plugin, api: *const c_char, floating: bool) -> bool {     assert!(!api.is_null());-    floating+    if unsafe { fx(p) }.embedded {+        let api = unsafe { CStr::from_ptr(api) };+        !floating && (api == CLAP_WINDOW_API_COCOA || api == CLAP_WINDOW_API_WIN32 || api == CLAP_WINDOW_API_X11)+    } else {+        floating+    } }  unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floating: bool) -> bool {     let f = unsafe { fx(p) };-    assert!(floating && !f.gui_created, "create");+    assert!(floating != f.embedded && !f.gui_created, "create");     f.gui_created = true;     true }@@ -484,14 +511,68 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa unsafe extern "C" fn gui_destroy(p: *const clap_plugin) {     let f = unsafe { fx(p) };     assert!(f.gui_created, "destroy without create");+    if !f.gui_view.is_null() {+        unsafe { cocoa::remove_and_release(f.gui_view) };+        f.gui_view = std::ptr::null_mut();+    }     f.gui_created = false;     f.gui_visible = false; } -unsafe extern "C" fn gui_set_scale(_p: *const clap_plugin, scale: f64) -> bool {+unsafe extern "C" fn gui_set_scale(p: *const clap_plugin, scale: f64) -> bool {+    // CLAP: Cocoa uses logical pixels, so a host never sets a scale there.+    assert!(!(cfg!(target_os = "macos") && unsafe { fx(p) }.embedded), "set_scale on Cocoa");     scale > 0.0 } +unsafe extern "C" fn gui_get_size(p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {+    assert!(unsafe { fx(p) }.gui_created);+    unsafe {+        *w = 400;+        *h = 300;+    }+    true+}++unsafe extern "C" fn gui_can_resize(_p: *const clap_plugin) -> bool {+    true+}++unsafe extern "C" fn gui_adjust_size(_p: *const clap_plugin, w: *mut u32, h: *mut u32) -> bool {+    unsafe {+        *w = (*w).clamp(200, 800);+        *h = (*h).clamp(150, 600);+    }+    true+}++unsafe extern "C" fn gui_set_size(p: *const clap_plugin, w: u32, h: u32) -> bool {+    let f = unsafe { fx(p) };+    assert!(f.gui_created, "set_size without a GUI");+    if !(200..=800).contains(&w) || !(150..=600).contains(&h) {+        return false;+    }+    if !f.gui_view.is_null() {+        unsafe { cocoa::set_size(f.gui_view, f64::from(w), f64::from(h)) };+    }+    true+}++unsafe extern "C" fn gui_set_parent(p: *const clap_plugin, window: *const clap_window) -> bool {+    let f = unsafe { fx(p) };+    assert!(f.gui_created && f.gui_view.is_null(), "set_parent");+    let window = unsafe { &*window };+    if cfg!(target_os = "macos") {+        assert_eq!(unsafe { CStr::from_ptr(window.api) }, CLAP_WINDOW_API_COCOA);+        let parent = unsafe { window.specific.cocoa };+        assert!(!parent.is_null(), "no parent view");+        f.gui_view = unsafe { cocoa::add_child(parent, 400.0, 300.0) };+        !f.gui_view.is_null()+    } else {+        true+    }+}+ unsafe extern "C" fn gui_suggest_title(p: *const clap_plugin, title: *const c_char) {     assert!(unsafe { fx(p) }.gui_created);     assert!(!unsafe { CStr::from_ptr(title) }.to_bytes().is_empty());@@ -515,6 +596,87 @@ unsafe extern "C" fn gui_hide(p: *const clap_plugin) -> bool {     true } +static GUI_EMBEDDED: clap_plugin_gui = clap_plugin_gui {+    is_api_supported: Some(gui_is_api_supported),+    get_preferred_api: None,+    create: Some(gui_create),+    destroy: Some(gui_destroy),+    set_scale: Some(gui_set_scale),+    get_size: Some(gui_get_size),+    can_resize: Some(gui_can_resize),+    get_resize_hints: None,+    adjust_size: Some(gui_adjust_size),+    set_size: Some(gui_set_size),+    set_parent: Some(gui_set_parent),+    set_transient: None,+    suggest_title: None,+    show: Some(gui_show),+    hide: Some(gui_hide),+};++/// Just enough AppKit, through the Objective-C runtime, for the embedded GUI's child view.+#[cfg(target_os = "macos")]+mod cocoa {+    use std::ffi::{c_char, c_void};++    #[repr(C)]+    #[derive(Clone, Copy)]+    struct Rect {+        x: f64,+        y: f64,+        w: f64,+        h: f64,+    }++    #[link(name = "objc")]+    unsafe extern "C" {+        fn objc_getClass(name: *const c_char) -> *mut c_void;+        fn sel_registerName(name: *const c_char) -> *mut c_void;+        fn objc_msgSend();+    }++    #[link(name = "AppKit", kind = "framework")]+    unsafe extern "C" {}++    /// A new `NSView` of `w`×`h` added to `parent` (one retain, owned by the caller).+    pub unsafe fn add_child(parent: *mut c_void, w: f64, h: f64) -> *mut c_void {+        unsafe {+            let send0: unsafe extern "C" fn(*mut c_void, *mut c_void) -> *mut c_void = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+            let init: unsafe extern "C" fn(*mut c_void, *mut c_void, Rect) -> *mut c_void = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+            let send1: unsafe extern "C" fn(*mut c_void, *mut c_void, *mut c_void) = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+            let view = send0(objc_getClass(c"NSView".as_ptr()), sel_registerName(c"alloc".as_ptr()));+            let view = init(view, sel_registerName(c"initWithFrame:".as_ptr()), Rect { x: 0.0, y: 0.0, w, h });+            send1(parent, sel_registerName(c"addSubview:".as_ptr()), view);+            view+        }+    }++    pub unsafe fn set_size(view: *mut c_void, w: f64, h: f64) {+        unsafe {+            let send: unsafe extern "C" fn(*mut c_void, *mut c_void, f64, f64) = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+            send(view, sel_registerName(c"setFrameSize:".as_ptr()), w, h);+        }+    }++    pub unsafe fn remove_and_release(view: *mut c_void) {+        unsafe {+            let send0: unsafe extern "C" fn(*mut c_void, *mut c_void) = std::mem::transmute(objc_msgSend as unsafe extern "C" fn());+            send0(view, sel_registerName(c"removeFromSuperview".as_ptr()));+            send0(view, sel_registerName(c"release".as_ptr()));+        }+    }+}++#[cfg(not(target_os = "macos"))]+mod cocoa {+    use std::ffi::c_void;+    pub unsafe fn add_child(_parent: *mut c_void, _w: f64, _h: f64) -> *mut c_void {+        std::ptr::null_mut()+    }+    pub unsafe fn set_size(_view: *mut c_void, _w: f64, _h: f64) {}+    pub unsafe fn remove_and_release(_view: *mut c_void) {}+}+ static GUI: clap_plugin_gui = clap_plugin_gui {     is_api_supported: Some(gui_is_api_supported),     get_preferred_api: None,@@ -546,6 +708,8 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) ->         &NOTES as *const _ as *const c_void     } else if id == CLAP_EXT_STATE && !synth {         &STATE as *const _ as *const c_void+    } else if id == CLAP_EXT_GUI && unsafe { fx(p) }.embedded {+        &GUI_EMBEDDED as *const _ as *const c_void     } else if id == CLAP_EXT_GUI && !synth {         &GUI as *const _ as *const c_void     } else {@@ -558,7 +722,7 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {} // ---- factory + entry ----  unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {-    5+    6 }  unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {@@ -568,14 +732,18 @@ unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *co         2 => &WIDE16_DESC,         3 => &WIDE17_DESC,         4 => &WIDE65_DESC,+        5 => &EMBEDDED_DESC,         _ => std::ptr::null(),     } }  unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {     let id = unsafe { CStr::from_ptr(id) };+    let embedded = id == c"org.soundcraft.test.embedded";     let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" {         (&GAIN_DESC as *const _, false, None)+    } else if embedded {+        (&EMBEDDED_DESC as *const _, false, None)     } else if id == c"org.soundcraft.test.synth" {         (&SYNTH_DESC as *const _, true, None)     } else if id == c"org.soundcraft.test.wide16" {@@ -613,6 +781,8 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const         gui_created: false,         gui_visible: false,         gui_edit_pending: false,+        embedded,+        gui_view: std::ptr::null_mut(),     });     let raw = Box::into_raw(b);     unsafe {diff --git a/crates/clap-host/tests/it/fixture_e2e.rs b/crates/clap-host/tests/it/fixture_e2e.rs--- a/crates/clap-host/tests/it/fixture_e2e.rs+++ b/crates/clap-host/tests/it/fixture_e2e.rs@@ -3,7 +3,7 @@  use soundcraft_dsp::{Category, Unit}; -use crate::common::fixture_dir;+use crate::common::{FIXTURE_PLUGINS, fixture_dir};  const GAIN: &str = "clap:org.soundcraft.test.gain"; const SYNTH: &str = "clap:org.soundcraft.test.synth";@@ -12,7 +12,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth"; fn scan_lists_both_fixture_plugins() {     let dir = fixture_dir();     let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);-    assert_eq!(found.len(), 5);+    assert_eq!(found.len(), FIXTURE_PLUGINS);     let g = found.iter().find(|d| d.id == GAIN).unwrap();     assert_eq!(g.name, "Fixture Gain");     assert_eq!(g.vendor, "SoundCraft tests");diff --git a/crates/engine/tests/it/clap_e2e.rs b/crates/engine/tests/it/clap_e2e.rs--- a/crates/engine/tests/it/clap_e2e.rs+++ b/crates/engine/tests/it/clap_e2e.rs@@ -40,7 +40,8 @@ fn install_fixture_now() {     let _ = std::fs::remove_dir_all(&dir);     std::fs::create_dir_all(&dir).unwrap();     std::fs::copy(target.join("debug").join(lib), dir.join("Fixture.clap")).unwrap();-    assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);+    // The plugins it exports are checked by id below (their count belongs to the clap-host tests).+    assert!(soundcraft_clap_host::add_search_dir(&dir) > 0); }  #[test]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@@ -20,7 +20,7 @@ libloading = "0.9" # The macOS editor host window (an NSWindow whose content view the plugin's IPlugView attaches to). [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6"-objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] }+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSNotification", "NSObject", "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).diff --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@@ -1763,6 +1763,8 @@ fn split_state(data: &[u8]) -> Option<(&[u8], &[u8])> { pub(crate) struct PlugFrame {     /// The host window (an `NSWindow*` on macOS), not owned; null when there is none.     window: std::sync::atomic::AtomicPtr<c_void>,+    /// Shared with the host window's resize handling (delegate / window procedure).+    sizer: Arc<ViewSizer>, }  impl Class for PlugFrame {@@ -1771,11 +1773,21 @@ impl Class for PlugFrame {  impl IPlugFrameTrait for PlugFrame {     unsafe fn resizeView(&self, view: *mut sb::IPlugView, new_size: *mut sb::ViewRect) -> sb::tresult {+        // Like Steinberg's editorhost: only the attached view may resize the window, and not+        // again from inside its own resize (a view that re-requests a size from `onSize` would+        // otherwise recurse until the stack overflows).+        if view.is_null() || view != self.sizer.attached() {+            return sb::kInvalidArgument;+        }         // SAFETY: in-pointer from the plugin (or null, handled).         let Some(r) = (unsafe { new_size.as_mut() }) else { return sb::kInvalidArgument };+        let Some(_resizing) = self.sizer.plugin_resize() else { return sb::kResultFalse };         let (w, h) = rect_size(r);+        // The window notifications this resize causes are not user resizes: don't negotiate them.+        let _guard = self.sizer.applying();         window::resize(self.window.load(Ordering::Relaxed), w, h);-        // SAFETY: the plugin passes its own live view (or null, handled).+        self.sizer.set_last(w, h);+        // SAFETY: the plugin passes its own live view (checked against the attached one above).         if let Some(v) = unsafe { vst3::ComRef::from_raw(view) } {             // SAFETY: valid view; `r` is the plugin's own writable rect.             unsafe { v.onSize(r) };@@ -1791,6 +1803,220 @@ fn rect_size(r: &sb::ViewRect) -> (f64, f64) {     (f64::from(w), f64::from(h)) } +/// A view rect of `w`×`h` at the origin (non-finite or out-of-range sizes are clamped).+fn size_rect(w: f64, h: f64) -> sb::ViewRect {+    let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 16_384.0) as i32 } else { 1 };+    sb::ViewRect { left: 0, top: 0, right: px(w), bottom: px(h) }+}++type ViewSlot = Option<ComPtr<sb::IPlugView>>;++/// Negotiates user resizes of a host window with its plugin view (VST3: the host asks+/// `checkSizeConstraint`, sizes the window to the answer, then calls `onSize`).+///+/// Shared by the plug frame (plugin-initiated `resizeView`) and the platform window's resize+/// handling (an `NSWindowDelegate` on macOS, the window procedure on Windows). Everything runs on+/// the main thread; the view lock only guards against the instance clearing the view from another+/// thread while a resize calls into it. Loops are cut in two places: while the host applies a+/// size (a plugin `resizeView`, or a user resize being constrained and handed to `onSize`), the+/// window notifications this causes are ignored rather than negotiated again (`applying`), and a+/// `resizeView` from inside another one is refused (`plugin_resizing`).+pub(crate) struct ViewSizer {+    view: Mutex<ViewSlot>,+    /// The thread inside a call into the view under `view`'s lock (see `with_view`).+    holder: Mutex<Option<std::thread::ThreadId>>,+    /// A view change requested re-entrantly by the holder thread, applied when its call returns.+    pending: Mutex<Option<ViewSlot>>,+    /// The attached view (identity only, never dereferenced): the one allowed to `resizeView`.+    attached: std::sync::atomic::AtomicPtr<sb::IPlugView>,+    /// The view said `canResize`: user resizes are negotiated (the window has a resize handle).+    resizable: AtomicBool,+    /// Nesting depth of resizes being applied by the host.+    applying: AtomicU32,+    /// A plugin `resizeView` is in progress.+    plugin_resizing: AtomicBool,+    /// The size last given to the view (`width << 32 | height`), to skip repeated `onSize`.+    last: AtomicU64,+}++/// Ends an "applying" section of a [`ViewSizer`] when dropped.+pub(crate) struct Applying<'a>(&'a AtomicU32);++impl Drop for Applying<'_> {+    fn drop(&mut self) {+        // Paired with the increment in `ViewSizer::applying`: never below zero.+        self.0.fetch_sub(1, Ordering::Relaxed);+    }+}++/// Ends a plugin `resizeView` of a [`ViewSizer`] when dropped.+pub(crate) struct PluginResize<'a>(&'a AtomicBool);++impl Drop for PluginResize<'_> {+    fn drop(&mut self) {+        self.0.store(false, Ordering::Relaxed);+    }+}++impl ViewSizer {+    pub fn new(w: f64, h: f64) -> ViewSizer {+        let s = ViewSizer {+            view: Mutex::new(None),+            holder: Mutex::new(None),+            pending: Mutex::new(None),+            attached: std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()),+            resizable: AtomicBool::new(false),+            applying: AtomicU32::new(0),+            plugin_resizing: AtomicBool::new(false),+            last: AtomicU64::new(0),+        };+        s.set_last(w, h);+        s+    }++    fn holder(&self) -> std::sync::MutexGuard<'_, Option<std::thread::ThreadId>> {+        self.holder.lock().unwrap_or_else(PoisonError::into_inner)+    }++    fn pending(&self) -> std::sync::MutexGuard<'_, Option<ViewSlot>> {+        self.pending.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Runs `f` on the view under its lock (`None` without a view). A view change requested from+    /// inside `f` on this thread (a plugin pumping a nested event loop that closes the editor) is+    /// deferred until `f` returns instead of deadlocking on the lock.+    fn with_view<R>(&self, f: impl FnOnce(&ComPtr<sb::IPlugView>) -> R) -> Option<R> {+        let mut g = self.view.lock().unwrap_or_else(PoisonError::into_inner);+        *self.holder() = Some(std::thread::current().id());+        let out = g.as_ref().map(f);+        *self.holder() = None;+        if let Some(v) = self.pending().take() {+            *g = v;+        }+        out+    }++    /// Replaces the view, or defers that when this thread is inside `with_view` (see there).+    /// Returns the previous view when it was replaced right away.+    fn replace_view(&self, view: ViewSlot) -> ViewSlot {+        self.attached.store(view.as_ref().map_or(std::ptr::null_mut(), |v| v.as_ptr()), Ordering::Relaxed);+        if *self.holder() == Some(std::thread::current().id()) {+            *self.pending() = Some(view);+            return None;+        }+        let mut g = self.view.lock().unwrap_or_else(PoisonError::into_inner);+        std::mem::replace(&mut *g, view)+    }++    /// Names the view about to be attached: from now on it may `resizeView` (plugins often do+    /// from `attached`), before it is handed over with [`ViewSizer::set_view`].+    pub fn expect_view(&self, view: &ComPtr<sb::IPlugView>) {+        self.attached.store(view.as_ptr(), Ordering::Relaxed);+    }++    fn attached(&self) -> *mut sb::IPlugView {+        self.attached.load(Ordering::Relaxed)+    }++    /// Hands the attached view over (or takes it away again: `None`).+    pub fn set_view(&self, view: ViewSlot, resizable: bool) {+        self.resizable.store(resizable && view.is_some(), Ordering::Relaxed);+        drop(self.replace_view(view));+    }++    /// Takes the view away (stops negotiating) and hands its reference to the caller (`None`+    /// when the removal had to be deferred, see `with_view`).+    pub fn take_view(&self) -> ViewSlot {+        self.resizable.store(false, Ordering::Relaxed);+        self.replace_view(None)+    }++    pub fn resizable(&self) -> bool {+        self.resizable.load(Ordering::Relaxed)+    }++    /// Marks a host-applied resize; window notifications are ignored until the guard drops.+    pub fn applying(&self) -> Applying<'_> {+        self.applying.fetch_add(1, Ordering::Relaxed);+        Applying(&self.applying)+    }++    fn is_applying(&self) -> bool {+        self.applying.load(Ordering::Relaxed) > 0+    }++    /// Starts a plugin `resizeView`; `None` while one is already in progress.+    fn plugin_resize(&self) -> Option<PluginResize<'_>> {+        (!self.plugin_resizing.swap(true, Ordering::Relaxed)).then_some(PluginResize(&self.plugin_resizing))+    }++    fn set_last(&self, w: f64, h: f64) {+        let r = size_rect(w, h);+        self.last.store((u64::from(r.right as u32) << 32) | u64::from(r.bottom as u32), Ordering::Relaxed);+    }++    fn last(&self) -> (f64, f64) {+        let v = self.last.load(Ordering::Relaxed);+        (f64::from((v >> 32) as u32), f64::from(v as u32))+    }++    /// The size the view accepts instead of `w`×`h` (`checkSizeConstraint`), or `None` when no+    /// resize should be negotiated (the view can't resize, is gone, or a resize is being applied).+    pub fn constrain(&self, w: f64, h: f64) -> Option<(f64, f64)> {+        if !self.resizable() || self.is_applying() {+            return None;+        }+        let _guard = self.applying();+        self.with_view(|view| constrain_view(view, w, h))+    }++    /// The window's content was resized to `w`×`h` by the user (or the window manager): settles+    /// on the view's constrained size, makes the window follow it through `set_window` when it+    /// differs, and tells the view with `onSize`. Ignored while the host applies a resize itself.+    // Called by the macOS and Windows host windows only (and the tests).+    #[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))]+    pub fn user_resized(&self, w: f64, h: f64, set_window: impl FnOnce(f64, f64)) {+        if !self.resizable() || self.is_applying() {+            return;+        }+        let _guard = self.applying();+        self.with_view(|view| {+            let (cw, ch) = constrain_view(view, w, h);+            let want = size_rect(w, h);+            let mut r = size_rect(cw, ch);+            if (r.right, r.bottom) != (want.right, want.bottom) {+                set_window(cw, ch);+            }+            if self.last() != (cw, ch) {+                self.set_last(cw, ch);+                // SAFETY: valid, attached view (the editor link takes it away before `removed`,+                // deferred to here when that happens inside this call); main thread; `r` is a+                // writable local rect.+                unsafe { view.onSize(&mut r) };+            }+        });+    }+}++/// `checkSizeConstraint` for a proposed `w`×`h`: the plugin adjusts the rect in place to a size it+/// supports. A plugin that doesn't implement it leaves the rect alone (the proposal stands).+fn constrain_view(view: &ComPtr<sb::IPlugView>, w: f64, h: f64) -> (f64, f64) {+    let mut r = size_rect(w, h);+    // SAFETY: valid view; `r` is a writable local rect.+    unsafe { view.checkSizeConstraint(&mut r) };+    rect_size(&r)+}++/// The window rect while the user drags `edge` (a Windows `WMSZ_*` value): moves only the dragged+/// edges so the outer size becomes `w`×`h`. Pure, so it's testable on every platform.+#[cfg_attr(not(windows), allow(dead_code))]+fn sizing_rect(edge: usize, (left, top, right, bottom): (i32, i32, i32, i32), (w, h): (i32, i32)) -> (i32, i32, i32, i32) {+    // WMSZ_LEFT = 1, WMSZ_TOP = 3, WMSZ_TOPLEFT = 4, WMSZ_TOPRIGHT = 5, WMSZ_BOTTOMLEFT = 7.+    let (l, r) = if matches!(edge, 1 | 4 | 7) { (right.saturating_sub(w), right) } else { (left, left.saturating_add(w)) };+    let (t, b) = if matches!(edge, 3..=5) { (bottom.saturating_sub(h), bottom) } else { (top, top.saturating_add(h)) };+    (l, t, r, b)+}+ #[derive(Default)] struct EditorInner {     /// `None` once the instance is gone.@@ -1849,6 +2075,23 @@ impl EditorLink {         Ok((w as u32, h as u32))     } +    /// Creates the view and asks it how it would take a user resize to `w`×`h`, without+    /// attaching it: `None` when it can't resize (`canResize`), else the size `checkSizeConstraint`+    /// settles on. The same negotiation the host window runs, except that `canResize` and+    /// `checkSizeConstraint` are asked before `attached` (the window asks once attached), which+    /// some plugins answer differently. For diagnostics and tests.+    pub fn probe_resize(&self, w: u32, h: u32) -> Result<Option<(u32, u32)>, String> {+        let g = self.lock();+        let view = EditorLink::create_view(&g)?;+        // SAFETY: valid view.+        let resizable = unsafe { view.canResize() } == sb::kResultTrue;+        let sizer = ViewSizer::new(f64::from(w), f64::from(h));+        sizer.set_view(Some(view), resizable);+        let out = sizer.constrain(f64::from(w), f64::from(h)).map(|(a, b)| (a as u32, b as u32));+        sizer.set_view(None, false);+        Ok(out)+    }+     /// Opens (or raises) the editor in a host window. Main thread only.     pub fn open(&self, title: &str) -> Result<(), String> {         if !window::on_main_thread() {@@ -1873,13 +2116,16 @@ impl EditorLink {         // SAFETY: valid view; `r` is writable. On failure the default size stays.         unsafe { view.getSize(&mut r) };         let (w, h) = rect_size(&r);-        let win = window::HostWindow::new(title, w, h)?;-        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(win.raw()) });+        let mut win = window::HostWindow::new(title, w, h)?;+        let sizer = Arc::new(ViewSizer::new(w, h));+        win.set_sizer(&sizer);+        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(win.raw()), sizer: sizer.clone() });         if let Some(f) = frame.as_com_ref::<sb::IPlugFrame>() {             // SAFETY: valid view; the frame outlives the view's attachment (kept in `g.frame`             // and detached in `teardown`).             unsafe { view.setFrame(f.as_ptr()) };         }+        sizer.expect_view(&view);         // SAFETY: valid view; the parent is the live content view of `win`, of the type named.         if !ok(unsafe { view.attached(win.parent(), platform) }) {             // SAFETY: valid view; detach our frame again.@@ -1887,6 +2133,13 @@ impl EditorLink {             win.close();             return Err("the editor could not attach to its window".into());         }+        // Asked once attached: some plugins only know whether their editor resizes by then.+        // SAFETY: valid, attached view.+        let resizable = unsafe { view.canResize() } == sb::kResultTrue;+        // The resize border first: the frame change it causes is not a user resize (the sizer+        // negotiates only once it has the view).+        win.set_resizable(resizable);+        sizer.set_view(Some(view.clone()), resizable);         win.raise();         g.view = Some(view);         g.frame = Some(frame);@@ -1895,6 +2148,10 @@ impl EditorLink {     }      fn teardown(g: &mut EditorInner) {+        // No more user resizes reach the view once it is being removed.+        if let Some(f) = &g.frame {+            f.sizer.set_view(None, false);+        }         if let Some(view) = g.view.take() {             // SAFETY: valid, attached view; main thread (callers check). `removed` before the             // window goes, and the frame is detached before it is released.@@ -2033,9 +2290,11 @@ impl EditorLink {                     std::mem::forget(v);                     leaked = true;                 }-                // `HostWindow` has no destructor: dropping it leaks its retain on purpose.+                // `HostWindow` has no destructor: dropping it leaks its retain on purpose. The+                // leaked window keeps its resize handling, so cut it off from the view first.                 g.window = None;                 if let Some(f) = g.frame.take() {+                    std::mem::forget(f.sizer.take_view());                     std::mem::forget(f);                 }             }@@ -2049,11 +2308,85 @@ impl EditorLink { /// The platform host window for plugin views. #[cfg(target_os = "macos")] mod window {+    use super::ViewSizer;     use objc2::rc::Retained;-    use objc2::{MainThreadMarker, MainThreadOnly};-    use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask};-    use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};+    use objc2::runtime::ProtocolObject;+    use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};+    use objc2_app_kit::{+        NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowDelegate, NSWindowStyleMask,+    };+    use objc2_foundation::{NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};     use std::ffi::{c_char, c_void};+    use std::sync::Arc;++    define_class!(+        // SAFETY: NSObject has no subclassing requirements, and `ResizeDelegate` implements no+        // `Drop` (its ivar, an `Arc`, is dropped by objc2 when the object is deallocated).+        // No `#[name]`: objc2 then generates a crate- and version-specific name and reuses an+        // already registered class of that name (e.g. a second copy of this crate in the process)+        // instead of panicking.+        #[unsafe(super(NSObject))]+        #[thread_kind = MainThreadOnly]+        #[ivars = Arc<ViewSizer>]+        struct ResizeDelegate;++        // SAFETY: NSObjectProtocol has no safety requirements.+        unsafe impl NSObjectProtocol for ResizeDelegate {}++        // SAFETY: the methods below match the NSWindowDelegate selectors' signatures.+        unsafe impl NSWindowDelegate for ResizeDelegate {+            /// Live user resizes: the window only ever takes a size the view accepts.+            #[unsafe(method(windowWillResize:toSize:))]+            fn window_will_resize(&self, sender: &NSWindow, frame_size: NSSize) -> NSSize {+                // Our own reference: the sizer stays alive even if the plugin closes the editor+                // from a nested event loop during this call.+                let sizer = self.ivars().clone();+                constrained_frame(&sizer, sender, NSRect::new(NSPoint::new(0.0, 0.0), frame_size)).size+            }++            /// The zoom button: zoom to the largest size the view accepts within the default+            /// (screen) frame, keeping the window's top edge.+            #[unsafe(method(windowWillUseStandardFrame:defaultFrame:))]+            fn window_standard_frame(&self, window: &NSWindow, default_frame: NSRect) -> NSRect {+                let sizer = self.ivars().clone();+                let mut frame = constrained_frame(&sizer, window, default_frame);+                frame.origin.y = default_frame.origin.y + default_frame.size.height - frame.size.height;+                frame+            }++            /// Every completed resize (drags, zoom): settles on a constrained size and tells the+            /// view. Resizes the host applies itself are ignored by the sizer.+            #[unsafe(method(windowDidResize:))]+            fn window_did_resize(&self, notification: &NSNotification) {+                let sizer = self.ivars().clone();+                let Some(window) = notification.object().and_then(|o| o.downcast::<NSWindow>().ok()) else { return };+                let Some(view) = window.contentView() else { return };+                let size = view.frame().size;+                sizer.user_resized(size.width, size.height, |w, h| window.setContentSize(NSSize::new(w, h)));+            }+        }+    );++    /// `frame` with its content size constrained by the view (unchanged when nothing is+    /// negotiated); the origin stays.+    fn constrained_frame(sizer: &ViewSizer, window: &NSWindow, frame: NSRect) -> NSRect {+        let content = window.contentRectForFrameRect(frame);+        match sizer.constrain(content.size.width, content.size.height) {+            Some((w, h)) => {+                let size = window.frameRectForContentRect(NSRect::new(content.origin, NSSize::new(w, h))).size;+                NSRect::new(frame.origin, size)+            }+            None => frame,+        }+    }++    impl ResizeDelegate {+        fn new(mtm: MainThreadMarker, sizer: Arc<ViewSizer>) -> Retained<ResizeDelegate> {+            let this = ResizeDelegate::alloc(mtm).set_ivars(sizer);+            // SAFETY: `init` of NSObject on a freshly allocated instance whose ivars are set.+            unsafe { msg_send![super(this), init] }+        }+    }      pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeNSView); @@ -2065,10 +2398,12 @@ mod window {     /// the plugin view's parent. Holds one retain of the window; main thread only.     pub struct HostWindow {         window: *mut NSWindow,+        /// The window's delegate (one retain; the window only keeps a weak reference), or null.+        delegate: *mut ResizeDelegate,     } -    // SAFETY: the pointer is only dereferenced on the main thread (every method checks-    // `MainThreadMarker`); the struct merely carries the retain between threads' locks.+    // SAFETY: the pointers are only dereferenced on the main thread (every method checks+    // `MainThreadMarker`); the struct merely carries the retains between threads' locks.     unsafe impl Send for HostWindow {}      impl HostWindow {@@ -2099,7 +2434,38 @@ mod window {             window.setExcludedFromWindowsMenu(true);             window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);             window.center();-            Ok(HostWindow { window: Retained::into_raw(window) })+            Ok(HostWindow { window: Retained::into_raw(window), delegate: std::ptr::null_mut() })+        }++        /// Routes the window's resizes through `sizer` (an `NSWindowDelegate`). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<ViewSizer>) {+            let (Some(mtm), Some(w)) = (MainThreadMarker::new(), self.get()) else { return };+            if !self.delegate.is_null() {+                return;+            }+            let delegate = ResizeDelegate::new(mtm, sizer.clone());+            w.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));+            self.delegate = Retained::into_raw(delegate);+        }++        /// Gives the window a resize handle (the view said `canResize`), or takes it away.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(w) = self.get() else { return };+            let style = w.styleMask();+            let style = if resizable { style | NSWindowStyleMask::Resizable } else { style & !NSWindowStyleMask::Resizable };+            if style == w.styleMask() {+                return;+            }+            let size = w.contentView().map(|v| v.frame().size);+            w.setStyleMask(style);+            // A resizable window gets a zoom button; keep it out of full screen (the editor is a+            // transient panel, and the sizer would fight a full-screen size the view refuses).+            if resizable {+                w.setCollectionBehavior(w.collectionBehavior() | NSWindowCollectionBehavior::FullScreenNone);+            }+            if let Some(size) = size {+                w.setContentSize(size);+            }         }          /// The raw window pointer (for the plug frame's resizes).@@ -2124,12 +2490,20 @@ mod window {          pub fn close(self) {             if let Some(w) = self.get() {+                w.setDelegate(None);                 w.orderOut(None);                 w.close();             }             if MainThreadMarker::new().is_some() {                 // SAFETY: balances the retain taken in `new`; nothing uses the pointer after this.                 drop(unsafe { Retained::from_raw(self.window) });+                // SAFETY: null, or balances the retain taken in `set_sizer`; the window no longer+                // refers to it (delegate cleared above).+                if let Some(d) = unsafe { Retained::from_raw(self.delegate) } {+                    // Released when the event loop's autorelease pool drains, not now: `close` may+                    // run inside one of the delegate's own methods (a plugin pumping a nested loop).+                    let _ = Retained::autorelease_ptr(d);+                }             }         }     }@@ -2152,15 +2526,18 @@ mod window { /// the window. Messages are dispatched by the app's own event loop on the main thread. #[cfg(windows)] mod window {+    use super::{ViewSizer, sizing_rect};     use std::ffi::{c_char, c_void};-    use std::sync::OnceLock;+    use std::sync::{Arc, 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,+        GWLP_USERDATA, GetClientRect, GetWindowLongPtrW, GetWindowRect, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW,+        SIZE_MINIMIZED, SW_HIDE, SW_SHOW, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetForegroundWindow,+        SetWindowLongPtrW, SetWindowPos, ShowWindow, WM_CLOSE, WM_NCDESTROY, WM_SIZE, WM_SIZING, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN,+        WS_OVERLAPPED, WS_SYSMENU, WS_THICKFRAME,     };      pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND);@@ -2176,11 +2553,83 @@ mod window {      const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN; +    /// The sizer stored in the window's user data by `set_sizer` (one `Arc` reference, released+    /// on `WM_NCDESTROY`), borrowed for one message.+    fn window_sizer(hwnd: HWND) -> Option<Arc<ViewSizer>> {+        // SAFETY: `hwnd` is the window the procedure was called for; its user data is 0 or a+        // pointer from `Arc::into_raw`, kept alive until `WM_NCDESTROY` (the last message).+        let p = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const ViewSizer;+        if p.is_null() {+            return None;+        }+        // SAFETY: as above; this message takes a reference of its own, so the sizer outlives it+        // even when a plugin destroys the window from a nested event loop meanwhile.+        unsafe { Arc::increment_strong_count(p) };+        // SAFETY: balances the increment just made.+        Some(unsafe { Arc::from_raw(p) })+    }++    /// The window's frame (outer minus client size), measured: right at any monitor's DPI, unlike+    /// `AdjustWindowRectEx` (system DPI). `None` when the window can't be measured.+    fn frame_size(hwnd: HWND) -> Option<(i32, i32)> {+        let (mut w, mut c) = (RECT { left: 0, top: 0, right: 0, bottom: 0 }, RECT { left: 0, top: 0, right: 0, bottom: 0 });+        // SAFETY: a window of this thread (or a stale handle, which fails); both rects are writable.+        if unsafe { GetWindowRect(hwnd, &mut w) } == 0 || unsafe { GetClientRect(hwnd, &mut c) } == 0 {+            return None;+        }+        let fw = w.right.saturating_sub(w.left).saturating_sub(c.right.saturating_sub(c.left));+        let fh = w.bottom.saturating_sub(w.top).saturating_sub(c.bottom.saturating_sub(c.top));+        Some((fw.max(0), fh.max(0)))+    }++    /// Outer size for a client area of `w`×`h`: measured frame, else `AdjustWindowRectEx`.+    fn outer_for(hwnd: HWND, w: f64, h: f64) -> (i32, i32) {+        match frame_size(hwnd) {+            Some((fw, fh)) => ((w as i32).saturating_add(fw), (h as i32).saturating_add(fh)),+            None => {+                // SAFETY: a window of this thread; style values are only read.+                let (style, ex) = unsafe { (GetWindowLongPtrW(hwnd, GWL_STYLE) as u32, GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32) };+                outer_size(style, ex, w, h)+            }+        }+    }+     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;+        match msg {+            WM_CLOSE => {+                // SAFETY: `hwnd` is the window this procedure was called for.+                unsafe { ShowWindow(hwnd, SW_HIDE) };+                return 0;+            }+            // Live user resizes: keep the dragged edges at a size the view accepts.+            WM_SIZING => {+                // SAFETY: for WM_SIZING, `lparam` points to the window's writable drag rect.+                if let (Some(s), Some(r), Some((fw, fh))) = (window_sizer(hwnd), unsafe { (lparam as *mut RECT).as_mut() }, frame_size(hwnd)) {+                    let (w, h) = (r.right.saturating_sub(r.left).saturating_sub(fw), r.bottom.saturating_sub(r.top).saturating_sub(fh));+                    if let Some((cw, ch)) = s.constrain(f64::from(w.max(1)), f64::from(h.max(1))) {+                        let (ow, oh) = ((cw as i32).saturating_add(fw), (ch as i32).saturating_add(fh));+                        (r.left, r.top, r.right, r.bottom) = sizing_rect(wparam, (r.left, r.top, r.right, r.bottom), (ow, oh));+                        return 1;+                    }+                }+            }+            // Every completed resize: settle on the constrained size and tell the view.+            WM_SIZE if wparam != SIZE_MINIMIZED as usize => {+                if let Some(s) = window_sizer(hwnd) {+                    let (w, h) = (f64::from((lparam & 0xFFFF) as u16), f64::from(((lparam >> 16) & 0xFFFF) as u16));+                    s.user_resized(w, h, |cw, ch| resize(hwnd, cw, ch));+                }+            }+            WM_NCDESTROY => {+                // SAFETY: the window's last message: take the user data back and release the+                // `Arc` reference `set_sizer` stored there; nothing reads it afterwards.+                let p = unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) } as *const ViewSizer;+                if !p.is_null() {+                    // SAFETY: `p` came from `Arc::into_raw` in `set_sizer`, released exactly once.+                    drop(unsafe { Arc::from_raw(p) });+                }+            }+            _ => {}         }         // SAFETY: default handling of a message for the window it was sent to.         unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }@@ -2269,6 +2718,8 @@ mod window {             if hwnd.is_null() {                 return Err("cannot create the editor window".into());             }+            // `AdjustWindowRectEx` assumed the system DPI; size the client area by the real frame.+            resize(hwnd, w, h);             Ok(HostWindow { hwnd })         } @@ -2277,6 +2728,42 @@ mod window {             self.hwnd         } +        /// Routes the window's resizes through `sizer` (the window procedure reads it from the+        /// window's user data). Once per window.+        pub fn set_sizer(&mut self, sizer: &Arc<ViewSizer>) {+            let Some(h) = self.live() else { return };+            if window_sizer(h).is_some() {+                return;+            }+            let p = Arc::into_raw(sizer.clone());+            // SAFETY: a live window of this thread; the pointer stays valid until `WM_NCDESTROY`+            // releases it.+            unsafe { SetWindowLongPtrW(h, GWLP_USERDATA, p as _) };+        }++        /// Gives the window a sizing border (the view said `canResize`), or takes it away,+        /// keeping the client size.+        pub fn set_resizable(&self, resizable: bool) {+            let Some(h) = self.live() else { return };+            // SAFETY: a live window of this thread; the style is only read.+            if ((unsafe { GetWindowLongPtrW(h, GWL_STYLE) } as u32 & WS_THICKFRAME) != 0) == resizable {+                return;+            }+            let mut c = RECT { left: 0, top: 0, right: 0, bottom: 0 };+            // SAFETY: a live window of this thread; `c` is writable.+            unsafe { GetClientRect(h, &mut c) };+            // SAFETY: as above; only the sizing-border bit of the style changes.+            unsafe {+                let style = GetWindowLongPtrW(h, GWL_STYLE) as u32;+                let style = if resizable { style | WS_THICKFRAME } else { style & !WS_THICKFRAME };+                SetWindowLongPtrW(h, GWL_STYLE, style as _);+            }+            // SAFETY: as above; the frame is recomputed for the new style, nothing else changes.+            unsafe { SetWindowPos(h, std::ptr::null_mut(), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED) };+            // The new frame took room from the client area: give it back.+            resize(h, f64::from(c.right), f64::from(c.bottom));+        }+         /// 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())@@ -2311,10 +2798,8 @@ mod window {         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.+        let (ow, oh) = outer_for(window, w, h);+        // SAFETY: a live window of this thread; only the size changes.         unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };     } }@@ -2339,6 +2824,8 @@ mod window {         pub fn raw(&self) -> *mut c_void {             std::ptr::null_mut()         }+        pub fn set_sizer(&mut self, _sizer: &std::sync::Arc<super::ViewSizer>) {}+        pub fn set_resizable(&self, _resizable: bool) {}         pub fn parent(&self) -> *mut c_void {             std::ptr::null_mut()         }@@ -2407,6 +2894,199 @@ mod tests {         assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());     } +    /// A plugin view that accepts 200..=800 × 100..=600 and records what the host tells it. With+    /// `nested`, it calls back into the sizer from `onSize`, the way a plugin calling+    /// `resizeView` from `onSize` makes the window notify the host again.+    struct SizedView {+        sizes: Mutex<Vec<(i32, i32)>>,+        nested: Mutex<Option<Arc<ViewSizer>>>,+        /// Closes the editor (takes the view away) from inside `onSize`, once.+        close_inside: Mutex<Option<Arc<ViewSizer>>>,+        /// Re-requests its size through this frame from inside `onSize` (as `view`, once per+        /// `onSize`), recording what the host answered.+        frame: Mutex<Option<(ComPtr<sb::IPlugFrame>, usize)>>,+        answers: Mutex<Vec<sb::tresult>>,+    }++    impl SizedView {+        fn new() -> SizedView {+            SizedView {+                sizes: Mutex::new(Vec::new()),+                nested: Mutex::new(None),+                close_inside: Mutex::new(None),+                frame: Mutex::new(None),+                answers: Mutex::new(Vec::new()),+            }+        }+    }++    impl Class for SizedView {+        type Interfaces = (sb::IPlugView,);+    }++    impl IPlugViewTrait for SizedView {+        unsafe fn isPlatformTypeSupported(&self, _t: sb::FIDString) -> sb::tresult {+            sb::kResultTrue+        }+        unsafe fn attached(&self, _p: *mut c_void, _t: sb::FIDString) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn removed(&self) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn onWheel(&self, _d: f32) -> sb::tresult {+            sb::kResultFalse+        }+        unsafe fn onKeyDown(&self, _k: sb::char16, _c: i16, _m: i16) -> sb::tresult {+            sb::kResultFalse+        }+        unsafe fn onKeyUp(&self, _k: sb::char16, _c: i16, _m: i16) -> sb::tresult {+            sb::kResultFalse+        }+        unsafe fn getSize(&self, _s: *mut sb::ViewRect) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn onSize(&self, s: *mut sb::ViewRect) -> sb::tresult {+            let r = unsafe { &*s };+            self.sizes.lock().unwrap().push((r.right - r.left, r.bottom - r.top));+            if let Some(sizer) = self.nested.lock().unwrap().clone() {+                sizer.user_resized(999.0, 999.0, |_, _| panic!("a nested notification is not negotiated"));+            }+            if let Some(sizer) = self.close_inside.lock().unwrap().take() {+                sizer.set_view(None, false);+            }+            let frame = self.frame.lock().unwrap().clone();+            if let Some((f, me)) = frame {+                let mut r = sb::ViewRect { left: 0, top: 0, right: 333, bottom: 222 };+                let answer = unsafe { f.resizeView(me as *mut sb::IPlugView, &mut r) };+                self.answers.lock().unwrap().push(answer);+            }+            sb::kResultOk+        }+        unsafe fn onFocus(&self, _s: sb::TBool) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn setFrame(&self, _f: *mut sb::IPlugFrame) -> sb::tresult {+            sb::kResultOk+        }+        unsafe fn canResize(&self) -> sb::tresult {+            sb::kResultTrue+        }+        unsafe fn checkSizeConstraint(&self, r: *mut sb::ViewRect) -> sb::tresult {+            let r = unsafe { &mut *r };+            r.right = r.left + (r.right - r.left).clamp(200, 800);+            r.bottom = r.top + (r.bottom - r.top).clamp(100, 600);+            sb::kResultTrue+        }+    }++    #[test]+    fn user_resizes_are_negotiated_with_the_view_without_loops() {+        let fake = ComWrapper::new(SizedView::new());+        let view = fake.to_com_ptr::<sb::IPlugView>().unwrap();+        let sizer = Arc::new(ViewSizer::new(320.0, 240.0));+        // Not resizable (yet): nothing is negotiated and the view isn't told.+        sizer.set_view(Some(view.clone()), false);+        assert_eq!(sizer.constrain(500.0, 400.0), None);+        sizer.user_resized(500.0, 400.0, |_, _| panic!("not resizable"));+        assert!(fake.sizes.lock().unwrap().is_empty());++        sizer.set_view(Some(view), true);+        assert_eq!(sizer.constrain(500.0, 400.0), Some((500.0, 400.0)));+        assert_eq!(sizer.constrain(50.0, 5000.0), Some((200.0, 600.0)), "checkSizeConstraint adjusts the drag");+        // A size the view refuses: the window is set back to the constrained size, then `onSize`.+        let mut set = None;+        sizer.user_resized(1000.0, 50.0, |w, h| set = Some((w, h)));+        assert_eq!(set, Some((800.0, 100.0)));+        assert_eq!(*fake.sizes.lock().unwrap(), vec![(800, 100)]);+        // An accepted size leaves the window alone; the same size again is not repeated.+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        sizer.user_resized(640.0, 480.0, |_, _| panic!("accepted as is"));+        assert_eq!(*fake.sizes.lock().unwrap(), vec![(800, 100), (640, 480)]);++        // Notifications caused by a resize the host applies (a plugin `resizeView`) are ignored…+        {+            let _applying = sizer.applying();+            assert_eq!(sizer.constrain(300.0, 300.0), None);+            sizer.user_resized(300.0, 300.0, |_, _| panic!("ignored while applying"));+            sizer.set_last(300.0, 300.0);+        }+        assert_eq!(fake.sizes.lock().unwrap().len(), 2);+        // …and the plugin's new size is remembered: the window notification for it is a no-op.+        sizer.user_resized(300.0, 300.0, |_, _| panic!("accepted as is"));+        assert_eq!(fake.sizes.lock().unwrap().len(), 2);++        // A view that resizes itself from `onSize` doesn't start a loop (or deadlock).+        *fake.nested.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(400.0, 400.0, |_, _| panic!("accepted as is"));+        assert_eq!(fake.sizes.lock().unwrap().last(), Some(&(400, 400)));+        assert_eq!(fake.sizes.lock().unwrap().len(), 3, "the nested notification was not negotiated");+        *fake.nested.lock().unwrap() = None;++        // Closing the editor from inside `onSize` (a plugin pumping a nested event loop) neither+        // deadlocks nor leaves the view reachable: the removal is applied when the call returns.+        *fake.close_inside.lock().unwrap() = Some(sizer.clone());+        sizer.user_resized(450.0, 450.0, |_, _| panic!("accepted as is"));+        assert_eq!(fake.sizes.lock().unwrap().len(), 4);+        assert!(!sizer.resizable());+        assert!(sizer.view.lock().unwrap().is_none(), "removed once onSize returned");+        assert!(sizer.attached().is_null());+        sizer.set_view(fake.to_com_ptr::<sb::IPlugView>(), true);++        // Once the view is taken away (editor closing), nothing reaches it any more.+        assert!(sizer.take_view().is_some());+        assert_eq!(sizer.constrain(500.0, 500.0), None);+        sizer.user_resized(500.0, 500.0, |_, _| panic!("no view"));+        assert_eq!(fake.sizes.lock().unwrap().len(), 4);+    }++    #[test]+    fn plugin_resizes_come_from_the_attached_view_and_do_not_recurse() {+        let fake = ComWrapper::new(SizedView::new());+        let view = fake.to_com_ptr::<sb::IPlugView>().unwrap();+        let other = ComWrapper::new(SizedView::new());+        let other = other.to_com_ptr::<sb::IPlugView>().unwrap();+        let sizer = Arc::new(ViewSizer::new(320.0, 240.0));+        // No window (null): only the negotiation runs.+        let frame = ComWrapper::new(PlugFrame { window: std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()), sizer: sizer.clone() });+        let f = frame.to_com_ptr::<sb::IPlugFrame>().unwrap();+        let mut r = sb::ViewRect { left: 0, top: 0, right: 500, bottom: 400 };+        // SAFETY: calls through our own implementation with valid pointers (or null, handled).+        unsafe {+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kInvalidArgument, "nothing attached yet");+            sizer.expect_view(&view);+            assert_eq!(f.resizeView(other.as_ptr(), &mut r), sb::kInvalidArgument, "not the attached view");+            assert_eq!(f.resizeView(std::ptr::null_mut(), &mut r), sb::kInvalidArgument);+            assert_eq!(f.resizeView(view.as_ptr(), std::ptr::null_mut()), sb::kInvalidArgument);+            // The attached view resizes; asking again from inside its `onSize` is refused instead+            // of recursing.+            *fake.frame.lock().unwrap() = Some((f.clone(), view.as_ptr() as usize));+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kResultOk);+            assert_eq!(*fake.sizes.lock().unwrap(), vec![(500, 400)]);+            assert_eq!(*fake.answers.lock().unwrap(), vec![sb::kResultFalse]);+            assert_eq!(sizer.last(), (500.0, 400.0));+            *fake.frame.lock().unwrap() = None;+            // The guard is released: the next request goes through.+            let mut r = sb::ViewRect { left: 0, top: 0, right: 640, bottom: 480 };+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kResultOk);+            assert_eq!(sizer.last(), (640.0, 480.0));+            // Detached: refused again.+            sizer.set_view(None, false);+            assert_eq!(f.resizeView(view.as_ptr(), &mut r), sb::kInvalidArgument);+        }+    }++    #[test]+    fn sizing_moves_only_the_dragged_edges() {+        let r = (100, 100, 500, 400);+        assert_eq!(sizing_rect(8, r, (300, 200)), (100, 100, 400, 300), "bottom-right");+        assert_eq!(sizing_rect(4, r, (300, 200)), (200, 200, 500, 400), "top-left");+        assert_eq!(sizing_rect(1, r, (300, 300)), (200, 100, 500, 400), "left (height follows the constraint downwards)");+        assert_eq!(sizing_rect(5, r, (300, 200)), (100, 200, 400, 400), "top-right");+        assert_eq!(sizing_rect(7, r, (300, 200)), (200, 100, 500, 300), "bottom-left");+        assert_eq!(sizing_rect(2, (i32::MAX - 1, 0, i32::MAX, 10), (100, 10)), (i32::MAX - 1, 0, i32::MAX, 10), "saturates");+    }+     #[test]     fn the_transport_fills_the_process_context() {         use sv::ProcessContext_::StatesAndFlags_::*;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@@ -27,7 +27,15 @@ //! 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`); //! 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).+//! closing it hides it until the link tears it down). When the view `canResize`, the window gets+//! a resize handle: user resizes are constrained live by `checkSizeConstraint` (the window+//! delegate's `windowWillResize:toSize:` / `WM_SIZING`) and applied with `onSize` once the window+//! has its new size (`windowDidResize:` / `WM_SIZE`); the zoom button zooms to a constrained+//! size. Window notifications caused by the host's own resizes (a plugin's `resizeView`, or+//! snapping the window to a constrained size) are not negotiated again, and like Steinberg's+//! editorhost, `resizeView` is accepted only from the attached view and refused (`kResultFalse`)+//! while another one is in progress, so neither side can make resizes recurse. Content scaling+//! (`IPlugViewContentScaleSupport`) is not hosted yet. //! [`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 editordiff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs--- a/crates/vst3-host/src/plugin.rs+++ b/crates/vst3-host/src/plugin.rs@@ -149,6 +149,16 @@ impl Vst3Plugin {         self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe()     } +    /// How the editor would take a user resize of its window to `w`×`h`, without opening it:+    /// `None` when it can't be resized (`IPlugView::canResize`), else the size it constrains the+    /// request to (`checkSizeConstraint`), as the host window negotiates it. For diagnostics and+    /// tests; real plugins expect this on the main thread. The view is not attached, so a plugin+    /// that only knows its constraints once attached may accept any size here.+    #[doc(hidden)]+    pub fn editor_resize(&self, w: u32, h: u32) -> Result<Option<(u32, u32)>, String> {+        self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe_resize(w, h)+    }+     /// Re-reads every parameter value from the controller (after a state load) and drops     /// changes still queued from before it.     fn refresh_values(&mut self) {diff --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@@ -15,6 +15,9 @@ fn main() {     } else {         assert!(p.editor_size().is_err());     }+    // A resizable view: user resizes of the host window are negotiated with the view.+    assert_eq!(p.editor_resize(640, 480), Ok(Some((640, 480))), "a size the view accepts stands");+    assert_eq!(p.editor_resize(50, 2000), Ok(Some((200, 900))), "checkSizeConstraint clamps the request");     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");diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs--- a/crates/vst3-host/tests/fixture/src/lib.rs+++ b/crates/vst3-host/tests/fixture/src/lib.rs@@ -6,7 +6,8 @@ //!   9 hidden, 11 bypass. Starts at gain 0.5 (its state) although the declared default is 1.0. //!   Reports 3 samples of latency. State: the component stores the gain (one LE f64), the //!   controller the Freq value (one LE f64). Its controller has an editor view (`IPlugView`,-//!   320×240, no real drawing) supporting NSView/HWND/X11.+//!   320×240, no real drawing) supporting NSView/HWND/X11, resizable between 200×150 and+//!   1200×900 (`canResize`, `checkSizeConstraint` clamps into that range). //! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also //!   its edit controller). No audio input, one stereo output, one event input; every output //!   sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and@@ -266,10 +267,14 @@ impl IPlugViewTrait for View {         kResultOk     }     unsafe fn canResize(&self) -> tresult {-        kResultFalse+        kResultTrue     }-    unsafe fn checkSizeConstraint(&self, _r: *mut ViewRect) -> tresult {-        kResultOk+    /// Resizable between 200×150 and 1200×900 (a proposal outside is clamped into that range).+    unsafe fn checkSizeConstraint(&self, r: *mut ViewRect) -> tresult {+        let Some(r) = (unsafe { r.as_mut() }) else { return kInvalidArgument };+        r.right = r.left + (r.right - r.left).clamp(200, 1200);+        r.bottom = r.top + (r.bottom - r.top).clamp(150, 900);+        kResultTrue     } } 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@@ -334,4 +334,5 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() {     // The synth's controller has no view.     let synth = soundcraft_vst3_host::instantiate_plugin(&id_of("Fixture Synth")).unwrap();     assert!(synth.editor_size().is_err());+    assert!(synth.editor_resize(640, 480).is_err()); }diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-05: embedded CLAP editors on macOS; previously trivial: G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**@@ -114,8 +114,8 @@ 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.+  (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). VST3 editors+  on Linux. - **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it).  ### G-06. Memory-bound media@@ -221,6 +221,7 @@ missing. Out of scope unless the owner decides otherwise.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | trivial | G-05: embedded CLAP editors are hosted on macOS (#485); VST3 editors already open on Windows | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |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-23 · **Change:** 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-24 · **Change:** minor (embedded CLAP editors on macOS; previously minor: resizable plugin editor windows; 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@@ -44,8 +44,8 @@ User report #152: a saved instrument level does not reach the bounce. | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | | 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: CLAP | — | 🟡 floating windows; embedded editors on macOS (Cocoa parent view in a SoundCraft window; fixture-tested with a real window, not yet run with a commercial plugin) and Windows (unit-tested, not yet run on Windows), resizable when the plugin `can_resize`: `adjust_size`/`set_size` | Embedded on Linux (X11) missing |+| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor); resizable views (`canResize`) get a resizable window, user resizes negotiated through `checkSizeConstraint`/`onSize` (fixture-tested; not yet resized by hand with a commercial plugin) | Linux views missing; content scaling (`IPlugViewContentScaleSupport`) missing | | 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 |@@ -58,6 +58,8 @@ User report #152: a saved instrument level does not reach the bounce.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Editors: embedded-only CLAP editors open in a SoundCraft window on macOS (#485), resizable like the Windows ones |+| 2026-10-24 | minor | Editors: resizable VST3 editor windows (macOS, Windows) and embedded CLAP editor windows (Windows) follow the plugin's size constraints (fixture- and unit-tested) | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |