From 71e550752de739d69daa387831e266ae0e20c02a Mon Sep 17 00:00:00 2001 From: David Arutyunyan Date: Mon, 16 Mar 2026 16:25:45 +0300 Subject: [PATCH] fix: add switchable liquid glass mode --- src-tauri/Cargo.lock | 93 ++++++++++++++++++- src-tauri/Cargo.toml | 5 +- src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 32 ++++++- src-tauri/src/panel.rs | 12 ++- src-tauri/src/webkit_config.rs | 52 ++++++++--- src/App.test.tsx | 26 ++++++ src/components/app/app-shell.tsx | 13 +-- src/components/side-nav.tsx | 4 +- .../app/use-settings-display-actions.test.ts | 8 +- src/hooks/app/use-settings-theme.ts | 20 ++++ src/index.css | 86 ++++++++++++----- src/lib/settings.test.ts | 5 + src/lib/settings.ts | 4 +- src/pages/settings.test.tsx | 13 +++ 15 files changed, 315 insertions(+), 59 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ad534e34..3bb3fa02 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -354,6 +354,12 @@ dependencies = [ "wyz", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -655,6 +661,35 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.11.0", + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-foundation 0.10.1", + "core-graphics-types", + "objc", +] + [[package]] name = "combine" version = "4.6.7" @@ -725,6 +760,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -949,6 +997,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -2414,6 +2468,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2657,6 +2720,15 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2994,6 +3066,7 @@ dependencies = [ "tauri-plugin-aptabase", "tauri-plugin-autostart", "tauri-plugin-global-shortcut", + "tauri-plugin-liquid-glass", "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-process", @@ -4677,7 +4750,7 @@ dependencies = [ "bitflags 2.11.0", "block2", "core-foundation 0.10.1", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dispatch2", "dlopen2", @@ -4932,6 +5005,24 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-liquid-glass" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a86fd9cd6fc62cad37daa0d7f8be8f4986543d13dd30e4ff5cd2bc81ff91d0" +dependencies = [ + "cocoa", + "dispatch", + "log", + "objc", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bda0620f..a6fed9cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,9 +39,10 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2.5.1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } regex-lite = "0.1.9" +tauri-plugin-liquid-glass = "0.1.6" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } -objc2-app-kit = { version = "0.3", features = ["NSEvent", "NSScreen", "NSGraphics"] } +objc2-foundation = { version = "0.3", features = ["NSKeyValueCoding", "NSProcessInfo", "NSString"] } +objc2-app-kit = { version = "0.3", features = ["NSClipView", "NSColor", "NSEvent", "NSGraphics", "NSScreen", "NSScrollView", "NSView", "NSWindow"] } objc2-web-kit = { version = "0.3", features = ["WKPreferences", "WKWebView", "WKWebViewConfiguration"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cc6aa864..a3f0af54 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "process:allow-restart", "global-shortcut:default", "autostart:default", + "liquid-glass:default", "core:menu:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0a31e8a..385cd20f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde::Serialize; use tauri::Emitter; use tauri_plugin_aptabase::EventTracker; +use tauri_plugin_liquid_glass::{GlassMaterialVariant, LiquidGlassConfig, LiquidGlassExt}; use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; @@ -199,6 +200,33 @@ fn hide_panel(app_handle: tauri::AppHandle) { } } +#[tauri::command] +fn set_liquid_glass_enabled(app_handle: tauri::AppHandle, enabled: bool) -> Result<(), String> { + use tauri::Manager; + + let Some(window) = app_handle.get_webview_window("main") else { + return Ok(()); + }; + + let config = if enabled { + LiquidGlassConfig { + corner_radius: 22.0, + variant: GlassMaterialVariant::Sidebar, + ..Default::default() + } + } else { + LiquidGlassConfig { + enabled: false, + ..Default::default() + } + }; + + app_handle + .liquid_glass() + .set_effect(&window, config) + .map_err(|error| error.to_string()) +} + #[tauri::command] fn open_devtools(#[allow(unused)] app_handle: tauri::AppHandle) { #[cfg(debug_assertions)] @@ -489,9 +517,11 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_autostart::Builder::new().build()) + .plugin(tauri_plugin_liquid_glass::init()) .invoke_handler(tauri::generate_handler![ init_panel, hide_panel, + set_liquid_glass_enabled, open_devtools, start_probe_batch, list_plugins, @@ -505,7 +535,7 @@ pub fn run() { #[cfg(target_os = "macos")] { app_nap::disable_app_nap(); - webkit_config::disable_webview_suspension(app.handle()); + webkit_config::configure_webview(app.handle()); } use tauri::Manager; diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 78cfe030..2c5165da 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -1,5 +1,7 @@ use tauri::{AppHandle, Manager, Position, Size}; -use tauri_nspanel::{tauri_panel, CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt}; +use tauri_nspanel::{ + CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, +}; /// Macro to get existing panel or initialize it if needed. /// Returns Option - Some if panel is available, None on error. @@ -174,7 +176,8 @@ pub fn position_panel_at_tray_icon( None => { log::warn!( "No monitor found for cursor at ({:.0}, {:.0}), using primary", - mouse_x, mouse_y + mouse_x, + mouse_y ); match window.primary_monitor() { Ok(Some(m)) => m, @@ -204,9 +207,8 @@ pub fn position_panel_at_tray_icon( let panel_width = match (window.outer_size(), window.scale_factor()) { (Ok(s), Ok(win_scale)) => s.width as f64 / win_scale, _ => { - let conf: serde_json::Value = - serde_json::from_str(include_str!("../tauri.conf.json")) - .expect("tauri.conf.json must be valid JSON"); + let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json must be valid JSON"); conf["app"]["windows"][0]["width"] .as_f64() .expect("width must be set in tauri.conf.json") diff --git a/src-tauri/src/webkit_config.rs b/src-tauri/src/webkit_config.rs index e1a8e01a..3509e96d 100644 --- a/src-tauri/src/webkit_config.rs +++ b/src-tauri/src/webkit_config.rs @@ -1,25 +1,53 @@ -//! WebKit configuration for disabling background suspension on macOS. +//! WebKit configuration for macOS panel behavior. //! -//! By default, WebKit suspends JavaScript execution when the webview is not visible. -//! This module disables that behavior so auto-update timers continue to fire. +//! We keep JavaScript active while the panel is hidden and force the WKWebView +//! itself fully transparent so native liquid-glass can show through the app +//! container instead of only around it. use tauri::Manager; -pub fn disable_webview_suspension(app_handle: &tauri::AppHandle) { +pub fn configure_webview(app_handle: &tauri::AppHandle) { let Some(window) = app_handle.get_webview_window("main") else { log::warn!("webkit_config: main window not found"); return; }; - if let Err(e) = window.with_webview(|webview| { - unsafe { - use objc2_web_kit::{WKInactiveSchedulingPolicy, WKWebView}; - let wk_webview: &WKWebView = &*webview.inner().cast(); - let config = wk_webview.configuration(); - let prefs = config.preferences(); - prefs.setInactiveSchedulingPolicy(WKInactiveSchedulingPolicy::None); - log::info!("WebKit inactiveSchedulingPolicy set to None"); + if let Err(e) = window.with_webview(|webview| unsafe { + use objc2::sel; + use objc2_app_kit::NSColor; + use objc2_foundation::{NSNumber, NSObjectNSKeyValueCoding, NSObjectProtocol, ns_string}; + use objc2_web_kit::{WKInactiveSchedulingPolicy, WKWebView}; + + let wk_webview: &WKWebView = &*webview.inner().cast(); + let clear = NSColor::clearColor(); + let no = NSNumber::numberWithBool(false); + let config = wk_webview.configuration(); + let prefs = config.preferences(); + + prefs.setInactiveSchedulingPolicy(WKInactiveSchedulingPolicy::None); + + config.setValue_forKey(Some(&no), ns_string!("drawsBackground")); + wk_webview.setValue_forKey(Some(&no), ns_string!("drawsBackground")); + + if wk_webview.respondsToSelector(sel!(setUnderPageBackgroundColor:)) { + wk_webview.setUnderPageBackgroundColor(Some(&clear)); + } + + if let Some(scroll_view) = wk_webview.enclosingScrollView() { + scroll_view.setDrawsBackground(false); + scroll_view.setBackgroundColor(&clear); + + let clip_view = scroll_view.contentView(); + clip_view.setDrawsBackground(false); + clip_view.setBackgroundColor(&clear); } + + if let Some(ns_window) = wk_webview.window() { + ns_window.setOpaque(false); + ns_window.setBackgroundColor(Some(&clear)); + } + + log::info!("Configured transparent WKWebView and disabled inactive scheduling"); }) { log::warn!("Failed to configure WebKit scheduling: {e}"); } diff --git a/src/App.test.tsx b/src/App.test.tsx index bf3b91d0..8c40249b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -381,18 +381,44 @@ describe("App", () => { // Dark await userEvent.click(await screen.findByRole("radio", { name: "Dark" })) expect(document.documentElement.classList.contains("dark")).toBe(true) + expect(document.documentElement.classList.contains("glass")).toBe(false) // Light await userEvent.click(await screen.findByRole("radio", { name: "Light" })) expect(document.documentElement.classList.contains("dark")).toBe(false) + expect(document.documentElement.classList.contains("glass")).toBe(false) + + // Glass + await userEvent.click(await screen.findByRole("radio", { name: "Glass" })) + expect(document.documentElement.classList.contains("dark")).toBe(false) + expect(document.documentElement.classList.contains("glass")).toBe(true) // Back to system should subscribe to matchMedia changes await userEvent.click(await screen.findByRole("radio", { name: "System" })) expect(mq.addEventListener).toHaveBeenCalled() + expect(document.documentElement.classList.contains("glass")).toBe(false) mmSpy.mockRestore() }) + it("syncs native liquid glass mode when running in tauri", async () => { + state.isTauriMock.mockReturnValue(true) + + render() + const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) + await userEvent.click(settingsButtons[0]) + + await userEvent.click(await screen.findByRole("radio", { name: "Glass" })) + await waitFor(() => + expect(state.invokeMock).toHaveBeenCalledWith("set_liquid_glass_enabled", { enabled: true }) + ) + + await userEvent.click(await screen.findByRole("radio", { name: "Light" })) + await waitFor(() => + expect(state.invokeMock).toHaveBeenCalledWith("set_liquid_glass_enabled", { enabled: false }) + ) + }) + it("loads plugins, normalizes settings, and renders overview", async () => { state.isTauriMock.mockReturnValue(true) render() diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index a0d5406b..012b648f 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -9,8 +9,6 @@ import { usePanel } from "@/hooks/app/use-panel" import { useAppUpdate } from "@/hooks/use-app-update" import { useAppUiStore } from "@/stores/app-ui-store" -const ARROW_OVERHEAD_PX = 37 - type AppShellProps = { onRefreshAll: () => void navPlugins: NavPlugin[] @@ -67,11 +65,10 @@ export function AppShell({ const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() return ( -
-
+
-
+
+