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

commitf76b6e6d8ae24ea7ed8693bff2093960ea3a04c9
parentbab2ab347b
authorLucas Galante <[email protected]>
date2026-08-25 10:12
fix: stop an app rename from silently undecorating its window

rounded_apps matched app_ids with `a == app_id`, an exact-string
allowlist. Claude Desktop shipped as `claude-desktop` and renamed itself
to `com.anthropic.Claude`; the config entry stopped matching, and
is_decorated_app went false. That one predicate gates four effects, so
the window lost its rounded corner clip, blur-behind, drop shadow and
bevel simultaneously — with no error, no log line, and nothing in
`ccectl windows` to point at. The only way to even confirm it was a
full-output screenshot, since a per-window capture reads the client's
dmabuf, which is pre-composite and never shows the compositor's clip.

Two changes, matching and visibility:

app_id_matches() replaces `==` for both rounded_apps and bevel_apps.
Comparison is case-insensitive, and a pattern containing `*` is a glob,
so `rounded_apps "*claude*"` spans the rename. A pattern without `*` is
still exact, so existing configs are unaffected. Deliberately not a
general glob — no `?`, no character classes: an app_id is a flat
identifier, `*` covers the rename cases, and the rest is just surface
for a pattern to match something nobody intended.

`ccectl windows` now reports `decorated=` and `beveled=` per window, in
both the text and --json forms. The state was previously underivable
from outside the compositor, which is what made the original diagnosis
take a screenshot and a code read.

Unit tests cover the rename case, the exact-match and case rules, the
end anchors (including a trailing anchor that must not re-consume what a
leading one matched), and the degenerate patterns.

Verified live in a shadow session, on one unchanged foot window across a
config reload: decorated=false with an exact non-matching pattern, then
decorated=true under "*oot*" — and the captures confirm the pixels, a
square corner before and a clipped arc after.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/server/config.rs         |  11 +++-
 src/server/window_manager.rs | 118 +++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 124 insertions(+), 5 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index a90fb58..fba11e7 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -340,14 +340,21 @@ pub struct WindowManagerConfig {
     pub corner_shape: Option<f64>,
     /// Extra app_ids (beyond cce-* apps and SSD requesters) that get the full
     /// decorated-window treatment: rounded corner clip, blur-behind, shadow.
-    /// KDL: `rounded_apps "claude-desktop" "org.example.App"`.
+    /// KDL: `rounded_apps "*claude*" "org.example.App"`.
+    ///
+    /// Entries are matched case-insensitively by `app_id_matches`, and one
+    /// containing `*` is a glob. Prefer a glob for anything third-party: an
+    /// app_id is not a stable identifier, and an exact entry that stops
+    /// matching after a rename takes the corners, blur, shadow and bevel with
+    /// it in silence. `ccectl windows` reports the outcome as `decorated=`.
     pub rounded_apps: Option<Vec<String>>,
     /// Which apps the compositor draws an edge BEVEL on. Separate from
     /// `rounded_apps` because drawing one is only right for apps that do not
     /// bevel themselves — every cce-ui app already draws its own, so beveling
     /// them compositor-side doubles the rim. Unset falls back to
     /// `rounded_apps` (never the implicit cce-* set).
-    /// KDL: `bevel_apps "claude-desktop"`.
+    /// KDL: `bevel_apps "*claude*"`. Globbed like `rounded_apps`; reported by
+    /// `ccectl windows` as `beveled=`.
     pub bevel_apps: Option<Vec<String>>,
 }
 
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index d280479..67e62bd 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -221,6 +221,59 @@ pub(crate) fn manage_debug() -> bool {
     *FLAG.get_or_init(|| std::env::var_os("CCE_MANAGE_DEBUG").is_some())
 }
 
+/// Does a `rounded_apps` / `bevel_apps` config pattern match this app_id?
+///
+/// Case-insensitive, and a pattern containing `*` is a glob (`*` stands for any
+/// run of characters, including none). A pattern without `*` is still an exact
+/// comparison, so existing configs keep working unchanged.
+///
+/// Both of those exist because **an app_id is not a stable identifier**, and an
+/// exact allowlist fails silently when one changes. Claude Desktop shipped as
+/// `claude-desktop` and renamed itself to `com.anthropic.Claude`; the config
+/// entry stopped matching, and the window lost its rounded corners, blur,
+/// shadow and bevel at once — with no error, no log line, and nothing in
+/// `ccectl windows` to point at. `rounded_apps "*claude*"` survives that rename,
+/// and the `decorated=`/`beveled=` fields in `windows` make the outcome
+/// visible either way.
+///
+/// Deliberately NOT a general glob: no `?`, no character classes. An app_id is
+/// a flat identifier and `*` covers the rename cases; the rest is surface for
+/// a pattern to match something nobody intended.
+pub fn app_id_matches(pattern: &str, app_id: &str) -> bool {
+    if !pattern.contains('*') {
+        return pattern.eq_ignore_ascii_case(app_id);
+    }
+    let pattern = pattern.to_ascii_lowercase();
+    let app_id = app_id.to_ascii_lowercase();
+    // Segments between the stars. The first and last are anchored to the ends
+    // of the app_id; the ones between float, consuming left to right.
+    let segments: Vec<&str> = pattern.split('*').collect();
+    let last = segments.len() - 1;
+    let mut rest = app_id.as_str();
+    for (i, seg) in segments.iter().enumerate() {
+        if seg.is_empty() {
+            continue;
+        }
+        if i == 0 {
+            match rest.strip_prefix(seg) {
+                Some(r) => rest = r,
+                None => return false,
+            }
+        } else if i == last {
+            // ends_with on what is LEFT, not on the whole app_id: an anchored
+            // tail must not re-consume characters an earlier segment already
+            // matched ("ab*ab" must not match "ab").
+            return rest.ends_with(seg);
+        } else {
+            match rest.find(seg) {
+                Some(at) => rest = &rest[at + seg.len()..],
+                None => return false,
+            }
+        }
+    }
+    true
+}
+
 impl WindowManager {
     pub unsafe fn init(&mut self) -> Result<(), ()> {
         // This is a stub for the 0-arg struct instantiation.
@@ -837,7 +890,7 @@ impl WindowManager {
     /// predicate behind every radius/blur/shadow decision — the mirrored
     /// render sites must all agree or the effects visibly disagree per pass.
     pub fn is_decorated_app(&self, app_id: &str) -> bool {
-        app_id.starts_with("cce-") || self.rounded_apps.iter().any(|a| a == app_id)
+        app_id.starts_with("cce-") || self.rounded_apps.iter().any(|a| app_id_matches(a, app_id))
     }
 
     /// Should the compositor draw an edge bevel on this app? Unlike
@@ -846,7 +899,7 @@ impl WindowManager {
     /// the rim. Only apps named in `bevel_apps` (defaulting to `rounded_apps`)
     /// get one.
     pub fn is_beveled_app(&self, app_id: &str) -> bool {
-        self.bevel_apps.iter().any(|a| a == app_id)
+        self.bevel_apps.iter().any(|a| app_id_matches(a, app_id))
     }
 
     pub unsafe fn match_and_remove_restore_state(&mut self, app_id: &str, title: &str) -> Option<SavedWindowState> {
@@ -4037,11 +4090,19 @@ impl WindowManager {
                                 "has_parent": (*w).has_parent,
                                 "focused": w == focused_window,
                                 "ssd": (*w).wm_requested.ssd,
+                                // Why a window has (or lacks) rounded corners,
+                                // blur and shadow. Without it the only way to
+                                // tell is a full-output screenshot: a
+                                // per-window capture reads the client's
+                                // dmabuf, which is pre-composite and never
+                                // shows the compositor's clip.
+                                "decorated": self.is_decorated_app(&app_id),
+                                "beveled": self.is_beveled_app(&app_id),
                             }).to_string());
                             out.push('\n');
                         } else {
                             out.push_str(&format!(
-                                "window id={} app_id={} title=\"{}\" mode={} x={} y={} w={} h={} vx={:.1} vy={:.1} cell={} minimized={} has_parent={} focused={} ssd={}\n",
+                                "window id={} app_id={} title=\"{}\" mode={} x={} y={} w={} h={} vx={:.1} vy={:.1} cell={} minimized={} has_parent={} focused={} ssd={} decorated={} beveled={}\n",
                                 (*w).ref_key.index,
                                 app_id,
                                 title,
@@ -4057,6 +4118,8 @@ impl WindowManager {
                                 (*w).has_parent,
                                 w == focused_window,
                                 (*w).wm_requested.ssd,
+                                self.is_decorated_app(&app_id),
+                                self.is_beveled_app(&app_id),
                             ));
                         }
                     }
@@ -5390,6 +5453,55 @@ unsafe extern "C" fn handle_border_fade_tick(data: *mut std::ffi::c_void) -> std
 mod tests {
     use super::*;
 
+    #[test]
+    fn app_id_matches_is_exact_without_a_star() {
+        assert!(app_id_matches("claude-desktop", "claude-desktop"));
+        assert!(!app_id_matches("claude-desktop", "com.anthropic.Claude"));
+        // An exact pattern must not match a longer id that merely contains it,
+        // or `rounded_apps "foot"` would take in "footbar".
+        assert!(!app_id_matches("foot", "footbar"));
+        assert!(!app_id_matches("oot", "foot"));
+    }
+
+    #[test]
+    fn app_id_matches_ignores_case() {
+        assert!(app_id_matches("com.anthropic.claude", "com.anthropic.Claude"));
+        assert!(app_id_matches("*CLAUDE*", "com.anthropic.Claude"));
+    }
+
+    /// The regression this matcher exists for: one pattern spanning an app's
+    /// rename, so the window does not silently lose its decoration.
+    #[test]
+    fn app_id_matches_spans_a_rename() {
+        for id in ["claude-desktop", "com.anthropic.Claude", "Claude"] {
+            assert!(app_id_matches("*claude*", id), "{id} should match *claude*");
+        }
+        assert!(!app_id_matches("*claude*", "org.inkscape.Inkscape"));
+    }
+
+    #[test]
+    fn app_id_matches_anchors_the_ends() {
+        assert!(app_id_matches("com.anthropic.*", "com.anthropic.Claude"));
+        assert!(!app_id_matches("com.anthropic.*", "org.example.anthropic"));
+        assert!(app_id_matches("*.Claude", "com.anthropic.Claude"));
+        assert!(!app_id_matches("*.Claude", "com.anthropic.ClaudeX"));
+        // A trailing anchor may not re-consume what the leading one took.
+        assert!(!app_id_matches("ab*ab", "ab"));
+        assert!(app_id_matches("ab*ab", "abab"));
+    }
+
+    #[test]
+    fn app_id_matches_handles_degenerate_patterns() {
+        assert!(app_id_matches("*", "anything"));
+        assert!(app_id_matches("*", ""));
+        assert!(app_id_matches("**", "anything"));
+        assert!(!app_id_matches("", "anything"));
+        assert!(app_id_matches("", ""));
+        // Interior segments consume left to right and may repeat.
+        assert!(app_id_matches("a*b*c", "axxbyyc"));
+        assert!(!app_id_matches("a*b*c", "acb"));
+    }
+
     #[test]
     #[allow(invalid_value)]
     fn test_last_window_state_matching() {