Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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;
}