From 30975dcf047e44f886740f8bf85c3055872ef276 Mon Sep 17 00:00:00 2001 From: lml Date: Thu, 24 Sep 2026 01:19:19 +0800 Subject: [PATCH 1/2] fix(desktop): cover the window frame strips with the WebView The undecorated main window keeps the Windows resizable frame so that resizing, Aero Snap and the system move/size gestures stay available, but tao sizes the WebView to the client rectangle and leaves the window class without a background brush. The strips between the client rectangle and the window rectangle therefore stay unpainted on the left, right and bottom edge, and a transparent window composites them as bare window backdrop: the frame reads as a drop shadow around the window, while the top edge has no such strip. Collapse the non-client area so the client rectangle covers the window rectangle instead. Resizing, snapping and the move/size gestures are driven by the window rectangle, so the native interactions stay intact while the WebView paints the window edge-to-edge. Maximized windows keep the default handling: Windows inflates their window rectangle beyond the work area, which already moves the strips off-screen. Measured before the change on Windows 10 19045 at 150% scaling: window rect 289,188-2741,1863 against DWM extended frame bounds 299,188-2731,1853 (10px inset on the left/right/bottom, 0px on the top), and the strips sampled the desktop instead of app content. --- src/apps/desktop/src/appearance.rs | 4 ++ src/apps/desktop/src/lib.rs | 2 + src/apps/desktop/src/window_shell.rs | 99 ++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/apps/desktop/src/window_shell.rs diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 443c2e2d7d..41f611ef3d 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -672,6 +672,10 @@ pub fn create_main_window( if let Err(error) = crate::window_webview_geometry::install(&window) { error!("Failed to install main WebView geometry protection: {error}"); } + #[cfg(target_os = "windows")] + if let Err(error) = crate::window_shell::install_frame_handling(&window) { + error!("Failed to install main window frame handling: {error}"); + } let reapply_maximized = crate::restore_main_window_state(&window); crate::webview_recovery::install(&window); startup_trace.record_elapsed_step("native_window", "webview_build", build_started_at); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index dbe27c0e63..3c8c96bda4 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -35,6 +35,8 @@ pub mod sleep_prevention; pub mod startup_trace; pub mod tray; mod webview_recovery; +#[cfg(target_os = "windows")] +mod window_shell; mod window_state_support; #[cfg(target_os = "windows")] mod window_webview_geometry; diff --git a/src/apps/desktop/src/window_shell.rs b/src/apps/desktop/src/window_shell.rs new file mode 100644 index 0000000000..7423dc0109 --- /dev/null +++ b/src/apps/desktop/src/window_shell.rs @@ -0,0 +1,99 @@ +//! Windows shell integration for the main window. +//! +//! Two window details that the native non-client area owns are settled here: +//! the frame strips the undecorated window leaves unpainted, and the backdrop +//! capability that decides whether the native sidebar material can be hosted +//! by the compositor at all. + +use windows::Win32::Foundation::{GetLastError, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::UI::Shell::{DefSubclassProc, SetWindowSubclass, SUBCLASSPROC}; +use windows::Win32::UI::WindowsAndMessaging::{ + IsZoomed, SetWindowPos, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOOWNERZORDER, + SWP_NOSIZE, SWP_NOZORDER, WM_NCCALCSIZE, +}; + +/// Identifies the main window's client/frame subclass. +const FRAME_SUBCLASS_ID: usize = 0x4f42_4653; // "OBFS" + +/// Windows 11 22H2 is the first release that can host the window backdrop in +/// the compositor (`DWMWA_SYSTEMBACKDROP_TYPE`). +const WINDOWS_BACKDROP_MIN_BUILD: u32 = 22621; + +/// The undecorated main window keeps its resizable frame so that Windows still +/// provides the resize border, Aero Snap and the system move/size gestures. +/// tao sizes the WebView to the client rectangle and leaves the window class +/// without a background brush, so the strips between the client rectangle and +/// the window rectangle stay unpainted. Those strips sit on the left, right and +/// bottom edges (the top edge has none), and a transparent window composites +/// them as bare window backdrop, which reads as a drop shadow around the window. +/// +/// Widen the client rectangle over the strips instead. Resizing, snapping and +/// the move/size gestures are all driven by the window rectangle, so removing +/// the non-client area leaves every native interaction intact while the WebView +/// paints the window edge-to-edge. Maximized windows keep the default handling: +/// Windows inflates their window rectangle beyond the work area, which already +/// moves the strips off-screen. +pub(crate) fn install_frame_handling(window: &tauri::WebviewWindow) -> Result<(), String> { + let hwnd = window.hwnd().map_err(|error| error.to_string())?; + let subclass: SUBCLASSPROC = Some(frame_subclass_proc); + let installed = unsafe { SetWindowSubclass(hwnd, subclass, FRAME_SUBCLASS_ID, 0) }; + if !installed.as_bool() { + return Err(format!( + "SetWindowSubclass failed for the main window: 0x{:08X}", + unsafe { GetLastError() }.0 + )); + } + // The subclass applies from the next frame recalculation onwards. + unsafe { + SetWindowPos( + hwnd, + None, + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED + | SWP_NOMOVE + | SWP_NOSIZE + | SWP_NOZORDER + | SWP_NOOWNERZORDER + | SWP_NOACTIVATE, + ) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +/// Whether the compositor can host the window backdrop on this build. +/// +/// Windows 10 has no `DWMWA_SYSTEMBACKDROP_TYPE`, so the window material falls +/// back to a live blur-behind that re-blurs everything behind the window on +/// every move. Dragging the window then drops frames, which is why the native +/// sidebar material is only requested where the backdrop is compositor-owned. +pub(crate) fn window_backdrop_available() -> bool { + use windows::Win32::System::SystemInformation::{GetVersionExW, OSVERSIONINFOW}; + + let mut version = OSVERSIONINFOW { + dwOSVersionInfoSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { GetVersionExW(&mut version) }.is_ok() + && version.dwBuildNumber >= WINDOWS_BACKDROP_MIN_BUILD +} + +unsafe extern "system" fn frame_subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + _ref_data: usize, +) -> LRESULT { + // A non-zero `wparam` marks the `NCCALCSIZE_PARAMS` form of the message, + // whose `rgrc[0]` already holds the proposed window rectangle. Returning + // zero adopts that rectangle unchanged, which is what removes the strips. + if msg == WM_NCCALCSIZE && wparam.0 != 0 && !IsZoomed(hwnd).as_bool() { + return LRESULT(0); + } + DefSubclassProc(hwnd, msg, wparam, lparam) +} From 5f39561acf937d67eeddd6a5a9b1c26c3b5aa3f5 Mon Sep 17 00:00:00 2001 From: lml Date: Thu, 24 Sep 2026 01:19:41 +0800 Subject: [PATCH 2/2] fix(desktop): request the sidebar material only where the compositor hosts it Windows 10 has no DWMWA_SYSTEMBACKDROP_TYPE, so the window material falls back to a live blur-behind that re-blurs everything behind the window on every move. Dragging the window then visibly drops frames (issue 3128). macOS vibrancy and the Windows 11 Mica/acrylic backdrops are compositor-owned and move smoothly. Gate both the native material and the frontend's transparent sidebar surfaces on that capability, so Windows 10 keeps the opaque fallback surfaces that the frontend already implements for reduced transparency and high contrast. --- src/apps/desktop/src/appearance.rs | 43 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 41f611ef3d..e090751248 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -318,7 +318,7 @@ impl AppearanceConfig { let startup_locale_json = serde_json::to_string(&startup_locale).unwrap_or_else(|_| "\"zh-CN\"".to_string()); let show_startup_window_controls = !cfg!(target_os = "macos"); - let native_sidebar_material = cfg!(any(target_os = "windows", target_os = "macos")); + let native_sidebar_material = native_sidebar_material_available(); let startup_trace_id_json = serde_json::to_string(startup_trace_id) .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); let bootstrap_log_level_json = serde_json::to_string(crate::logging::level_to_str( @@ -510,6 +510,28 @@ fn use_development_frontend() -> bool { } } +/// Whether the window-level sidebar material can be hosted by the compositor. +/// +/// macOS vibrancy and the Windows 11 Mica/acrylic backdrops are owned by the +/// compositor. Older Windows builds have to fall back to a live blur-behind, +/// which re-blurs everything behind the window on every move and makes dragging +/// the window stutter, so those builds keep the frontend fallback surfaces +/// (opaque theme colors plus a CSS backdrop) instead. +fn native_sidebar_material_available() -> bool { + #[cfg(target_os = "macos")] + { + true + } + #[cfg(target_os = "windows")] + { + crate::window_shell::window_backdrop_available() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + false + } +} + pub fn create_main_window( app_handle: &tauri::AppHandle, startup_trace_id: &str, @@ -619,19 +641,22 @@ pub fn create_main_window( // The webview must be transparent for the OS material to reach the sidebar. // Scene backgrounds and the startup tint remain owned by the frontend. + // Requesting the material where the compositor cannot host it would leave + // the window with a live blur-behind that drops frames while the window is + // dragged, so those builds keep the opaque fallback surfaces instead. #[cfg(any(target_os = "windows", target_os = "macos"))] { + let mut effects = tauri::window::EffectsBuilder::new(); + if native_sidebar_material_available() { + effects = effects.effects([ + tauri::window::Effect::Acrylic, + tauri::window::Effect::Sidebar, + ]); + } builder = builder .transparent(true) .background_color(tauri::window::Color(0, 0, 0, 0)) - .effects( - tauri::window::EffectsBuilder::new() - .effects([ - tauri::window::Effect::Acrylic, - tauri::window::Effect::Sidebar, - ]) - .build(), - ); + .effects(effects.build()); } #[cfg(debug_assertions)]