diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs new file mode 100644 index 000000000..b23d1099d --- /dev/null +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -0,0 +1,121 @@ +//! Linux Wayland: tell winit where the cursor theme is (#2450). +//! +//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor +//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps +//! showing its default one, until the canvas hides the system pointer to paint a tool glyph +//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup +//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the +//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads. +//! +//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before +//! any thread starts and before the window opens. +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::ffi::OsString; + +/// Where a Flatpak sandbox mounts the host's cursor themes (user and system). +const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"]; + +/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`). +#[derive(Debug, Default, PartialEq)] +pub struct DesktopCursor { + pub theme: Option, + pub size: Option, +} + +/// The variables to set, given the environment, the host icon folders that exist and the +/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set +/// stay as they are. +pub fn plan(var: impl Fn(&str) -> Option, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> { + let set = |name: &str| var(name).is_some_and(|v| !v.is_empty()); + if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) { + return Vec::new(); + } + let mut out = Vec::new(); + if !set("XCURSOR_PATH") && !host_icons.is_empty() { + out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":")))); + } + if !set("XCURSOR_THEME") + && let Some(theme) = desktop.theme.filter(|t| !t.is_empty()) + { + out.push(("XCURSOR_THEME", OsString::from(theme))); + } + if !set("XCURSOR_SIZE") + && let Some(size) = desktop.size.filter(|s| (1..=512).contains(s)) + { + out.push(("XCURSOR_SIZE", OsString::from(size.to_string()))); + } + out +} + +/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`. +fn gsettings_value(output: &str) -> &str { + output.trim().trim_matches('\'') +} + +#[cfg(target_os = "linux")] +fn desktop_cursor() -> DesktopCursor { + let get = |key: &str| { + let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + DesktopCursor { + theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()), + size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()), + } +} + +/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them +/// unset. Call first thing in `main`, while the process is single-threaded. +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +pub fn apply() { + let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect(); + for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) { + log::debug!("cursor theme: {name}={value:?}"); + // SAFETY: called at the top of `main`, before the app starts any thread. + unsafe { std::env::set_var(name, value) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v)) + } + + fn adwaita() -> DesktopCursor { + DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) } + } + + #[test] + fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() { + let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita()); + assert_eq!( + got, + vec![ + ("XCURSOR_PATH", OsString::from("/run/host/share/icons")), + ("XCURSOR_THEME", OsString::from("Adwaita")), + ("XCURSOR_SIZE", OsString::from("24")), + ] + ); + } + + #[test] + fn settings_the_user_made_and_other_sessions_stay_untouched() { + let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")]; + assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty()); + assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty()); + // A theme-less desktop and an absurd size set nothing. + let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) }; + assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty()); + } + + #[test] + fn gsettings_output_is_unquoted() { + assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita"); + assert_eq!(gsettings_value("24\n"), "24"); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 17bc010a3..c7a395a61 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -27,6 +27,7 @@ mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; mod control_server; +mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; mod logging; @@ -654,6 +655,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli: "; fn main() -> std::process::ExitCode { + // Before any thread starts: the pointer must find a cursor theme on Wayland (#2450). + #[cfg(target_os = "linux")] + cursor_theme::apply(); // Validate roots before logging, font scans, preferences or the window: a missing value // must not clear an environment root and start the control channel without confinement. let mut control = automation_args::ControlArg::default(); diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml index b245e0b31..687445a55 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml @@ -24,6 +24,10 @@ finish-args: - --socket=fallback-x11 # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself. + # The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible + # over the window. The host's themes are mounted under /run/host (#2450). + - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by # path from the command line. diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml index 865609509..cbee5b720 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml @@ -22,6 +22,10 @@ finish-args: - --socket=wayland - --socket=fallback-x11 - --device=dri + # Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself. + # The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible + # over the window. The host's themes are mounted under /run/host (#2450). + - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by # path from the command line.