Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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() {