Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix(focus): a reconnect is the same program, not just the same app_id
A window that vanishes unbidden lets the next window of its app_id,
within 5 s, map unfocused -- a cce-ui client rebuilding its surface on
a fresh connection must not steal focus back. Keyed on app_id alone,
that caught every Proton program (all are steam_proton): Trackmania,
launched from Ubisoft Connect a second after one of the launcher's own
windows closed, was held unfocused as the launcher "reconnecting", and
the fullscreen key went to Claude instead.
The vanish record now carries the program (proc_args argv[0]), and a
reconnect needs both to match; an unreadable program (process already
gone) falls back to the app_id, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 11 ++++++++
src/server/window.rs | 6 +++--
src/server/window_manager.rs | 64 +++++++++++++++++++++++++++++++++++---------
3 files changed, 66 insertions(+), 15 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 460a9f5a..cb8daf33 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -692,6 +692,17 @@ what the user pans along (cce-data-editor parked left of the first column
came back mid-view every login before 2026-09-14). The recall is for a
window with no tiled neighbour within a screen.
+**A client reconnecting maps unfocused**: a window that vanishes without
+the compositor asking it to close (`Window::unmap` → `note_vanished`) lets
+the next window of the same app_id AND the same program (`proc_args`
+argv[0]) within 5 s map without taking focus (`take_recent_vanish`) — a
+cce-ui client rebuilding its surface on a fresh connection must not steal
+focus back. The program half is from 2026-09-26: keyed on app_id alone,
+every Proton program is `steam_proton`, so a game Ubisoft Connect launched
+a second after closing one of its own windows was held unfocused, and the
+user's fullscreen key went to the window that kept focus. An unreadable
+program (the process already gone) falls back to the app_id.
+
Restored windows map unfocused, and their FIRST focus pans the camera only
once the session has seen deliberate input (`WindowManager::startup_input_seen`,
gated in `Seat::focus`), so apps settling in at login do not drag the view
diff --git a/src/server/window.rs b/src/server/window.rs
index 178e3acd..571d07ea 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -2032,7 +2032,8 @@ impl Window {
// does too and which must still focus and spawn-pan normally.
if should_focus {
if let Some(app_id) = self.get_app_id_string() {
- if (*self.server).wm.take_recent_vanish(&app_id) {
+ let program = crate::window_manager::proc_args(self.unreliable_pid()).into_iter().next();
+ if (*self.server).wm.take_recent_vanish(&app_id, program.as_deref()) {
log::info!("[FocusRestore] Blocking focus steal by reconnecting client {:?} ({})", self.get_title(), app_id);
should_focus = false;
}
@@ -2130,7 +2131,8 @@ impl Window {
)
{
if let Some(app_id) = self.get_app_id_string() {
- (*self.server).wm.note_vanished(app_id);
+ let program = crate::window_manager::proc_args(self.unreliable_pid()).into_iter().next();
+ (*self.server).wm.note_vanished(app_id, program);
}
}
wl_listener_remove_safe(&mut self.commit);
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index ef84d210..af9d88ae 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -157,6 +157,23 @@ fn borrowable(saved: &SavedWindowState, program: Option<&str>) -> bool {
!saved.title.is_empty() && same_program(saved, program)
}
+/// Whether a window mapping as (`app_id`, `program`) is the reconnect of
+/// one that vanished as (`gone_app_id`, `gone_program`): the same app_id
+/// AND the same program. An app_id alone is too coarse — every Proton
+/// program is `steam_proton`, so a game launched from Ubisoft Connect,
+/// mapping a second after one of the launcher's own windows closed, was
+/// held unfocused as the launcher "reconnecting", and the user's
+/// fullscreen key went to whatever still had focus (2026-09-26). A
+/// program unknown on either side (the process already gone) falls back
+/// to the app_id alone, as before.
+fn is_reconnect(gone_app_id: &str, gone_program: Option<&str>, app_id: &str, program: Option<&str>) -> bool {
+ gone_app_id == app_id
+ && match (gone_program, program) {
+ (Some(a), Some(b)) => a == b,
+ _ => true,
+ }
+}
+
/// The state matchers' exact title pass. Two empty titles do not match: an
/// untitled window has no identity beyond its app_id, so "" == "" was an
/// app_id-only match that skipped `same_program` — Wine's untitled tray
@@ -500,10 +517,11 @@ pub struct WindowManager {
/// focus from the restored session's focused window.
pub startup_input_seen: bool,
/// app_ids whose window went away without the compositor ever asking it
- /// to close, and when. A client that loses its Wayland connection lands
- /// here and reappears a moment later having rebuilt its surface; see
- /// `take_recent_vanish`.
- pub vanished_windows: Vec<(String, std::time::Instant)>,
+ /// to close, with the program that owned it (`proc_args` argv[0], when
+ /// still readable) and when. A client that loses its Wayland connection
+ /// lands here and reappears a moment later having rebuilt its surface;
+ /// see `take_recent_vanish`.
+ pub vanished_windows: Vec<(String, Option<String>, std::time::Instant)>,
pub last_viewport_zoom: f64,
pub last_viewport_pan_x: f64,
pub last_viewport_pan_y: f64,
@@ -4376,22 +4394,28 @@ impl WindowManager {
self.focus_history.retain(|&w| w != window);
}
- /// Record that this app_id's window disappeared unbidden.
- pub fn note_vanished(&mut self, app_id: String) {
+ /// Record that this app_id's window disappeared unbidden, and which
+ /// program owned it (`None` once the process is gone).
+ pub fn note_vanished(&mut self, app_id: String, program: Option<String>) {
let now = std::time::Instant::now();
self.vanished_windows
- .retain(|(_, at)| now.duration_since(*at) < RECONNECT_FOCUS_GRACE);
- self.vanished_windows.push((app_id, now));
+ .retain(|(_, _, at)| now.duration_since(*at) < RECONNECT_FOCUS_GRACE);
+ self.vanished_windows.push((app_id, program, now));
}
- /// Whether this app_id vanished unbidden within the grace, consuming the
- /// record so one disappearance excuses exactly one re-map — a client that
+ /// Whether this window's program recently lost one of its windows
+ /// unbidden within the grace — the reconnect case — consuming the record
+ /// so one disappearance excuses exactly one re-map: a client that
/// crashes twice does not get a standing exemption.
- pub fn take_recent_vanish(&mut self, app_id: &str) -> bool {
+ pub fn take_recent_vanish(&mut self, app_id: &str, program: Option<&str>) -> bool {
let now = std::time::Instant::now();
self.vanished_windows
- .retain(|(_, at)| now.duration_since(*at) < RECONNECT_FOCUS_GRACE);
- match self.vanished_windows.iter().position(|(id, _)| id == app_id) {
+ .retain(|(_, _, at)| now.duration_since(*at) < RECONNECT_FOCUS_GRACE);
+ match self
+ .vanished_windows
+ .iter()
+ .position(|(id, prog, _)| is_reconnect(id, prog.as_deref(), app_id, program))
+ {
Some(i) => {
self.vanished_windows.remove(i);
true
@@ -7663,6 +7687,20 @@ mod tests {
assert!(!saved_by_program(&proton_entry("", "steam_proton"), EXPLORER));
}
+ #[test]
+ fn a_reconnect_is_the_same_program_under_the_same_app_id() {
+ let upc = Some(UPC);
+ let game = Some(r"C:\Program Files\Ubisoft\Trackmania\Trackmania.exe");
+ // The launcher's own window coming back is a reconnect...
+ assert!(is_reconnect("steam_proton", upc, "steam_proton", upc));
+ // ...a game it started, sharing only the app_id, is not.
+ assert!(!is_reconnect("steam_proton", upc, "steam_proton", game));
+ // Unknown program on either side: the app_id decides, as before.
+ assert!(is_reconnect("steam_proton", None, "steam_proton", game));
+ assert!(is_reconnect("cce-files", Some("/usr/bin/cce-files"), "cce-files", None));
+ assert!(!is_reconnect("cce-files", None, "cce-mail", None));
+ }
+
#[test]
fn empty_titles_match_nothing_exactly() {
assert!(titles_match("Ubisoft Connect", "Ubisoft Connect"));