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

commit9f069fde884375835d134fd0c4fa47874d6371cb
parent9af3f26383
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 17:55
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 {