Files
cinny-desktop/src-tauri/src/native/chrome.rs
T
jaredandClaude Opus 5.5 4158990c2a 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
2026-11-09 21:41:31 -05:00

182 lines
7.3 KiB
Rust

//! P5-47 — TDS Custom Window Chrome (opt-in, runtime-reversible).
//!
//! 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 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
//! frameless window can't coexist). Each command resolves the "main" window and
//! silently no-ops if it isn't present (e.g. during teardown); the `Result`s are
//! 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};
/// 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) -> 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
// system backdrop glitches the whole surface (black/blank window). Drop
// the backdrop before undecorating, and restore it together with the
// native frame when custom chrome turns off.
#[cfg(target_os = "windows")]
if enabled {
let _ = window_vibrancy::clear_mica(&window);
}
let _ = window.set_decorations(!enabled);
// Re-assert the DWM shadow so a frameless window keeps its drop shadow
// and resize borders on Windows (no-op / harmless elsewhere).
let _ = window.set_shadow(true);
#[cfg(target_os = "windows")]
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).
#[tauri::command]
pub fn window_minimize(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.minimize();
}
}
/// Toggle maximize/restore the main window (custom titlebar max button and
/// drag-region double-click).
#[tauri::command]
pub fn window_toggle_maximize(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_maximized().unwrap_or(false) {
let _ = window.unmaximize();
} else {
let _ = window.maximize();
}
}
}
/// Begin an OS-level window drag from the custom titlebar drag region. The web
/// side also marks the drag area with `data-tauri-drag-region`; this command is
/// the explicit fallback so behaviour is identical across platforms.
#[tauri::command]
pub fn window_start_drag(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.start_dragging();
}
}
/// Close from the custom titlebar. Mirrors the app's close-to-tray behaviour
/// (see the `CloseRequested` handler in `lib.rs`): we `hide()` the window rather
/// than exiting, so the tray keeps the app running and the tray menu remains the
/// single explicit quit path.
#[tauri::command]
pub fn window_close(app: AppHandle) {
// Same rules as the OS close button (tray / quit / first-close dialog,
// 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));
}
}