Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix(xwayland): don't tell a Wine window it is maximized just for being tiled
Wine answers _NET_WM_STATE_MAXIMIZED by maximizing the Win32 window
itself on the next ConfigureNotify — the first time the tile moves. A
captionless window maximizes to the whole monitor, which Wine reads as
fullscreen and requests _NET_WM_STATE_FULLSCREEN: moving a tiled Ubisoft
Connect turned it fullscreen.
A Wine window (argv[0] is a Windows path, now the shared
`is_windows_path`) hears "maximized" from the Tiled layout only while it
already holds that state itself, so its own maximize button still works
and dragging it out of the grid still un-maximizes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/server/window_manager.rs | 22 +++++++++++++++++++---
src/server/xwayland_window.rs | 41 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 60 insertions(+), 3 deletions(-)
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index afdd418b..f0801de4 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -165,9 +165,16 @@ fn borrowable(saved: &SavedWindowState, program: Option<&str>) -> bool {
/// stood a minute over the empty desk every login (2026-09-26), waiting for
/// a window nothing had started.
fn relaunchable(cmdline: &str) -> bool {
- let b = cmdline.trim().as_bytes();
- let windows_path = b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\');
- !b.is_empty() && !windows_path
+ !cmdline.trim().is_empty() && !is_windows_path(cmdline)
+}
+
+/// Whether `s` starts with a Windows drive path (`C:\` or `C:/`) — the
+/// argv[0] every Wine/Proton process rewrites its command line to, which
+/// makes it the one reliable sign that a window belongs to Wine: its
+/// WM_CLASS is `steam_proton`, `steam_app_N` or the exe's own name.
+pub(crate) fn is_windows_path(s: &str) -> bool {
+ let b = s.trim().as_bytes();
+ b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
}
/// Whether a window mapping as (`app_id`, `program`) is the reconnect of
@@ -7711,6 +7718,15 @@ mod tests {
assert!(!relaunchable(""));
}
+ #[test]
+ fn wine_argv0_is_a_windows_path() {
+ assert!(is_windows_path(UPC));
+ assert!(is_windows_path("D:/Games/thing.exe"));
+ assert!(!is_windows_path("/usr/bin/wine"));
+ assert!(!is_windows_path("C:"));
+ assert!(!is_windows_path(""));
+ }
+
#[test]
fn a_reconnect_is_the_same_program_under_the_same_app_id() {
let upc = Some(UPC);
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 2553c775..67301605 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -33,6 +33,10 @@ pub struct XwaylandWindow {
/// `send_configure`, physical pixels; `None` until the first one. See
/// `needs_configure` for why this is kept apart from the wlroots mirror.
pub sent_geom: Option<X11Geom>,
+
+ /// Whether the client is a Wine/Proton process (`is_wine_process`),
+ /// read from /proc once and cached; `None` until first asked.
+ pub wine_process: Option<bool>,
}
/// A window geometry in X11 root coordinates — physical pixels under
@@ -352,6 +356,7 @@ impl XwaylandWindow {
map: std::mem::zeroed(),
unmap: std::mem::zeroed(),
sent_geom: None,
+ wine_process: None,
});
let raw = Box::into_raw(xwindow);
@@ -435,6 +440,23 @@ impl XwaylandWindow {
if scheduled.activated != sent.activated {
self.set_activated(scheduled.activated);
}
+ // A Tiled window is told it is maximized, but Wine answers that
+ // state by maximizing the Win32 window itself — on the next
+ // ConfigureNotify, i.e. the first time the tile moves. A window
+ // with no caption maximizes to the WHOLE monitor, Wine reads a
+ // monitor-sized rect as fullscreen and asks for
+ // _NET_WM_STATE_FULLSCREEN: moving a tiled Ubisoft Connect
+ // (2026-09-26) turned it fullscreen. So a Wine window hears
+ // "maximized" from the layout only while it already holds that
+ // state itself — its own maximize button, which wlroots writes into
+ // _NET_WM_STATE on receipt — so dragging such a window out of the
+ // grid still un-maximizes it.
+ if scheduled.maximized
+ && !((*self.xsurface).maximized_vert || (*self.xsurface).maximized_horz)
+ && self.is_wine_process()
+ {
+ scheduled.maximized = false;
+ }
if scheduled.maximized != sent.maximized {
ffi::wlr_xwayland_surface_set_maximized(self.xsurface, scheduled.maximized, scheduled.maximized);
}
@@ -463,6 +485,25 @@ impl XwaylandWindow {
false
}
+ /// Whether the client is a Wine/Proton process: its argv[0] is a
+ /// Windows path (`window_manager::is_windows_path`). Not cached while
+ /// the pid is still unknown, so a window read before _NET_WM_PID
+ /// arrives is asked again.
+ pub unsafe fn is_wine_process(&mut self) -> bool {
+ if let Some(wine) = self.wine_process {
+ return wine;
+ }
+ let pid = (*self.xsurface).pid;
+ if pid <= 0 {
+ return false;
+ }
+ let wine = crate::window_manager::proc_args(pid)
+ .first()
+ .map_or(false, |argv0| crate::window_manager::is_windows_path(argv0));
+ self.wine_process = Some(wine);
+ wine
+ }
+
/// The geometry wlroots currently reports for the X window.
pub unsafe fn reported_geom(&self) -> X11Geom {
X11Geom {