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

commitf32348e85f389a114d870d11b448299c75d157cb
parent95572c01f5
authorLucas Galante <[email protected]>
date2026-09-14 08:39
fix(xwayland): a transient maps where it asked before mapping

handle_request_configure grants a request that arrives before the window
is mapped verbatim, but recorded nothing: the window had no geometry and
the arrange pass had not placed it. The grant updates the X surface's x/y
and nothing read them back at map, so a dialog that positioned itself
before showing -- Qt's move() before show(), how Houdini's HC Panel centres
itself -- mapped at the constructor's default origin, a hundred pixels in
from the desk corner. X had told the client the request was granted, so
it never asked again and the dialog sat there for good.

At map, a window with a parent whose WM_NORMAL_HINTS carry USPosition or
PPosition now takes the granted pre-map geometry as its virtual origin,
converted through the X11 scale and the current camera, and is marked
hint-placed so the spawn pan leaves the camera alone. A transient without
the flags is at whatever the X server defaulted to and keeps the
compositor's placement; top-level windows are untouched, restore and the
placement hints own those.

cce-shadow grows --xwayland, and its run/spawn now hand clients the
shadow's own X display and otherwise unset DISPLAY. Without that an X11
client run through a shadow fell through to the live session's X server
and put its windows on the user's screen.

Verified in headless Xwayland shadows with a GTK3 transient that moves
itself to (400,300) before show: patched, it maps at 400,300 and ctl
windows agrees; the installed binary maps it at the default 100,100. At
--scale 2 a request of (800,600) physical maps at logical (400,300) once
the parent's spawn pan has settled. 56 library tests pass.

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

 scripts/cce-shadow            | 24 +++++++++++++++++--
 src/server/xwayland_window.rs | 56 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 78 insertions(+), 2 deletions(-)

diff --git a/scripts/cce-shadow b/scripts/cce-shadow
index a415ed6..4229c4d 100755
--- a/scripts/cce-shadow
+++ b/scripts/cce-shadow
@@ -243,6 +243,8 @@ default instance is "default"; CCE_SHADOW_INSTANCE sets it for a whole shell.
                         runs do not inherit the previous one's windows)
       --exec <cmd>      run <cmd> inside the session once it is up
       --scale <n>       output scale, e.g. 2 for HiDPI (default 1)
+      --xwayland        start Xwayland too, for X11 clients (off by default:
+                        a shadow rarely needs it and it slows startup)
       --gpu <path>      pin the renderer (default: first non-NVIDIA render
                         node, because window capture fails on NVIDIA);
                         --gpu none leaves the choice to wlroots
@@ -370,14 +372,28 @@ require_running() {
 }
 
 # The environment a client (or ccectl) needs to talk to the shadow.
+# The X display of a shadow started with --xwayland, ":N", or nothing.
+# Only the compositor's log says which one Xwayland took.
+shadow_x_display() {
+    grep -a -m1 -oE 'Starting Xwayland on :[0-9]+' "$LOG" 2>/dev/null | awk '{print $4}' || true
+}
+
+# `-u DISPLAY` comes first: env(1) takes its options before assignments. It
+# is there so an X11 client cannot fall through to the LIVE session's X
+# server — without it a `run` of a GTK program under an Xwayland-less shadow
+# put its windows on the user's screen.
 shadow_env() {
     printf '%s\n' \
+        "-u" "DISPLAY" \
         "HOME=$SHADOW_HOME" \
         "XDG_CONFIG_HOME=$SHADOW_HOME/.config" \
         "XDG_STATE_HOME=$SHADOW_HOME/.local/state" \
         "XDG_CACHE_HOME=$SHADOW_HOME/.cache" \
         "XDG_DATA_HOME=$SHADOW_HOME/.local/share" \
         "WAYLAND_DISPLAY=$(shadow_display)"
+    local x; x=$(shadow_x_display)
+    [ -n "$x" ] && printf 'DISPLAY=%s\n' "$x"
+    return 0
 }
 
 # ── config seeding ───────────────────────────────────────────────────────────
@@ -463,7 +479,7 @@ seed_config() {
 
 # ── commands ─────────────────────────────────────────────────────────────────
 cmd_start() {
-    local fresh=0 restore=0 exec_cmd=':' scale=1 gpu="${CCE_SHADOW_GPU:-}" bin="" new=0
+    local fresh=0 restore=0 exec_cmd=':' scale=1 gpu="${CCE_SHADOW_GPU:-}" bin="" new=0 xwayland=0
     while [ $# -gt 0 ]; do
         case "$1" in
             --new)   new=1; shift ;;
@@ -472,6 +488,7 @@ cmd_start() {
             --restore) restore=1; shift ;;
             --exec)  exec_cmd=${2:?--exec needs a command}; shift 2 ;;
             --scale) scale=${2:?--scale needs a number}; shift 2 ;;
+            --xwayland) xwayland=1; shift ;;
             --gpu)   gpu=${2:?--gpu needs a device path}; shift 2 ;;
             --bin)   bin=${2:?--bin needs a path}; shift 2 ;;
             *) die "unknown option: $1" ;;
@@ -538,9 +555,12 @@ cmd_start() {
     # compositor dies during startup and leaves the tree behind.
     owner_token > "$RUN_DIR/owner"
 
+    local -a xwayland_args=(--no-xwayland)
+    [ "$xwayland" = 1 ] && xwayland_args=()
+
     note "starting $cce_fx (headless)"
     env "${env_args[@]}" setsid nohup \
-        "$cce_fx" --no-xwayland --log-level info -c "$exec_cmd" \
+        "$cce_fx" "${xwayland_args[@]}" --log-level info -c "$exec_cmd" \
         > "$LOG" 2>&1 &
     local started=$!
     printf '%s\n' "$started" > "$PIDFILE"
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index b158e94..95f6e16 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -548,6 +548,8 @@ unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
         (*(*xwindow).window).wm_scheduled.fullscreen_requested = crate::window::FullscreenRequest::Fullscreen(std::ptr::null_mut());
     }
 
+    place_transient_where_it_asked(xwindow);
+
     (*(*xwindow).window).state = WindowState::Initialized;
     if let Err(e) = (*(*xwindow).window).map() {
         log::error!("out of memory mapping window: {}", e);
@@ -558,6 +560,60 @@ unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
     (*(*(*xwindow).window).server).wm.dirty_windowing();
 }
 
+/// A transient that asked for a position before mapping maps there.
+///
+/// `handle_request_configure` grants a request that arrives before the
+/// window is mapped verbatim, but records nothing: the window has no
+/// geometry yet and the arrange pass has not placed it. The grant updates
+/// the X surface's x/y, and nothing read them back at map, so a dialog that
+/// positioned itself before showing -- Qt's `move()` before `show()`, which
+/// is how Houdini's HC Panel centres itself on the pane it was opened over
+/// -- mapped at the constructor's default origin instead, a hundred pixels
+/// in from the desk corner. The client never asks again, since X told it
+/// the request was granted, so the dialog sat there for good.
+///
+/// Only a window with a parent, and only when the client says the position
+/// is its own: ICCCM's `USPosition` / `PPosition` flags in WM_NORMAL_HINTS
+/// are what toolkits set for an explicit move before map. A transient
+/// without them is at whatever the X server defaulted to, and stays on the
+/// compositor's placement. Top-level windows keep theirs too: restore and
+/// the placement hints own those, and a transient is the one kind of window
+/// `try_restore` refuses to touch.
+unsafe fn place_transient_where_it_asked(xwindow: *mut XwaylandWindow) {
+    let xsurface = (*xwindow).xsurface;
+    if (*xsurface).parent.is_null() {
+        return;
+    }
+    let Some(asked) = (*xwindow).sent_geom else {
+        return;
+    };
+    let hints = (*xsurface).size_hints;
+    if hints.is_null() {
+        return;
+    }
+    let position_flags = ffi::xcb_icccm_size_hints_flags_t_XCB_ICCCM_SIZE_HINT_US_POSITION
+        | ffi::xcb_icccm_size_hints_flags_t_XCB_ICCCM_SIZE_HINT_P_POSITION;
+    if (*hints).flags & position_flags == 0 {
+        return;
+    }
+
+    let window = (*xwindow).window;
+    let s = x11_scale_for((*window).server, xsurface);
+    let log_x = from_x11(asked.x as i32, s);
+    let log_y = from_x11(asked.y as i32, s);
+    let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
+    (*window).virtual_x = vx;
+    (*window).virtual_y = vy;
+    // Placed by the client, like a picker placed by its hint: the camera
+    // must not pan to it on spawn or first focus.
+    (*window).hint_placed = true;
+    log::info!(
+        "XWayland transient mapped where it asked: title='{}' x11=({}, {}) logical=({}, {}) virtual=({:.1}, {:.1})",
+        (*window).get_title_string().unwrap_or_default(),
+        asked.x, asked.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);