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
@@ -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",@@ -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) ----------------------------------------@@ -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-Busnew file mode 100644@@ -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(())+ }+}@@ -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.@@ -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/ - >-@@ -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