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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -63,6 +63,22 @@ pub(crate) fn parse_driver_major(text: &str) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String>) -> 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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
//!
|
||||
//! When the user opts into custom window chrome, the web client renders its own
|
||||
//! `<TitleBar/>` (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<std::path::PathBuf> {
|
||||
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::<ChromeState>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user