diff --git a/Cargo.lock b/Cargo.lock index b70be614e2..dbcaed04a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6263,6 +6263,7 @@ dependencies = [ "win32job", "windows 0.61.3", "windows-core 0.61.2", + "windows-version", "wry", "xz2", "zip 4.6.1", diff --git a/Cargo.toml b/Cargo.toml index 357d59464b..a923edc6ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -256,6 +256,7 @@ tempfile = "3" webview2-com = "0.38" windows = "0.61" windows-core = "0.61" +windows-version = "0.1" glib = "0.18" gtk = "0.18" webkit2gtk = "2.0" diff --git a/scripts/fixtures/windows-window-material.rs b/scripts/fixtures/windows-window-material.rs new file mode 100644 index 0000000000..2dfd39b9d2 --- /dev/null +++ b/scripts/fixtures/windows-window-material.rs @@ -0,0 +1,312 @@ +// Run with scripts/test-windows-window-material.mjs. This creates no product +// state and never shows the test windows. Hit testing is performed by the real +// production Tao/Tauri window procedures, not a fixture copy of their algorithm. +#[cfg(target_os = "windows")] +mod native { + use std::error::Error; + use std::mem::size_of; + use std::sync::mpsc; + use std::time::Duration; + + use tauri::{WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2Controller2, COREWEBVIEW2_COLOR, + }; + use windows::core::Interface; + use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; + use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_SYSTEMBACKDROP_TYPE}; + use windows::Win32::Graphics::Gdi::ClientToScreen; + use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}; + use windows::Win32::UI::WindowsAndMessaging::{ + GetClientRect, GetWindowLongPtrW, GetWindowRect, IsWindowVisible, SendMessageW, + SetWindowPos, GWL_STYLE, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT, HTRIGHT, HTTOP, + HTTOPLEFT, HTTOPRIGHT, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_NOZORDER, WM_NCCALCSIZE, WM_NCHITTEST, WS_SIZEBOX, + }; + + type Result = std::result::Result>; + + fn window_rect(hwnd: HWND) -> Result { + let mut rect = RECT::default(); + unsafe { GetWindowRect(hwnd, &mut rect)? }; + Ok(rect) + } + + fn verify_frame(hwnd: HWND) -> Result { + let outer = window_rect(hwnd)?; + let mut client = RECT::default(); + unsafe { GetClientRect(hwnd, &mut client)? }; + let mut origin = POINT::default(); + assert!(unsafe { ClientToScreen(hwnd, &mut origin) }.as_bool()); + let insets = [ + origin.x - outer.left, + origin.y - outer.top, + outer.right - origin.x - client.right, + outer.bottom - origin.y - client.bottom, + ]; + assert!( + insets[0] > 0 && insets[2] > 0 && insets[3] > 0, + "Native side/bottom frame must remain outside the client area: {insets:?}" + ); + assert_ne!( + unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32 & WS_SIZEBOX.0, + 0, + "Native window must retain its resizable frame style" + ); + println!("PASS native resize frame insets: {insets:?}"); + Ok(()) + } + + fn verify_hit_testing(hwnd: HWND) -> Result { + let rect = window_rect(hwnd)?; + let middle_x = rect.left + (rect.right - rect.left) / 2; + let middle_y = rect.top + (rect.bottom - rect.top) / 2; + let left = rect.left + 1; + let right = rect.right - 2; + let top = rect.top + 1; + let bottom = rect.bottom - 2; + for (name, x, y, expected) in [ + ("left", left, middle_y, HTLEFT), + ("right", right, middle_y, HTRIGHT), + ("top", middle_x, top, HTTOP), + ("bottom", middle_x, bottom, HTBOTTOM), + ("top-left", left, top, HTTOPLEFT), + ("top-right", right, top, HTTOPRIGHT), + ("bottom-left", left, bottom, HTBOTTOMLEFT), + ("bottom-right", right, bottom, HTBOTTOMRIGHT), + ] { + // WM_NCHITTEST packs signed screen coordinates into two 16-bit words. + let coordinates = LPARAM(((x as u16 as u32) | ((y as u16 as u32) << 16)) as isize); + let actual = unsafe { SendMessageW(hwnd, WM_NCHITTEST, None, Some(coordinates)) }.0; + assert_eq!(actual, expected as isize, "Native resize hit test: {name}"); + } + println!("PASS native hit testing on all four edges and all four corners"); + Ok(()) + } + + fn verify_size_change(hwnd: HWND) -> Result { + let original = window_rect(hwnd)?; + let width = original.right - original.left; + let height = original.bottom - original.top; + unsafe { + SetWindowPos( + hwnd, + None, + 0, + 0, + width + 73, + height + 41, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + )?; + } + let resized = window_rect(hwnd)?; + assert_eq!(resized.right - resized.left, width + 73); + assert_eq!(resized.bottom - resized.top, height + 41); + verify_frame(hwnd)?; + verify_hit_testing(hwnd)?; + unsafe { + SetWindowPos( + hwnd, + None, + 0, + 0, + width, + height, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + )?; + } + let restored = window_rect(hwnd)?; + assert_eq!(restored.right - restored.left, width); + assert_eq!(restored.bottom - restored.top, height); + assert!(!unsafe { IsWindowVisible(hwnd) }.as_bool()); + println!("PASS physical native resize and size restoration while hidden"); + Ok(()) + } + + fn verify_backdrop(hwnd: HWND, native_material: bool) -> Result { + let mut backdrop: i32 = -1; + let result = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &mut backdrop as *mut _ as _, + size_of::() as u32, + ) + }; + match result { + Ok(()) if native_material => { + assert_eq!(backdrop, 3, "Native Acrylic uses DWMSBT_TRANSIENTWINDOW"); + println!("PASS compositor-owned Acrylic backdrop"); + } + Ok(()) => { + assert!( + backdrop == 0 || backdrop == 1, + "Opaque fallback must not request a system material: {backdrop}" + ); + println!("PASS opaque fallback has no explicit system backdrop"); + } + Err(_) if !crate::window_material::native_sidebar_material_available() => { + println!("PASS fallback on Windows without system backdrop support"); + } + Err(error) => return Err(error.into()), + } + Ok(()) + } + + fn verify_webview_background(window: &WebviewWindow, native_material: bool) -> Result { + let (sender, receiver) = mpsc::channel(); + window.with_webview(move |webview| { + let color = (|| -> windows::core::Result<_> { + let controller: ICoreWebView2Controller2 = webview.controller().cast()?; + let mut color = COREWEBVIEW2_COLOR::default(); + unsafe { controller.DefaultBackgroundColor(&mut color)? }; + Ok((color.A, color.R, color.G, color.B)) + })(); + let _ = sender.send(color); + })?; + let actual = receiver.recv_timeout(Duration::from_secs(5))??; + let expected = if native_material { + (0, 0, 0, 0) + } else { + (255, 24, 26, 28) + }; + assert_eq!( + actual, expected, + "Production WebView background opacity and color" + ); + println!("PASS actual WebView2 background: {actual:?}"); + Ok(()) + } + + // Negative control reproduces PR #3217's frame collapse, demonstrating that + // the actual HWND assertion detects the regression. It is removed immediately. + unsafe extern "system" fn collapse_frame( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + _data: usize, + ) -> LRESULT { + if message == WM_NCCALCSIZE && wparam.0 != 0 { + LRESULT(0) + } else { + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + } + + fn verify_regression_control(hwnd: HWND) -> Result { + const SUBCLASS_ID: usize = 0x4f42_4654; + unsafe { + assert!(SetWindowSubclass(hwnd, Some(collapse_frame), SUBCLASS_ID, 0).as_bool()); + SetWindowPos( + hwnd, + None, + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + )?; + } + let outer = window_rect(hwnd)?; + let mut client = RECT::default(); + unsafe { GetClientRect(hwnd, &mut client)? }; + assert_eq!( + client.right, + outer.right - outer.left, + "Negative control must expose the collapsed horizontal frame" + ); + assert_eq!( + client.bottom, + outer.bottom - outer.top, + "Negative control must expose the collapsed vertical frame" + ); + unsafe { + assert!(RemoveWindowSubclass(hwnd, Some(collapse_frame), SUBCLASS_ID).as_bool()); + SetWindowPos( + hwnd, + None, + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, + )?; + } + verify_frame(hwnd)?; + verify_hit_testing(hwnd)?; + println!("PASS regression control distinguishes collapsed and native frames"); + Ok(()) + } + + pub fn run() -> Result { + let storage = tempfile::tempdir()?; + let app = tauri::Builder::default().build(tauri::generate_context!())?; + let native_available = crate::window_material::native_sidebar_material_available(); + println!( + "Windows build {}: native material available={native_available}", + windows_version::OsVersion::current().build + ); + let mut windows: Vec = Vec::new(); + for native_material in [false, true] { + if native_material && !native_available { + println!("SKIP native material: this Windows build uses the opaque fallback"); + continue; + } + let label = if native_material { + "native-material" + } else { + "opaque-fallback" + }; + let builder = WebviewWindowBuilder::new( + &app, + label, + WebviewUrl::External("about:blank".parse()?), + ) + .title("OpenBitFun hidden window material fixture") + .inner_size(640.0, 480.0) + .position(80.0, 80.0) + .resizable(true) + .visible(false) + .skip_taskbar(true) + .incognito(true) + .data_directory(storage.path().join(label)); + let window = crate::window_material::configure_main_window( + builder, + native_material, + tauri::window::Color(24, 26, 28, 255), + ) + .build()?; + let hwnd = window.hwnd()?; + assert!(!unsafe { IsWindowVisible(hwnd) }.as_bool()); + verify_frame(hwnd)?; + verify_hit_testing(hwnd)?; + verify_backdrop(hwnd, native_material)?; + verify_webview_background(&window, native_material)?; + verify_size_change(hwnd)?; + verify_regression_control(hwnd)?; + println!("PASS production main-window material mode: {label}"); + windows.push(window); + } + for window in windows { + window.destroy()?; + } + // Destroy is dispatched through the real event loop, even on the main + // thread. Let all native Destroyed events release their WebViews before + // the last-window exit completes and temporary storage is removed. + assert_eq!(app.run_return(|_, _| {}), 0); + println!("Native checks do not measure drag frame rate or rendered shadow appearance."); + Ok(()) + } +} + +#[cfg(target_os = "windows")] +fn main() -> std::result::Result<(), Box> { + native::run() +} + +#[cfg(not(target_os = "windows"))] +fn main() { + panic!("This native fixture must be built for and executed on Windows"); +} diff --git a/scripts/test-windows-window-material.mjs b/scripts/test-windows-window-material.mjs new file mode 100644 index 0000000000..6207a3d5c2 --- /dev/null +++ b/scripts/test-windows-window-material.mjs @@ -0,0 +1,131 @@ +// Exercise the production window material builder with real, hidden Windows +// WebViews. --check only checks types; --build-only leaves the executable in +// CARGO_TARGET_DIR for execution on a separate Windows host. +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const args = process.argv.slice(2); +const takeFlag = flag => { + const index = args.indexOf(flag); + if (index < 0) return false; + args.splice(index, 1); + return true; +}; +const check = takeFlag('--check'); +const buildOnly = takeFlag('--build-only'); +const xwin = takeFlag('--xwin'); +if (check && buildOnly) throw new Error('Choose --check or --build-only.'); +if (process.platform !== 'win32' && !check && !buildOnly) { + throw new Error('Native verification requires Windows. Use --check --target x86_64-pc-windows-msvc or --build-only --xwin --target x86_64-pc-windows-msvc.'); +} +const directory = await mkdtemp(join(tmpdir(), 'openbitfun-window-material-')); +const targetDirectory = resolve(process.env.CARGO_TARGET_DIR ?? join(root, 'target/windows-window-material-native')); +const source = path => JSON.stringify(resolve(root, path)); +const run = cargoArgs => new Promise((resolveExit, reject) => { + const child = spawn('cargo', cargoArgs, { + cwd: directory, + stdio: 'inherit', + windowsHide: true, + env: { ...process.env, CARGO_TARGET_DIR: targetDirectory }, + }); + child.once('error', reject); + child.once('close', code => resolveExit(code ?? 1)); +}); + +try { + const [workspace, lock, fixture] = await Promise.all([ + readFile(join(root, 'Cargo.toml'), 'utf8'), + readFile(join(root, 'Cargo.lock'), 'utf8'), + readFile(join(root, 'scripts/fixtures/windows-window-material.rs'), 'utf8'), + ]); + const version = (name, prefix = '') => { + const packages = lock.split('[[package]]').map(section => section.match(/\r?\nname = "([^"]+)"\r?\nversion = "([^"]+)"/)); + const found = packages.find(entry => entry?.[1] === name && entry[2].startsWith(prefix)); + if (!found) throw new Error(`Missing locked dependency: ${name} ${prefix}`); + return found[2]; + }; + const runtimePatches = ['tauri-runtime', 'tauri-runtime-wry', 'tauri-utils'].map(name => { + const escapedName = name.replaceAll('-', '\\-'); + const entry = workspace.match(new RegExp(`^${escapedName} = \\{ git = "([^"\\n]+)", rev = "([^"\\n]+)" \\}`, 'm')); + if (!entry) throw new Error(`Missing production Tauri patch: ${name}`); + return `${name} = { git = ${JSON.stringify(entry[1])}, rev = ${JSON.stringify(entry[2])} }`; + }); + await mkdir(join(directory, 'src')); + await mkdir(join(directory, 'assets')); + await writeFile(join(directory, 'Cargo.toml'), `[package] +name = "openbitfun-window-material-fixture" +version = "0.0.0" +edition = "2021" +[workspace] +[dependencies] +tauri = { version = "=${version('tauri')}", features = ["unstable"] } +tempfile = "=${version('tempfile')}" +windows-version = "=${version('windows-version')}" +webview2-com = "=${version('webview2-com')}" +windows = { version = "=${version('windows', '0.61.')}", features = [ + "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging" +] } +[build-dependencies] +tauri-build = "=${version('tauri-build')}" +[patch.crates-io] +tao = { path = ${source('third_party/tao')} } +wry = { path = ${source('third_party/wry')} } +${runtimePatches.join('\n')} +[profile.dev] +debug = 0 +incremental = false +`); + await writeFile(join(directory, 'tauri.conf.json'), JSON.stringify({ + productName: 'OpenBitFun Window Material Fixture', + version: '0.0.0', + identifier: 'test.openbitfun.window-material', + build: { frontendDist: 'assets' }, + app: { windows: [] }, + bundle: { active: false, icon: ['icons/icon.ico'] }, + })); + await mkdir(join(directory, 'icons')); + await copyFile(join(root, 'src/apps/desktop/icons/openbitfun-app-icon.ico'), join(directory, 'icons/icon.ico')); + await writeFile(join(directory, 'assets/index.html'), 'Hidden window material fixture'); + await writeFile(join(directory, 'build.rs'), 'fn main() { tauri_build::build(); }\n'); + await copyFile(join(root, 'Cargo.lock'), join(directory, 'Cargo.lock')); + await writeFile(join(directory, 'src/main.rs'), `#[path = ${source('src/apps/desktop/src/window_material.rs')}] +mod window_material; +${fixture}`); + const cargoArgs = command => [ + ...(xwin ? ['xwin'] : []), + command, + '--offline', + '--manifest-path', join(directory, 'Cargo.toml'), + ...args, + ]; + if (check) { + process.exitCode = await run([...cargoArgs('check'), '--tests']); + } else if (buildOnly) { + process.exitCode = await run(cargoArgs('build')); + if (process.exitCode === 0) { + process.exitCode = await run([...cargoArgs('test'), '--no-run']); + } + } else { + process.exitCode = await run(cargoArgs('test')); + if (process.exitCode === 0) process.exitCode = await run(cargoArgs('run')); + } + if (buildOnly && process.exitCode === 0) { + const option = name => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : args.find(argument => argument.startsWith(`${name}=`))?.slice(name.length + 1); + }; + const target = option('--target'); + const profile = option('--profile') ?? (args.includes('--release') ? 'release' : 'debug'); + // Cargo also accepts a JSON target path; its file stem is the output folder. + const targetFolder = target && isAbsolute(target) ? target.split(/[\\/]/).at(-1).replace(/\.json$/, '') : target; + console.log(`Native fixture executable: ${join(targetDirectory, ...(targetFolder ? [targetFolder] : []), profile, 'openbitfun-window-material-fixture.exe')}`); + console.log('Build-only verification does not establish Windows runtime behavior.'); + } +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 6a790a45c1..3b3e5feaf5 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -296,6 +296,17 @@ For the matching startup wiring contract, run For native sidebar material and appearance bootstrap, run `cargo test -p openbitfun-desktop --no-default-features --lib appearance::startup_appearance_tests` and `pnpm --dir src/web-ui run test:run src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts`. +For Windows main-window material, native frame and eight-direction resizing, run +`node scripts/test-windows-window-material.mjs` on Windows. It compiles the +production window configuration with the locked Tauri runtime and local Tao/Wry +adapters, then checks real hidden WebViews in isolated temporary storage. It +covers the opaque fallback, available native material, frame insets, resize hit +testing and window size changes; it does not measure Windows 10 drag frame rate +or exercise remote scenarios. Use `--check --target x86_64-pc-windows-msvc` from +another host for a compile-only check, adding `--xwin` when using cargo-xwin. +Keep the native frame and shadow intact: +expanding the client rectangle over the non-client frame removes the system's +side, bottom and corner resize targets. For embedded browser preview encoding, target correlation and navigation outcomes, run `cargo test -p openbitfun-desktop --lib api::browser_api::tests`. For conversation-owned browser lifetime, build Desktop and Web UI, then run diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 59a1478c8e..258d6d9396 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -129,6 +129,7 @@ windows = { workspace = true, features = [ "Win32_UI_WindowsAndMessaging", ] } windows-core = { workspace = true } +windows-version = { workspace = true } webview2-com = { workspace = true } [target.'cfg(windows)'.dev-dependencies] diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 39b41582f5..30c05a9dad 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -330,13 +330,13 @@ impl AppearanceConfig { startup_trace_id: &str, bootstrap_config: &StartupBootstrapConfig, workspace_startup_state: Option<&serde_json::Value>, + native_sidebar_material: bool, ) -> String { let appearance_mode = if self.is_light { "light" } else { "dark" }; let startup_locale = &bootstrap_config.locale; 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")); // The Linux shell paints the frame radius itself because WebKitGTK keeps // the GTK frame square on Wayland. Advertise that so neither this // bootstrap nor the Web UI paints an opaque page background over the @@ -401,6 +401,8 @@ impl AppearanceConfig { root.setAttribute('data-density', 'compact'); if ({native_sidebar_material}) {{ root.setAttribute('data-openbitfun-native-material', 'sidebar'); + }} else {{ + root.removeAttribute('data-openbitfun-native-material'); }} {native_frame_attribute} @@ -486,7 +488,7 @@ mod startup_appearance_tests { keybindings: None, }; - let script = appearance.generate_init_script("trace-id", &bootstrap, None); + let script = appearance.generate_init_script("trace-id", &bootstrap, None, false); assert!(script.contains("__OPENBITFUN_BOOTSTRAP_APPEARANCE_ID__")); assert!(script.contains("__OPENBITFUN_BOOTSTRAP_APPEARANCE_SELECTION__")); @@ -550,10 +552,12 @@ pub fn create_main_window( let (startup_page_ready, mut startup_page_ready_rx) = tokio::sync::watch::channel(false); let bootstrap_config = AppearanceConfig::load_startup_bootstrap_config(); let appearance = bootstrap_config.appearance.clone(); + let native_sidebar_material = crate::window_material::native_sidebar_material_available(); let init_script = appearance.generate_init_script( startup_trace_id, &bootstrap_config, workspace_startup_state.as_ref(), + native_sidebar_material, ); startup_trace.record_step( "native_step_end", @@ -643,22 +647,11 @@ 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. - #[cfg(any(target_os = "windows", target_os = "macos"))] - { - 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(), - ); - } + builder = crate::window_material::configure_main_window( + builder, + native_sidebar_material, + appearance.to_tauri_color(), + ); // The Linux window is transparent because the shell draws the frame radius // itself: WebKitGTK keeps the GTK frame square on Wayland. @@ -706,11 +699,10 @@ pub fn create_main_window( builder = builder.on_navigation(move |url| navigation_workbench.should_allow_main_navigation(url)); - // Windows and Linux both own their window chrome: the frontend renders the - // window controls and the drag regions, so GTK's header bar would add a - // second title bar above them. tao keeps a hidden CSD header on Wayland so - // the compositor does not paint server-side decorations instead. - #[cfg(any(target_os = "windows", target_os = "linux"))] + // The Linux frontend renders window controls and drag regions, so GTK's + // header bar would add a second title bar. tao keeps a hidden CSD header on + // Wayland so the compositor does not paint server-side decorations instead. + #[cfg(target_os = "linux")] { builder = builder.decorations(false); } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9c9886246c..bdf9efd162 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -47,6 +47,7 @@ pub mod sleep_prevention; pub mod startup_trace; pub mod tray; mod webview_recovery; +mod window_material; mod window_state_support; #[cfg(target_os = "windows")] mod window_webview_geometry; diff --git a/src/apps/desktop/src/window_material.rs b/src/apps/desktop/src/window_material.rs new file mode 100644 index 0000000000..939a1627ba --- /dev/null +++ b/src/apps/desktop/src/window_material.rs @@ -0,0 +1,106 @@ +//! Native main-window material without replacing the platform resize frame. + +#[cfg(any(target_os = "windows", test))] +const WINDOWS_SYSTEM_BACKDROP_MIN_BUILD: u32 = 22621; + +#[cfg(any(target_os = "windows", test))] +const fn windows_native_sidebar_material_available(build: u32) -> bool { + build >= WINDOWS_SYSTEM_BACKDROP_MIN_BUILD +} + +pub(crate) fn native_sidebar_material_available() -> bool { + #[cfg(target_os = "windows")] + { + // RtlGetVersion is independent of application compatibility manifests. + // A failed query leaves the build at zero and selects the opaque path. + windows_native_sidebar_material_available(windows_version::OsVersion::current().build) + } + #[cfg(target_os = "macos")] + { + true + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + false + } +} + +pub(crate) fn configure_main_window<'a, R: tauri::Runtime, M: tauri::Manager>( + builder: tauri::WebviewWindowBuilder<'a, R, M>, + native_sidebar_material: bool, + fallback_color: tauri::window::Color, +) -> tauri::WebviewWindowBuilder<'a, R, M> { + #[cfg(target_os = "windows")] + let builder = { + // Tao owns the native side/bottom frame and corner hit testing, while + // Tauri supplies the top resize strip. Keep both, including the shadow, + // rather than expanding the client rectangle over their resize areas. + builder.decorations(false).shadow(true) + }; + + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + if native_sidebar_material { + 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(), + ) + } else { + // Older Windows builds use live Acrylic blur during dragging. An + // opaque fallback avoids that work and keeps the native edge clear. + builder + .transparent(false) + .background_color(tauri::window::Color( + fallback_color.0, + fallback_color.1, + fallback_color.2, + 255, + )) + } + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = (native_sidebar_material, fallback_color); + builder + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_uses_opaque_fallback_before_compositor_backdrop_support() { + for build in [19045, 22000, 22620] { + assert!(!windows_native_sidebar_material_available(build)); + } + for build in [22621, 22631, 26100] { + assert!(windows_native_sidebar_material_available(build)); + } + } + + #[test] + fn failed_windows_version_query_uses_opaque_fallback() { + assert!(!windows_native_sidebar_material_available(0)); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_keeps_native_sidebar_material() { + assert!(native_sidebar_material_available()); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[test] + fn other_platforms_keep_their_existing_material_owner() { + assert!(!native_sidebar_material_available()); + } +} diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index f679decd23..0f88ce2900 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -24,13 +24,15 @@ function staticImportSpecifiers(source: string): string[] { } describe('startup performance contract', () => { - it('creates native glass with a transparent background instead of repainting it after load', () => { + it('configures native material before window creation instead of repainting it after load', () => { const source = readSource('../../../../apps/desktop/src/appearance.rs'); + const material = readSource('../../../../apps/desktop/src/window_material.rs'); const mainWindow = source.slice(source.indexOf('pub fn create_main_window('), source.indexOf('fn show_main_window_for_startup(')); - expect(mainWindow).toContain('.transparent(true)'); - expect(mainWindow).toContain('.background_color(tauri::window::Color(0, 0, 0, 0))'); - expect(mainWindow).toContain('tauri::window::Effect::Acrylic'); + expect(mainWindow).toContain('crate::window_material::configure_main_window('); + expect(material).toContain('.transparent(true)'); + expect(material).toContain('.background_color(tauri::window::Color(0, 0, 0, 0))'); + expect(material).toContain('tauri::window::Effect::Acrylic'); // The Windows host fill ignores alpha during repaint. Changing its color // after page load can cover the native material even with transparent CSS. expect(mainWindow).not.toContain('.set_background_color('); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts b/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts index f182ae0b2a..4011c050e0 100644 --- a/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts +++ b/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts @@ -97,10 +97,12 @@ describe('theme root background', () => { expect(document.documentElement.getAttribute('data-openbitfun-native-material')).toBe('sidebar'); }); - it('preserves the opaque theme background in browsers and older desktop hosts', async () => { - const settings = builtinAppearancePackages[0].renderers?.['theme-tokens']?.settings; - await themeTokenAppearanceAdapter.apply(settings, undefined, context); - expect(document.documentElement.style.backgroundColor).toBe('var(--openbitfun-color-surface-chrome)'); - expect(document.body.style.backgroundColor).toBe('var(--openbitfun-color-surface-chrome)'); + it('preserves the opaque fallback across theme switches in browsers and older desktop hosts', async () => { + for (const pkg of builtinAppearancePackages) { + await themeTokenAppearanceAdapter.apply(pkg.renderers?.['theme-tokens']?.settings, undefined, context); + expect(document.documentElement.style.backgroundColor).toBe('var(--openbitfun-color-surface-chrome)'); + expect(document.body.style.backgroundColor).toBe('var(--openbitfun-color-surface-chrome)'); + expect(document.documentElement.hasAttribute('data-openbitfun-native-material')).toBe(false); + } }); });