From 2271068460cbadcfedbac0b6b536c33a2c8981b8 Mon Sep 17 00:00:00 2001 From: Md Mojahid Date: Sat, 3 Oct 2026 02:02:49 +0600 Subject: [PATCH 1/2] Linux: fix island placement on GNOME and other desktops without layer-shell --- windows/README.md | 28 +++- windows/package-lock.json | 4 +- windows/src-tauri/src/island.rs | 14 +- windows/src-tauri/src/platform/linux.rs | 159 ++++++++++++++++++++++ windows/src-tauri/src/platform/windows.rs | 9 ++ 5 files changed, 207 insertions(+), 7 deletions(-) diff --git a/windows/README.md b/windows/README.md index 60374024c..20508ce9f 100644 --- a/windows/README.md +++ b/windows/README.md @@ -164,10 +164,30 @@ npm run pack # AppImage, .deb and .rpm in windows/release/ What changes on Linux: -- **The island** is a gtk-layer-shell overlay anchored to the top edge, over any - top panel, on compositors that support it: COSMIC, KDE Plasma, Hyprland, Sway - and other wlroots compositors. GNOME has no layer-shell, so there the island - is a regular window. `COUCOU_LAYER_SHELL=0` forces that mode anywhere. +- **The island** is a gtk-layer-shell overlay anchored to the top edge on + compositors that support it: COSMIC, KDE Plasma, Hyprland, Sway and other + wlroots compositors. GNOME has no layer-shell, so there the island is a regular + window placed by the window manager instead — see below. + `COUCOU_LAYER_SHELL=0` forces that mode anywhere. +- **The GDK backend is chosen at startup** so the island actually reaches the + top of the screen. A Wayland app has no say over where its window goes, so + without layer-shell the island ended up centred vertically — mid-screen, and + re-centred by the compositor on every resize. So: layer-shell available → + Wayland; no layer-shell but an X server reachable → X11, where the window + manager does honour the placement. GNOME sessions export + `GDK_BACKEND=wayland` for every app they launch, so that value is overridden + here. `COUCOU_GDK_BACKEND=x11|wayland` forces one, `GDK_BACKEND` is honoured as + before. The choice is written to the log as `backend: …`. +- **The island sits just under the top panel**, not over it, unless it is a + layer surface. With one (KDE Plasma, Sway, Hyprland, COSMIC and other wlroots + compositors) the compositor anchors it to the very top edge, over the panel — + the notch-like placement, and why the Mac layout is possible at all. Without + one (GNOME, and anything else on X11) the island is an ordinary window and the + top edge belongs to the clock and the date: drawn over it, the island hid both. + So there it goes below the panel, at the depth reported by the window manager's + own work area, not a hardcoded number. The island is never hinted as a dock: + that would buy nothing now and, on window managers that honour dock struts + (XFCE, MATE, Cinnamon, i3), would reserve desktop space for it. - **Click-through** is the window's input region, kept equal to the island shape, so the compositor sends every other click to what is underneath. - **Mochi's eyes** follow the pointer only while it is over the island: Wayland diff --git a/windows/package-lock.json b/windows/package-lock.json index 5e06f8bcb..c00688c9b 100644 --- a/windows/package-lock.json +++ b/windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "^2" }, diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cb..31ac7c0b5 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -153,6 +153,11 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let Some(win) = window(app) else { return }; let Some(m) = target_monitor(app, pref) else { return }; + // Before the move: on Linux the window manager lays the island out inside + // the work area unless the window asks to be a dock, which would put it + // under the top panel instead of over it. A no-op everywhere else. + platform::avoid_panels(&win); + let scale = m.scale_factor(); let mp = *m.position(); let ms = *m.size(); @@ -161,7 +166,14 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let pw = (lw * scale).round().max(1.0) as u32; let ph = (lh * scale).round().max(1.0) as u32; let x = mp.x + (ms.width as i32 - pw as i32) / 2; - let y = mp.y; + // Without a layer surface nothing anchors the island to the screen edge, so + // it is placed by hand. Where there are panels it goes just under them: on + // GNOME the top edge holds the clock and the date, and an island drawn over + // them would hide both. `top_panel_height` is the panel's own depth — 0 when + // there is none, or when the compositor anchors the island itself — so + // nothing about the desktop is assumed. + let panel = (platform::top_panel_height(&win) * scale).round().max(0.0) as i32; + let y = mp.y + panel; // GTK never sizes a non-resizable window below its natural size (200 px // here), so on Linux the 6 px wake strip would stay a 200 px block. tao diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..0a721b1ef 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -5,6 +5,9 @@ // window goes, so the island works differently from Windows: // * it is a layer-shell surface anchored to the top edge, above everything, // on compositors that support it (COSMIC, KDE, wlroots — not GNOME); +// * where layer-shell is missing, prepare_backend drops to X11 (XWayland +// included) and avoid_panels makes the island a dock, because that is the +// only combination where the window manager puts it at the top edge; // * click-through is the window's input region, set to the island shape, so // the compositor itself sends every other click to whatever is underneath; // * the cursor comes from the page's own mouse events, which only fire over @@ -61,12 +64,94 @@ pub fn local_dir() -> PathBuf { /// plugin paths that vanish once Coucou quits. Give ours its own file. pub fn prepare_environment() { if std::env::var_os("APPIMAGE").is_none() || std::env::var_os("GST_REGISTRY").is_some() { + prepare_backend(); return; } let cache = xdg("XDG_CACHE_HOME", ".cache").join("coucou"); if std::fs::create_dir_all(&cache).is_ok() { std::env::set_var("GST_REGISTRY", cache.join("gstreamer-registry.bin")); } + prepare_backend(); +} + +/// Picks the GDK backend before GTK is initialised, so the island lands where it +/// was asked to land. +/// +/// On Wayland an app has no say over where its window goes, and there is no +/// layer-shell on GNOME, so the island became an ordinary always-on-top window +/// and the compositor centred it — vertically in the middle of the screen, and +/// kept re-placing it on every configure. layer-shell is the reason for the +/// top-edge placement, so it is asked for first: +/// +/// * layer-shell available (KDE, wlroots/COSMIC/Sway/Hyprland): Wayland, and +/// the compositor anchors the surface to the top edge; +/// * no layer-shell but an X server reachable (GNOME, or any X11 session): +/// X11, where the window manager does honour `set_position`, so the +/// explicit placement in island::apply_geometry is what counts; +/// * neither: leave GDK to decide, rather than lock ourselves out of a +/// working display. +/// +/// A `GDK_BACKEND` already in the environment is *not* taken as the user's +/// choice here: GNOME sessions export `GDK_BACKEND=wayland` for every app they +/// start, and honouring it is exactly what left the island in the middle. +/// `COUCOU_GDK_BACKEND` is the per-app override, and `COUCOU_LAYER_SHELL=0` +/// leaves the backend alone entirely. +fn prepare_backend() { + if std::env::var("COUCOU_LAYER_SHELL").as_deref() == Ok("0") { + return; + } + if let Ok(forced) = std::env::var("COUCOU_GDK_BACKEND") { + if !forced.is_empty() { + crate::log::line(format!("backend: {forced} (COUCOU_GDK_BACKEND)")); + std::env::set_var("GDK_BACKEND", forced); + } + return; + } + if unsafe { layer::gtk_layer_is_supported() } != 0 { + std::env::set_var("GDK_BACKEND", "wayland"); + crate::log::line("backend: wayland (layer-shell overlay)"); + } else if has_x_display() { + // Overrides the session's GDK_BACKEND=wayland: the window manager moves + // the island for us, where a Wayland compositor would not. + std::env::set_var("GDK_BACKEND", "x11"); + crate::log::line("backend: x11 (no layer-shell; XWayland keeps placement)"); + } else { + crate::log::line("backend: default (no layer-shell, no X display)"); + } +} + +/// True when this process can reach an X server: `DISPLAY` set, and either +/// X11 or XWayland underneath it. GDK picks the Wayland backend whenever +/// `WAYLAND_DISPLAY` is present, which on a GNOME session is a compositor that +/// will neither give us a layer surface nor let us move the window. +fn has_x_display() -> bool { + if !std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) { + return false; + } + // An X server reachable through XWayland still speaks X11 to us, which is + // all the position calls need. + !x11_socket().is_empty() && std::path::Path::new(&x11_socket()).exists() +} + +/// `DISPLAY` as a socket path: `:0` → `/tmp/.X11-unix/X0`. Empty when the value +/// is not the simple host:socket form this app can use. +fn x11_socket() -> String { + x11_socket_for(&std::env::var("DISPLAY").unwrap_or_default()) +} + +fn x11_socket_for(display: &str) -> String { + let Some((host, rest)) = display.rsplit_once(':') else { + return String::new(); + }; + let number = rest.split('.').next().unwrap_or_default(); + if number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) { + return String::new(); + } + if !host.is_empty() && host != "unix" { + // A remote or TCP display: no local socket to stat. + return String::new(); + } + format!("/tmp/.X11-unix/X{number}") } pub fn local_time() -> LocalTime { @@ -201,6 +286,59 @@ fn gtk_window_ptr(win: >k::ApplicationWindow) -> *mut gtk::ffi::GtkWindow { /// WebKitGTK has no competing drop target to remove. pub fn unblock_webview_drops(_app: &AppHandle) {} +/// How many logical pixels of the screen's top edge the panels cover, so the +/// island can sit just under them instead of over them. +/// +/// On GNOME the top edge holds the clock and the date. An island drawn over it +/// hides both, so it goes below the bar rather than on it. +/// +/// The number comes from the window manager's own work area (the `_NET_WORKAREA` +/// property, surfaced by GDK as `Monitor::workarea`): the rectangle left over +/// once the panels are subtracted. Asking beats hardcoding a figure that is +/// right on one machine and wrong on the next — 32 is GNOME's default, but it +/// follows whatever the user configured, and it is 0 on a desktop with no panel. +/// +/// Always 0 on a layer surface: there the compositor anchors the island to the +/// top edge itself, and an offset would defeat that. +/// +/// The caller wants physical pixels; this is logical, so it is scaled by the +/// caller rather than guessing at it here. +pub fn top_panel_height(win: &WebviewWindow) -> f64 { + if LAYER_SURFACE.load(Ordering::Relaxed) { + return 0.0; + } + let Ok(gw) = win.gtk_window() else { return 0.0 }; + let display = gw.display(); + // The monitor the island actually lives on, not just the primary one. + // `window()` is the GdkWindow behind the GtkWindow; None before the window + // is realized, in which case the primary monitor is the right answer. + let monitor = gw + .window() + .and_then(|gdk_window| display.monitor_at_window(&gdk_window)) + .or_else(|| display.primary_monitor()); + let Some(monitor) = monitor else { return 0.0 }; + let workarea = monitor.workarea(); + // The work area starts where the panel ends. Negative would mean a bar on + // another edge only; clamp so a strange value can never push it off-screen. + workarea.y().max(0) as f64 +} + +/// Asks the window manager to keep the island out of the panels, so the +/// placement in `island::apply_geometry` is the placement we get. +/// +/// Only meaningful on the X11 path; a layer surface is anchored to the edge by +/// the compositor and has no work area at all. Safe to call on every +/// `apply_geometry`, which is why the set_resizable dance next to it is too. +/// +/// Deliberately *not* a dock. A dock is the one type placed against the screen +/// edge rather than inside the work area, which was how the island used to reach +/// y = 0 — on top of GNOME's clock and date. It now sits under the panel +/// instead, so the hint buys nothing and costs something: window managers that +/// honour dock struts (XFCE, MATE, Cinnamon, i3, openbox) reserve vertical space +/// for a dock window, which would shrink the usable desktop by the height of the +/// island. Not a trade worth making for an empty win. +pub fn avoid_panels(_win: &WebviewWindow) {} + /// Turns the island into an overlay surface on the top edge that never takes /// the keyboard. Must run before the window is first shown: a layer surface /// cannot be made out of a window the compositor already knows. @@ -223,6 +361,9 @@ pub fn make_non_activating(win: &WebviewWindow) { }; crate::log::line(format!("island is a regular window ({why})")); gw.set_accept_focus(false); + // No dock hint here: see `avoid_panels`. The island is placed under the + // panel by hand, and a dock window would make window managers that honour + // struts reserve space for it. return; } // tao gives undecorated Wayland windows an empty titlebar to force @@ -344,4 +485,22 @@ mod tests { assert_eq!(std::fs::metadata(&dir).unwrap().mode() & 0o777, 0o700); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_display_becomes_the_socket_we_can_stat() { + // The shapes a session actually hands us, Wayland ones included. + assert_eq!(x11_socket_for(":0"), "/tmp/.X11-unix/X0"); + assert_eq!(x11_socket_for(":0.0"), "/tmp/.X11-unix/X0"); + assert_eq!(x11_socket_for(":1"), "/tmp/.X11-unix/X1"); + assert_eq!(x11_socket_for("unix:0"), "/tmp/.X11-unix/X0"); + } + + #[test] + fn a_display_we_cannot_reach_locally_is_never_claimed() { + // Picking x11 for a display we cannot connect to would give the app no + // window at all, which is worse than the misplacement we are fixing. + for display in ["", ":", "wayland-0", "hostname:0", ":abc", ":-1", ":0:1"] { + assert_eq!(x11_socket_for(display), "", "{display:?} must not resolve"); + } + } } diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..1d4bb0eb7 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -231,5 +231,14 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { } } +/// Windows has no work-area reservation: a borderless, always-on-top window +/// goes exactly where it is put. Nothing to do. +pub fn avoid_panels(_win: &WebviewWindow) {} + +/// No top panel to clear: the island is placed against the screen edge. +pub fn top_panel_height(_win: &WebviewWindow) -> f64 { + 0.0 +} + /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} From 47024e192ad1babae52c6b252bb9c25301ce40af Mon Sep 17 00:00:00 2001 From: Md Mojahid Date: Sat, 3 Oct 2026 02:16:52 +0600 Subject: [PATCH 2/2] Linux: document installation and desktop-specific island placement --- windows/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/windows/README.md b/windows/README.md index 20508ce9f..2e3d262af 100644 --- a/windows/README.md +++ b/windows/README.md @@ -162,6 +162,29 @@ npm run tauri dev # live-reloading development build npm run pack # AppImage, .deb and .rpm in windows/release/ ``` +### Install + +```bash +sudo dpkg -i windows/release/Coucou-Linux-0.1.1-amd64.deb # Debian, Ubuntu +sudo rpm -i windows/release/Coucou-Linux-0.1.1-x86_64.rpm # Fedora +``` + +The `.deb` and `.rpm` depend on `libgtk-layer-shell0` / `gtk-layer-shell` and +`libayatana-appindicator3-1`; `apt` or `dnf` pulls them in, and the build step +above already needs the matching `-dev` packages. + +The AppImage needs nothing installed at all: + +```bash +chmod +x windows/release/Coucou-Linux-0.1.1-x86_64.AppImage +./windows/release/Coucou-Linux-0.1.1-x86_64.AppImage +``` + +First run on GNOME: the island sits just under the top bar, leaving the clock +and date visible. On KDE, Sway, Hyprland and COSMIC it sits at the very top edge, +over the panel — there is a real notch-equivalent there. `coucou.log` in +`~/.local/share/coucou/` records which path was taken, as `backend: …`. + What changes on Linux: - **The island** is a gtk-layer-shell overlay anchored to the top edge on