collective

Browse / github.com/storytold/craft-launcher / patch

Restore window controls on Wayland without server decorations

Restore window controls on Wayland without server decorations Restore the missing minimize, maximize/restore and close controls for launcher windows on Wayland compositors without server decorations, including GNOME. Detect the actual window backend and the advertised xdg-decoration protocol before enabling winit's Adwaita frame. X11 and compositors with server decorations retain the existing custom header; failed or slow probes retain its working controls. Reopening a launcher window uses the same policy. The frame buttons use vector paths. Its title-font renderer is disabled, so no Cantarell font is embedded and no new font or native font-rendering li

Status
open
Score
0.00
Adopters
0 other operators
Author
danielmigueltejedor (imported from pull request #489, open)
Base
a9ac66106e4007df63f00c5ab303469028e5f8fc
Size
+197 −7 in 7 files
Review
pending
Shared
2026-10-28
Id
529adc7db97fc7a9d32bcce2

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock NOTICE apps/artcraft-launcher/Cargo.toml apps/artcraft-launcher/src/linux_window.rs apps/artcraft-launcher/src/main.rs packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml packaging/linux/package.sh

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -287,10 +287,13 @@ dependencies = [  "objc2 0.6.5",  "objc2-app-kit 0.3.2",  "objc2-foundation 0.3.2",+ "raw-window-handle",  "rfd",  "ron",  "serde",  "tray-icon",+ "wayland-client",+ "winit",  "winreg",  "winresource",  "zbus",@@ -4180,6 +4183,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]]+name = "sctk-adwaita"+version = "0.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"+dependencies = [+ "log",+ "smithay-client-toolkit 0.19.2",+ "tiny-skia",+]+ [[package]] name = "security-framework" version = "3.7.0"@@ -5877,6 +5891,7 @@ dependencies = [  "raw-window-handle",  "redox_syscall 0.4.1",  "rustix 0.38.44",+ "sctk-adwaita",  "smithay-client-toolkit 0.19.2",  "smol_str",  "tracing",diff --git a/NOTICE b/NOTICE--- a/NOTICE+++ b/NOTICE@@ -25,6 +25,37 @@ Craft App Suite app icons (assets/app-icons/*.png) — from each app's repositor (github.com/storytold/<app>), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the <App> contributors. +Linux Wayland window decorations+-------------------------------+sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,+commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux+for vector window controls. Its title-font features are disabled: no Cantarell+font is embedded by this dependency and no font file is added to this repo.++sctk-adwaita's MIT licence:++MIT License++Copyright (c) 2022 Bartłomiej Maryńczak++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.+  egui icon font subset (dependency asset) ----------------------------------------diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml--- a/apps/artcraft-launcher/Cargo.toml+++ b/apps/artcraft-launcher/Cargo.toml@@ -33,6 +33,11 @@ serde = { workspace = true } craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] }  [target.'cfg(target_os = "linux")'.dependencies]+raw-window-handle = "0.6"+wayland-client = "0.31"+# GNOME/Wayland window controls without an embedded title font.+winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }+ zbus = "5"  # The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Busdiff --git a/apps/artcraft-launcher/src/linux_window.rs b/apps/artcraft-launcher/src/linux_window.rsnew file mode 100644--- /dev/null+++ b/apps/artcraft-launcher/src/linux_window.rs@@ -0,0 +1,112 @@+//! Request winit's frame only on Wayland compositors without xdg-decoration.+//! Desktop/session names cannot identify the backend of an individual window.++use std::os::unix::net::UnixStream;+use std::path::PathBuf;+use std::sync::mpsc;+use std::time::Duration;++use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};+use wayland_client::globals::{GlobalListContents, registry_queue_init};+use wayland_client::protocol::wl_registry;+use wayland_client::{Connection, Dispatch, QueueHandle};++/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.+/// Never consume, duplicate or query that inherited connection: there may be no+/// independently addressable display, or WAYLAND_DISPLAY may name another server.+pub fn display_socket() -> Option<PathBuf> {+    if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {+        return None;+    }+    let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;+    let path = PathBuf::from(display);+    if path.is_absolute() {+        Some(path)+    } else {+        std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))+    }+}++pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {+    let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));+    if !wayland {+        log::debug!("window controls: non-Wayland window, retaining custom controls");+        return false;+    }+    let server_decorations = socket.and_then(probe);+    log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");+    native_frame(wayland, server_decorations)+}++fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {+    wayland && server_decorations == Some(false)+}++fn probe(socket: PathBuf) -> Option<bool> {+    // The compositor must not be able to block startup on a registry roundtrip.+    // On failure/timeout retain the app's own working title bar and buttons.+    let (send, receive) = mpsc::sync_channel(1);+    std::thread::Builder::new()+        .name("wayland-decorations".into())+        .spawn(move || {+            let _ = send.send(registry_has_decorations(socket));+        })+        .ok()?;+    receive.recv_timeout(Duration::from_millis(500)).ok().flatten()+}++fn registry_has_decorations(socket: PathBuf) -> Option<bool> {+    // A fresh connection leaves winit's event queue and inherited descriptors alone.+    let stream = UnixStream::connect(socket).ok()?;+    let connection = Connection::from_socket(stream).ok()?;+    let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;+    Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))+}++struct Registry;++impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {+    fn event(+        _state: &mut Self,+        _registry: &wl_registry::WlRegistry,+        _event: wl_registry::Event,+        _data: &GlobalListContents,+        _connection: &Connection,+        _queue: &QueueHandle<Self>,+    ) {+    }+}++#[cfg(test)]+mod tests {+    #[test]+    fn only_wayland_without_server_decorations_uses_the_frame() {+        for (wayland, server_decorations, expected) in [+            (false, None, false),+            (false, Some(false), false),+            (false, Some(true), false),+            (true, Some(true), false),+            (true, Some(false), true),+            (true, None, false),+        ] {+            assert_eq!(super::native_frame(wayland, server_decorations), expected);+        }+    }++    #[test]+    fn an_unavailable_display_keeps_the_custom_controls() {+        assert_eq!(super::probe("/nonexistent/artcraft-launcher-wayland-test".into()), None);+    }++    #[test]+    fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {+        let socket = std::env::temp_dir().join(format!("craft-launcher-decoration-timeout-{}", std::process::id()));+        let listener = std::os::unix::net::UnixListener::bind(&socket)?;+        let start = std::time::Instant::now();+        assert_eq!(super::probe(socket.clone()), None);+        assert!(start.elapsed() < std::time::Duration::from_secs(2));+        drop(listener);+        let _ = std::fs::remove_file(socket);+        Ok(())+    }+}diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs--- a/apps/artcraft-launcher/src/main.rs+++ b/apps/artcraft-launcher/src/main.rs@@ -5,6 +5,8 @@ #![cfg_attr(windows, windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_os = "linux")]+mod linux_window; mod logging; mod motion; mod settings_file;@@ -123,16 +125,26 @@ fn main() -> ExitCode {         launcher.start(settings, &waker);         window = launcher.tray.is_none();     }+    #[cfg(target_os = "linux")]+    let decoration_socket = linux_window::display_socket();+     // One pass per window: where a window can't hide, closing to the tray ends its pass and the     // launcher carries on without one until the window is asked for again.     loop {         if window {             let result = {                 let (waker, launcher) = (waker.clone(), &mut launcher);+                #[cfg(target_os = "linux")]+                let decoration_socket = decoration_socket.clone();                 eframe::run_native(                     "ArtCraft Launcher",                     native_options(hidden),                     Box::new(move |cc| {+                        #[cfg(target_os = "linux")]+                        {+                            launcher.host.title_bar = if linux_window::needs_native_frame(cc, decoration_socket) { TitleBar::Native } else { TitleBar::Custom };+                            cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(launcher.host.title_bar == TitleBar::Native));+                        }                         launcher.open(cc, &waker);                         Ok(Box::new(Shell::new(cc, launcher, hidden)))                     }),@@ -170,11 +182,11 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {         .with_inner_size([1120.0, 760.0])         .with_min_inner_size([720.0, 500.0])         .with_visible(!hidden);-    // The header is the title bar (see `DesktopHost::title_bar`).-    viewport = if cfg!(target_os = "macos") {-        viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false)-    } else {-        viewport.with_decorations(false)+    // Keep window decorations and the in-app header consistent.+    viewport = match desktop_title_bar() {+        TitleBar::MacOs => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),+        TitleBar::Native => viewport.with_decorations(true),+        TitleBar::Custom => viewport.with_decorations(false),     };     // On macOS the bundle's .icns is the icon. Any icon given to eframe (its egui logo when none     // is) replaces it in the Dock at runtime with a raw bitmap, which skips the system's icon@@ -476,13 +488,18 @@ fn claim_notification_identity() -> &'static Result<(), String> { }  struct DesktopHost {+    title_bar: TitleBar,     tray: bool,     quit: bool, } +fn desktop_title_bar() -> TitleBar {+    if cfg!(target_os = "macos") { TitleBar::MacOs } else { TitleBar::Custom }+}+ impl Host for DesktopHost {     fn title_bar(&self) -> TitleBar {-        if cfg!(target_os = "macos") { TitleBar::MacOs } else { TitleBar::Custom }+        self.title_bar     }      fn notify(&mut self, title: &str, body: &str) {@@ -576,7 +593,7 @@ impl Launcher {             system_theme: system_theme::Monitor::default(),             ui: LauncherUi::new(&windowless, backend, Settings::default()),             tray: None,-            host: DesktopHost { tray: false, quit: false },+            host: DesktopHost { title_bar: desktop_title_bar(), tray: false, quit: false },             starts,             windowless,             run_again,@@ -859,6 +876,13 @@ mod tests {      use super::*; +    #[cfg(target_os = "linux")]+    #[test]+    fn linux_defaults_to_custom_frame_until_backend_is_known() {+        assert_eq!(desktop_title_bar(), TitleBar::Custom);+        assert_eq!(native_options(false).viewport.decorations, Some(false));+    }+     const NOW: i64 = 1_791_500_000;      /// The bundle id is set once: asking again gives the first outcome, not the library's "already set" error.diff --git a/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml--- a/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml+++ b/packaging/linux/flatpak/ai.storyteller.artcraft-launcher.yml@@ -62,6 +62,7 @@ modules:       - cargo --offline build --release --locked -p artcraft-launcher       - install -Dm755 target/release/artcraft-launcher /app/bin/artcraft-launcher       - install -Dm644 NOTICE /app/share/doc/artcraft-launcher/NOTICE+      - install -Dm644 NOTICE /app/share/doc/artcraft-launcher/NOTICE       - install -Dm644 packaging/linux/ai.storyteller.artcraft-launcher.desktop /app/share/applications/ai.storyteller.artcraft-launcher.desktop       - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/       - >-diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh--- a/packaging/linux/package.sh+++ b/packaging/linux/package.sh@@ -135,6 +135,8 @@ if has appimage; then   fi   APPDIR="$WORK/ArtCraftLauncher.AppDir"   cp -R "$STAGE" "$APPDIR"+  # Preserve sctk-adwaita MIT license after removing package documentation.+  install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/$PKG/NOTICE"   # AppRun is a script, not a symlink: it installs the .desktop entry and icons into   # $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's   # app_id to our icon instead of the generic Wayland logo (opt-out env in the