Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,55 @@ 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, 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
Expand Down
4 changes: 2 additions & 2 deletions windows/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion windows/src-tauri/src/island.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
159 changes: 159 additions & 0 deletions windows/src-tauri/src/platform/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -201,6 +286,59 @@ fn gtk_window_ptr(win: &gtk::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.
Expand All @@ -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
Expand Down Expand Up @@ -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");
}
}
}
9 changes: 9 additions & 0 deletions windows/src-tauri/src/platform/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)>) {}