From 4158990c2ac0db029ca26aa070e0767a7ed4f7d9 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 9 Nov 2026 21:41:31 -0500 Subject: [PATCH] fix(linux): custom window chrome no longer stacks with the native frame on Wayland On Linux / native Wayland GTK negotiates decorations with the compositor only when the window is created, so `set_decorations(false)` at runtime left KDE's frame in place and the Lotus titlebar was drawn inside it (two sets of close/min/max buttons). Turning it off would have left a window with no titlebar at all until restart. - The choice is saved natively (app config dir `custom-chrome`, like `start-minimized`). On Linux the main window is built with or without the frame from it. - `set_custom_chrome` still toggles at runtime where that works (Windows, macOS, Linux on X11 / XWayland); on native Wayland it only saves the choice, which applies next launch. - It now returns whether the frame is stripped right now, so the web client can show its titlebar only then. - The native-Wayland check moved out of gpu_workarounds into a shared `gtk_native_wayland` helper (behaviour unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src-tauri/src/gpu_workarounds.rs | 27 +++++--- src-tauri/src/lib.rs | 6 ++ src-tauri/src/native/chrome.rs | 112 ++++++++++++++++++++++++++++--- 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/gpu_workarounds.rs b/src-tauri/src/gpu_workarounds.rs index 7974361..44d171c 100644 --- a/src-tauri/src/gpu_workarounds.rs +++ b/src-tauri/src/gpu_workarounds.rs @@ -63,6 +63,22 @@ pub(crate) fn parse_driver_major(text: &str) -> Option { }) } +/// Whether GTK will run on native Wayland (not X11 / XWayland): a Wayland +/// session, and `GDK_BACKEND` unset or listing Wayland first (it may list +/// several, e.g. "wayland,x11": the first one wins). Pure, for tests; also used +/// by `native::chrome` (window decorations can't change at runtime there). +pub(crate) fn gtk_native_wayland(get: &impl Fn(&str) -> Option) -> bool { + let set = |k: &str| get(k).is_some_and(|v| !v.is_empty()); + let wayland_session = set("WAYLAND_DISPLAY") + || get("XDG_SESSION_TYPE").is_some_and(|v| v.eq_ignore_ascii_case("wayland")); + wayland_session + && get("GDK_BACKEND").map_or(true, |v| { + v.is_empty() + || v.trim_start().to_ascii_lowercase().starts_with("wayland") + || v.trim() == "*" + }) +} + /// Environment variables to set: pure, for tests. pub(crate) fn decide( nvidia: bool, @@ -73,16 +89,7 @@ pub(crate) fn decide( if !nvidia || set("LOTUS_NO_GPU_WORKAROUNDS") { return Vec::new(); } - let wayland_session = set("WAYLAND_DISPLAY") - || get("XDG_SESSION_TYPE").is_some_and(|v| v.eq_ignore_ascii_case("wayland")); - // GTK uses Wayland unless GDK_BACKEND says otherwise (it may list - // several, e.g. "wayland,x11": the first one wins). - let native_wayland = wayland_session - && get("GDK_BACKEND").map_or(true, |v| { - v.is_empty() - || v.trim_start().to_ascii_lowercase().starts_with("wayland") - || v.trim() == "*" - }); + let native_wayland = gtk_native_wayland(&get); let mut out = Vec::new(); let mut want = |k: &'static str, v: &'static str| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 286b885..3478fc2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1209,8 +1209,14 @@ pub fn run() { let window_settled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(hide_on_launch)); let settled_page_load = window_settled.clone(); + // Linux: build with or without the native frame from the saved + // custom-chrome choice, since on Wayland it can't change later (see + // native::chrome). Elsewhere it's toggled at runtime as before. + let undecorated = native::chrome::build_undecorated(app.handle()); + app.manage(native::chrome::ChromeState::new(undecorated)); let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url) .title("Lotus Chat") + .decorations(!undecorated) // First-run defaults; tauri-plugin-window-state restores geometry // on later launches. .inner_size(1100.0, 720.0) diff --git a/src-tauri/src/native/chrome.rs b/src-tauri/src/native/chrome.rs index fb129e9..f1f985d 100644 --- a/src-tauri/src/native/chrome.rs +++ b/src-tauri/src/native/chrome.rs @@ -2,9 +2,13 @@ //! //! When the user opts into custom window chrome, the web client renders its own //! `` (folds/TDS styled) and we strip the OS-native window frame so -//! the two don't stack. This is entirely opt-in: the window is built with native -//! `decorations(true)` and only `set_custom_chrome(true)` makes it frameless, so -//! the safe default is the untouched native frame. +//! the two don't stack. This is entirely opt-in; the default is the untouched +//! native frame. On Windows and macOS the window is built with the native frame +//! and `set_custom_chrome` toggles it at runtime. On Linux the saved choice +//! decides the frame when the window is built, because on native Wayland a +//! runtime change doesn't reach the compositor (it then applies next launch). +//! `set_custom_chrome` reports whether the frame is stripped *now*, and the web +//! client shows its titlebar only then. //! //! Everything here goes through the cross-platform Tauri v2 window API (plus a //! Windows-only `window_vibrancy` dance in `set_custom_chrome`, since Mica and a @@ -13,14 +17,87 @@ //! intentionally ignored since a failed chrome tweak should never surface as an //! error to the user. +use std::sync::atomic::{AtomicBool, Ordering}; + use tauri::{AppHandle, Manager}; -/// Toggle the native window frame. `enabled` = custom chrome on, which means the -/// OS decorations must come **off** (`set_decorations(!enabled)`). Passing -/// `false` restores the native frame, making the feature fully reversible at -/// runtime without a restart. +/// Whether the native frame is **actually** stripped right now, which is not +/// always what the user last asked for: on Linux / native Wayland GTK only +/// negotiates decorations with the compositor when the window is created, so a +/// runtime `set_decorated` leaves the compositor's frame in place (KDE drew its +/// buttons around the Lotus titlebar). There the choice is applied when the +/// window is built (from the persisted file) and takes effect next launch. +pub struct ChromeState { + active: AtomicBool, +} + +impl ChromeState { + /// `built_undecorated`: the main window was created without a frame. + pub fn new(built_undecorated: bool) -> Self { + Self { + active: AtomicBool::new(built_undecorated), + } + } +} + +/// Where the custom-chrome choice is kept natively, so the main window can be +/// built with the right frame before the web client has loaded. +fn custom_chrome_file(app: &AppHandle) -> Option { + app.path() + .app_config_dir() + .ok() + .map(|dir| dir.join("custom-chrome")) +} + +/// The saved choice; defaults to off (native frame). +pub fn read_custom_chrome(app: &AppHandle) -> bool { + custom_chrome_file(app) + .and_then(|path| std::fs::read_to_string(path).ok()) + .is_some_and(|value| value.trim() == "1") +} + +fn write_custom_chrome(app: &AppHandle, enabled: bool) { + let Some(path) = custom_chrome_file(app) else { + return; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(path, if enabled { "1" } else { "0" }); +} + +/// Whether `set_decorations` takes effect on a live window here. False only on +/// Linux with GTK on native Wayland. Pure, for tests. +pub(crate) fn runtime_toggle_works(linux: bool, native_wayland: bool) -> bool { + !(linux && native_wayland) +} + +/// Whether the main window should be built without a frame: only on Linux, +/// where a runtime toggle may not apply. Windows keeps building with the native +/// frame and toggling at runtime (Mica needs it there, see below). +pub fn build_undecorated(app: &AppHandle) -> bool { + cfg!(target_os = "linux") && read_custom_chrome(app) +} + +/// Apply the custom-chrome choice. `enabled` = custom chrome on, which means +/// the OS decorations must come **off** (`set_decorations(!enabled)`). The +/// choice is saved for the next launch; where the frame can change at runtime +/// (Windows, macOS, Linux on X11) it changes now. +/// +/// Returns whether the native frame is stripped **now**, so the web client +/// shows its own titlebar only then (never two titlebars, never none). #[tauri::command] -pub fn set_custom_chrome(app: AppHandle, enabled: bool) { +pub fn set_custom_chrome(app: AppHandle, enabled: bool) -> bool { + write_custom_chrome(&app, enabled); + let native_wayland = + crate::gpu_workarounds::gtk_native_wayland(&|k: &str| std::env::var(k).ok()); + let can_toggle = runtime_toggle_works(cfg!(target_os = "linux"), native_wayland); + let state = app.try_state::(); + if !can_toggle { + return state + .map(|s| s.active.load(Ordering::Relaxed)) + .unwrap_or(false); + } if let Some(window) = app.get_webview_window("main") { // Windows: the Mica backdrop (applied at startup in lib.rs) and a // frameless window are a known-bad combo — stripping WS_CAPTION under a @@ -39,7 +116,13 @@ pub fn set_custom_chrome(app: AppHandle, enabled: bool) { if !enabled { let _ = window_vibrancy::apply_mica(&window, Some(true)); } + if let Some(state) = &state { + state.active.store(enabled, Ordering::Relaxed); + } } + state + .map(|s| s.active.load(Ordering::Relaxed)) + .unwrap_or(false) } /// Minimize the main window (custom titlebar min button). @@ -83,3 +166,16 @@ pub fn window_close(app: AppHandle) { // cinny-desktop #5). crate::handle_close_request(&app); } + +#[cfg(test)] +mod tests { + use super::runtime_toggle_works; + + #[test] + fn only_linux_on_native_wayland_needs_a_restart() { + assert!(!runtime_toggle_works(true, true)); + assert!(runtime_toggle_works(true, false)); // Linux on X11 / XWayland + assert!(runtime_toggle_works(false, false)); // Windows / macOS + assert!(runtime_toggle_works(false, true)); + } +}