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