git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit831f5b3b403ec4efe3ccae88ca9fc24fdf1d75c4
parent89b01d4cad
authorLucas Galante <[email protected]>
date2026-09-18 21:04
feat(xwayland): leave no-activate helper windows to their app

Ubisoft Connect keeps an untitled WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW
window exactly behind its borderless main window — the shadow-window
trick, invisible on Windows. Wine maps it as a top-level with WM_HINTS
input = False and _NET_WM_STATE_SKIP_TASKBAR/PAGER; managed like an app
window it was restored to a saved spot, pulled on-desk and raised: a
blank white window with the app icon over the launcher and the game.

`Window::is_shy` names that shape (X11 top-level, declines input,
skip-taskbar), and such a window is left to its app: try_restore skips
it, it maps at the X window's own position (`place_shy_where_it_is`),
its configure requests are granted position and size like a
transient's, Seat::focus refuses it, focus cycling skips it,
raise_window leaves it, and manage_start links it at the bottom of the
render list.

Verified in an --xwayland --scale 2 shadow with a GTK3 X11 window
(accept_focus off, skip taskbar/pager, moved before show) and a seeded
saved state: mapped at its own (300, 200) X position, not restored,
focus-window refused, rendered beneath a later-mapped normal window,
and a client move granted.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/seat.rs            |  6 ++++++
 src/server/window.rs          | 44 ++++++++++++++++++++++++++++++++++++++++++-
 src/server/window_manager.rs  |  7 ++++++-
 src/server/xwayland_window.rs | 39 ++++++++++++++++++++++++++++++++++++--
 4 files changed, 92 insertions(+), 4 deletions(-)

diff --git a/src/server/seat.rs b/src/server/seat.rs
index 969b555..17a18f4 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -423,6 +423,12 @@ impl Seat {
                 log::info!("[FocusDebug] Seat::focus blocking focus to status bar/wallpaper/grid window");
                 return;
             }
+            // A shy helper window declines focus by its own hints
+            // (WM_HINTS input = False); honour that — see `Window::is_shy`.
+            if !window.is_null() && (*window).is_shy() {
+                log::info!("[FocusDebug] Seat::focus blocking focus to a no-activate helper window");
+                return;
+            }
         }
 
         if let Focus::Window(window) = new_focus {
diff --git a/src/server/window.rs b/src/server/window.rs
index f8bf111..6c2ef43 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1078,6 +1078,33 @@ impl Window {
             || self.get_app_id_string().as_deref() == Some("cce-cloud")
     }
 
+    /// A "shy" X11 window: a top-level that declines input focus
+    /// (WM_HINTS input = False) and asks to be skipped by the taskbar —
+    /// what Wine emits for a WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW window.
+    /// Apps use those as helpers they place themselves: Ubisoft Connect
+    /// keeps an untitled one exactly behind its borderless main window
+    /// (the shadow-window trick), where Windows never shows it. Managed
+    /// like an app window it was restored to a saved spot, pulled on-desk
+    /// and raised — a blank white window with the app icon, over
+    /// everything. So it is left to the client: no saved-state restore, its
+    /// own position honoured at map and on request, never focused, never
+    /// raised, stacked at the bottom.
+    pub unsafe fn is_shy(&self) -> bool {
+        let WindowImpl::Xwayland(xwindow) = self.impl_type else {
+            return false;
+        };
+        if xwindow.is_null() || (*xwindow).xsurface.is_null() {
+            return false;
+        }
+        let xs = (*xwindow).xsurface;
+        if !(*xs).parent.is_null() || !(*xs).skip_taskbar || (*xs).hints.is_null() {
+            return false;
+        }
+        let hints = (*xs).hints;
+        let input_flag = ffi::xcb_icccm_wm_t_XCB_ICCCM_WM_HINT_INPUT as i32;
+        (*hints).flags & input_flag != 0 && (*hints).input == 0
+    }
+
     pub unsafe fn try_restore(&mut self) {
         if self.restored {
             return;
@@ -1130,6 +1157,17 @@ impl Window {
             self.restored = true;
             return;
         }
+        // A shy helper window (no-activate, skip-taskbar) is placed by its
+        // app, relative to the app's own windows — see `is_shy`.
+        if self.is_shy() {
+            log::info!(
+                "Not restoring saved state for {:?} ({}): a no-activate helper window, its app places it",
+                self.get_title_string().unwrap_or_default(),
+                self.get_app_id_string().unwrap_or_default()
+            );
+            self.restored = true;
+            return;
+        }
         let app_id_str = self.get_app_id_string().unwrap_or_default();
         if app_id_str.is_empty()
             || app_id_str.starts_with("cce-status")
@@ -2310,7 +2348,11 @@ impl Window {
                             wl_list_remove_and_reinit(&mut self.node.link as *mut ffi::wl_list as *mut WlList);
                         }
                         let rendering_list = &mut (*self.server).wm.rendering_requested.list as *mut ffi::wl_list as *mut WlList;
-                        wl_list_insert((*rendering_list).prev, &mut self.node.link as *mut ffi::wl_list as *mut WlList);
+                        // The tail is the top of the stack. A shy helper
+                        // window (`is_shy`) links at the head instead — beneath
+                        // the app's own windows, where its app keeps it.
+                        let anchor = if self.is_shy() { rendering_list } else { (*rendering_list).prev };
+                        wl_list_insert(anchor, &mut self.node.link as *mut ffi::wl_list as *mut WlList);
 
                         if self.foreign_toplevel_handle.is_null() {
                             let list = (*self.server).foreign_toplevel_list;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 0a7eaa6..68103e5 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1804,7 +1804,8 @@ impl WindowManager {
                 && !(*w).minimized
                 && !is_status
                 && !(*w).is_grid()
-                && !(*w).is_overlay_ui();
+                && !(*w).is_overlay_ui()
+                && !(*w).is_shy();
             windows.push(ActionWindow {
                 id: WindowId((*w).ref_key),
                 app_id,
@@ -4359,6 +4360,10 @@ impl WindowManager {
         if window.is_null() {
             return;
         }
+        // A shy helper window stays where its app stacked it: beneath.
+        if (*window).is_shy() {
+            return;
+        }
         let node_link = &mut (*window).node.link as *mut ffi::wl_list as *mut WlList;
         let list_head = &mut self.rendering_requested.list as *mut ffi::wl_list as *mut WlList;
         if !node_link.is_null() && !list_head.is_null() {
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 883ac69..2553c77 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -589,6 +589,7 @@ unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
     }
 
     place_transient_where_it_asked(xwindow);
+    place_shy_where_it_is(xwindow);
 
     (*(*xwindow).window).state = WindowState::Initialized;
     if let Err(e) = (*(*xwindow).window).map() {
@@ -654,6 +655,37 @@ unsafe fn place_transient_where_it_asked(xwindow: *mut XwaylandWindow) {
     );
 }
 
+/// A shy helper window (`Window::is_shy`) maps where its app put it: the
+/// X window's own geometry, which Wine set from the app's CreateWindow
+/// position — for Ubisoft Connect's shadow window, exactly its main
+/// window's rect. The compositor's spawn placement would put it at the
+/// default origin, in front of everything, as a blank white window.
+unsafe fn place_shy_where_it_is(xwindow: *mut XwaylandWindow) {
+    let window = (*xwindow).window;
+    if !(*window).is_shy() {
+        return;
+    }
+    let xsurface = (*xwindow).xsurface;
+    let s = x11_scale_for((*window).server, xsurface);
+    let log_x = from_x11((*xsurface).x as i32, s);
+    let log_y = from_x11((*xsurface).y as i32, s);
+    let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
+    (*window).virtual_x = vx;
+    (*window).virtual_y = vy;
+    (*window).box_geom.x = log_x;
+    (*window).box_geom.y = log_y;
+    (*window).rendering_requested.x = log_x;
+    (*window).rendering_requested.y = log_y;
+    // Placed by the client: no spawn pan to it, ever.
+    (*window).hint_placed = true;
+    log::info!(
+        "XWayland no-activate helper window mapped where it is: title='{}' class='{}' x11=({}, {}) logical=({}, {}) virtual=({:.1}, {:.1})",
+        (*window).get_title_string().unwrap_or_default(),
+        (*window).get_app_id_string().unwrap_or_default(),
+        (*xsurface).x, (*xsurface).y, log_x, log_y, vx, vy,
+    );
+}
+
 unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let xwindow = crate::container_of!(listener, XwaylandWindow, unmap);
     handle_unmap_impl(xwindow);
@@ -719,8 +751,11 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
     // virtual origin moved with it. Not while the compositor has it
     // fullscreen or tiled: then the size is the compositor's (below).
     let exempt_self_placed = !has_parent && !is_fullscreen && !is_tiled && window_is_hidpi_exempt(window);
+    // A shy helper window (`Window::is_shy`) is placed by its app, which
+    // moves it to track its main window: position and size granted.
+    let shy_self_placed = !has_parent && !is_fullscreen && !is_tiled && (*window).is_shy();
 
-    if has_parent || exempt_self_placed {
+    if has_parent || exempt_self_placed || shy_self_placed {
         // Granted on the logical grid rather than verbatim — see `snap_x11`.
         // The logical values below are what the window's geometry becomes, so
         // handing X anything else is handing it a number this compositor
@@ -786,7 +821,7 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
         (*window).virtual_x = vx;
         (*window).virtual_y = vy;
-        if exempt_self_placed {
+        if exempt_self_placed || shy_self_placed {
             // Placed by the client: the camera must not pan to it on spawn.
             (*window).hint_placed = true;
         }