Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix(restore): an empty title is not an exact title match
Two untitled windows of one app_id matched in the exact pass, which made
it an app_id-only match that skipped the same-program check: any
untitled steam_proton window (Wine's tray window, upc.exe's untitled
helpers) could claim another's saved entry. The exact pass now needs a
non-empty title; an untitled window falls through to the app_id-only
pass, which still restores a relaunched app at app_id time, before its
title arrives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/server/window_manager.rs | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index c11a12e6..56eb9b91 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -143,6 +143,15 @@ fn saved_by_program(saved: &SavedWindowState, program: &str) -> bool {
.map_or(false, |rest| rest.is_empty() || rest.starts_with(' '))
}
+/// 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
+/// window and any other untitled helper of a `steam_proton` app all
+/// claimed one another's entries through it.
+fn titles_match(a: &str, b: &str) -> bool {
+ !a.is_empty() && a == b
+}
+
/// The state matchers' fuzzy title pass: equal once a trailing `*` (an
/// editor's unsaved marker) is stripped, or one a prefix of the other.
/// An empty title resembles nothing — as a prefix it would resemble every
@@ -1471,8 +1480,9 @@ impl WindowManager {
if app_id.is_empty() {
return None;
}
- // First pass: Exact match (app_id AND title)
- if let Some(pos) = self.restore_queue.iter().position(|w| w.app_id == app_id && w.title == title) {
+ // First pass: Exact match (app_id AND title). An empty title is no
+ // identity (`titles_match`); it falls through to the app_id-only pass.
+ if let Some(pos) = self.restore_queue.iter().position(|w| w.app_id == app_id && titles_match(title, &w.title)) {
let entry = self.restore_queue.remove(pos);
self.remove_placeholder_for(&entry);
return Some(entry);
@@ -1503,8 +1513,8 @@ impl WindowManager {
if app_id.is_empty() {
return None;
}
- // First pass: Exact match (app_id AND title)
- if let Some(w) = self.last_window_states.iter().find(|w| w.app_id == app_id && w.title == title) {
+ // First pass: Exact match (app_id AND title), as above
+ if let Some(w) = self.last_window_states.iter().find(|w| w.app_id == app_id && titles_match(title, &w.title)) {
return Some(w.clone());
}
// Second pass: Fuzzy title match
@@ -7631,6 +7641,13 @@ mod tests {
assert!(!saved_by_program(&proton_entry("", "steam_proton"), EXPLORER));
}
+ #[test]
+ fn empty_titles_match_nothing_exactly() {
+ assert!(titles_match("Ubisoft Connect", "Ubisoft Connect"));
+ assert!(!titles_match("", ""));
+ assert!(!titles_match("Ubisoft Connect", "Ubisoft"));
+ }
+
#[test]
fn empty_titles_resemble_nothing() {
assert!(titles_resemble("Doc.txt*", "Doc.txt"));
@@ -7662,6 +7679,9 @@ mod tests {
}
wm.last_window_states[0] = proton_entry("", &format!("{EXPLORER} /desktop"));
unsafe {
+ // Untitled on both sides is not an exact match, so the program
+ // check applies to it too.
+ assert!(wm.match_last_window_state("steam_proton", "", Some(UPC)).is_none());
assert!(wm.match_last_window_state("steam_proton", "Ubisoft Connect", Some(UPC)).is_none());
assert!(wm.match_last_window_state("steam_proton", "", Some(EXPLORER)).is_some());
}