git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commitac96568428496660701706eaeb8f30c053368f36
parentb81c892e19
authorLucas Galante <[email protected]>
date2026-07-13 21:18
refactor: structured ccectl consumption — JSON windows, direct SIGTERM, adjust-mode query (proposal phase 3)

The window picker and get_currently_focused_window now run `ccectl windows
--json` and parse one JSON object per line, so titles containing quotes or
spaces no longer break silently. parse_ccectl_window_any_line detects the
format per line: an older compositor ignores --json and answers in text,
which falls back to the legacy parser (kept, with its quote-truncation
limitation now documented as fallback-only). get_currently_focused_window
loses its hand-rolled inline copy of the text parser.

Killing cce-cloud popups sends SIGTERM via libc::kill (send_sigterm) instead
of shelling out to `kill`, and logs delivery failures.

Mode toggles now actually reach the compositor: the old `cce control ...`
invocations hit the compositor binary's clap parser, which rejected the
positional args and exited — they were silent no-ops. Both toggles go
through ccectl, and adjust-position-mode state is read back with
`ccectl adjust-position-mode query` instead of the /tmp sentinel file, so
the compositor is the single source of truth. Requires the paired cce
commit (bdc2bb6) for query support — on an older compositor the query arg
toggles instead.

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

 CLAUDE.md     |  16 +++++---
 Cargo.toml    |   1 +
 src/cloud.rs  |  65 ++++++++++++------------------
 src/config.rs |  10 -----
 src/main.rs   | 126 ++++++++++++++++++++++++++++++++++++++++++++++++----------
 5 files changed, 144 insertions(+), 74 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a2967e0..9066c4f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -84,10 +84,13 @@ listen to tray D-Bus, etc.:
   `/tmp/cce-status-interface-switcher-{WAYLAND_DISPLAY}.sock`; a line on it fires
   `SwitcherTriggered`.
 
-Outbound actions shell out to `ccectl` (`view <viewport>`, `windows`, `focus-window`,
-`viewport-layout`, `window-switcher`) and `cce control ...`; both binaries are resolved
-from `~/.local/bin` first (`get_ccectl_cmd`/`get_cce_cmd`). Keyboard alt-tab switching
-is delegated to the compositor (`ccectl window-switcher`) — don't reimplement it here.
+Outbound actions shell out to `ccectl` (`view <viewport>`, `windows --json`,
+`focus-window`, `viewport-layout`, `window-switcher`, `status-hide-mode`,
+`adjust-position-mode`), resolved from `~/.local/bin` first (`get_ccectl_cmd`).
+`ccectl windows --json` returns one JSON object per line; the text format is kept only
+as a parse fallback for older compositors (`parse_ccectl_window_any_line` handles
+both). Keyboard alt-tab switching is delegated to the compositor
+(`ccectl window-switcher`) — don't reimplement it here.
 
 **Popups are `cce-cloud` processes**, not surfaces of this app: the window picker, tray
 context menus, and the layout-mode menu each spawn `cce-cloud`, pipe it a JSON page
@@ -122,4 +125,7 @@ mtime in `tick()`, so there is no reload event to wire up.
   the layout indicator opens the layout-mode menu; tray icons left-click activate /
   right-click open their DBusMenu.
 - `ToggleHideModules` / `ToggleAdjustPositionMode` mirror their state to the compositor
-  via `cce control status-hide-mode|adjust-position-mode true|false`.
+  via `ccectl status-hide-mode|adjust-position-mode true|false`; the adjust-mode state
+  is read back with `ccectl adjust-position-mode query` (the compositor is the single
+  source of truth — the old `/tmp/cce-status-interface-adjust-mode` sentinel file is
+  no longer consulted).
diff --git a/Cargo.toml b/Cargo.toml
index 22f1204..b71975e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,7 @@ tokio-stream = "0.1"
 zbus = "4"
 resvg = "0.41.0"
 png = "0.17"
+libc = "0.2"
 log = "0.4"
 env_logger = "0.11"
 kdl = "4.6"
diff --git a/src/cloud.rs b/src/cloud.rs
index d50cbaa..11774a5 100644
--- a/src/cloud.rs
+++ b/src/cloud.rs
@@ -91,45 +91,31 @@ pub(crate) fn parse_menu_item(
 }
 
 pub(crate) fn get_currently_focused_window() -> Option<String> {
-    let output = std::process::Command::new(get_ccectl_cmd())
-        .arg("windows")
-        .output();
-    if let Ok(out) = output {
-        let stdout_str = String::from_utf8_lossy(&out.stdout);
-        for line in stdout_str.lines() {
-            let focused = if let Some(idx) = line.find("focused=") {
-                let rest = &line[idx + 8..];
-                let end = rest.find(' ').unwrap_or(rest.len());
-                rest[..end].trim() == "true"
-            } else {
-                false
-            };
+    let out = std::process::Command::new(get_ccectl_cmd())
+        .args(["windows", "--json"])
+        .output()
+        .ok()?;
+    let stdout_str = String::from_utf8_lossy(&out.stdout);
+    stdout_str
+        .lines()
+        .filter_map(crate::parse_ccectl_window_any_line)
+        .find(|(_, app_id, _, focused)| {
+            *focused && app_id != "cce-status" && app_id != "cce-cloud"
+        })
+        .map(|(id, _, _, _)| id)
+}
 
-            if focused {
-                let app_id = if let Some(idx) = line.find("app_id=") {
-                    let rest = &line[idx + 7..];
-                    let end = rest.find(' ').unwrap_or(rest.len());
-                    rest[..end].to_string()
-                } else {
-                    continue;
-                };
-                if app_id == "cce-status" || app_id == "cce-cloud" {
-                    continue;
-                }
-                
-                // Return the unique window ID if present, otherwise fall back to app_id
-                let id = if let Some(idx) = line.find("window id=") {
-                    let rest = &line[idx + 10..];
-                    let end = rest.find(' ').unwrap_or(rest.len());
-                    rest[..end].to_string()
-                } else {
-                    app_id
-                };
-                return Some(id);
-            }
-        }
+/// Send SIGTERM to `pid` directly instead of shelling out to `kill`,
+/// logging when the signal cannot be delivered.
+pub(crate) fn send_sigterm(pid: u32) {
+    let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
+    if ret != 0 {
+        log::warn!(
+            "[cloud] SIGTERM to pid {} failed: {}",
+            pid,
+            std::io::Error::last_os_error()
+        );
     }
-    None
 }
 
 pub(crate) async fn show_cce_cloud_menu(
@@ -382,9 +368,10 @@ pub(crate) fn spawn_window_picker(
     source: String,
 ) {
     std::thread::spawn(move || {
-        // Run "ccectl windows" to fetch the windows list
+        // Fetch the windows list; an older compositor ignores --json and
+        // answers in the text format, which parse_ccectl_windows detects.
         let output = std::process::Command::new(get_ccectl_cmd())
-            .arg("windows")
+            .args(["windows", "--json"])
             .output();
 
         let windows = if let Ok(out) = output {
diff --git a/src/config.rs b/src/config.rs
index b210692..957d6c5 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -235,16 +235,6 @@ pub(crate) fn get_cce_cloud_cmd() -> String {
     "cce-cloud".to_string()
 }
 
-pub(crate) fn get_cce_cmd() -> String {
-    if let Ok(home) = std::env::var("HOME") {
-        let path = format!("{}/.local/bin/cce", home);
-        if std::path::Path::new(&path).exists() {
-            return path;
-        }
-    }
-    "cce".to_string()
-}
-
 pub(crate) fn get_ccectl_cmd() -> String {
     if let Ok(home) = std::env::var("HOME") {
         let path = format!("{}/.local/bin/ccectl", home);
diff --git a/src/main.rs b/src/main.rs
index 776b348..e8fa256 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -739,7 +739,7 @@ impl StatusApp {
             if running {
                 if let Some(pid) = self.active_cloud_pid {
                     log::debug!("[window-picker] Toggling off existing cce-cloud PID {}", pid);
-                    let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
+                    send_sigterm(pid);
                 }
                 self.active_cloud_pid = None;
             }
@@ -872,6 +872,22 @@ fn parse_selected_module_from_args() -> Option<(String, Side)> {
     None
 }
 
+/// Ask the compositor for the current adjust-position-mode state
+/// (`ccectl adjust-position-mode query` → `ok true|false`). `None` when the
+/// query fails or the reply is unrecognized. Note: a pre-query compositor
+/// treats the `query` argument as a toggle — the two repos ship together.
+pub(crate) fn query_adjust_position_mode() -> Option<bool> {
+    let out = std::process::Command::new(get_ccectl_cmd())
+        .args(["adjust-position-mode", "query"])
+        .output()
+        .ok()?;
+    match String::from_utf8_lossy(&out.stdout).trim() {
+        "ok true" => Some(true),
+        "ok false" => Some(false),
+        _ => None,
+    }
+}
+
 /// One window from `ccectl windows` output: (id, app_id, title, focused).
 pub(crate) type CcectlWindow = (String, String, String, bool);
 
@@ -912,12 +928,34 @@ pub(crate) fn parse_ccectl_window_line(line: &str) -> Option<CcectlWindow> {
     Some((id, app_id, title, focused))
 }
 
-/// Parse `ccectl windows` output, dropping this app's own surfaces and cce-cloud
-/// popups (they should never appear in the window picker).
+/// Parse one line of `ccectl windows --json` output.
+pub(crate) fn parse_ccectl_window_json_line(line: &str) -> Option<CcectlWindow> {
+    let v: serde_json::Value = serde_json::from_str(line).ok()?;
+    let id = v.get("id")?.as_u64()?.to_string();
+    let app_id = v.get("app_id")?.as_str()?.to_string();
+    let title = v.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
+    let focused = v.get("focused").and_then(|f| f.as_bool()).unwrap_or(false);
+    Some((id, app_id, title, focused))
+}
+
+/// Parse one `ccectl windows` line in either format — JSON (`--json`) when the
+/// compositor supports it, otherwise the legacy text format (an older
+/// compositor ignores the `--json` flag and answers in text; titles containing
+/// `"` are then truncated at the quote).
+pub(crate) fn parse_ccectl_window_any_line(line: &str) -> Option<CcectlWindow> {
+    if line.trim_start().starts_with('{') {
+        parse_ccectl_window_json_line(line)
+    } else {
+        parse_ccectl_window_line(line)
+    }
+}
+
+/// Parse `ccectl windows [--json]` output, dropping this app's own surfaces and
+/// cce-cloud popups (they should never appear in the window picker).
 pub(crate) fn parse_ccectl_windows(output: &str) -> Vec<CcectlWindow> {
     output
         .lines()
-        .filter_map(parse_ccectl_window_line)
+        .filter_map(parse_ccectl_window_any_line)
         .filter(|(_, app_id, _, _)| {
             app_id != "cce-status" && app_id != "cce-status-interface" && app_id != "cce-cloud"
         })
@@ -1100,7 +1138,7 @@ impl cce_ui::engine::Application for StatusApp {
                     self.active_cloud_pid = Some(pid);
                 } else {
                     log::debug!("[cloud-event] CloudSpawned: pid {} for source {} is obsolete/canceled, killing", pid, source);
-                    let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
+                    send_sigterm(pid);
                 }
             }
             CustomEvent::CloudClosed { pid, source } => {
@@ -1131,18 +1169,26 @@ impl cce_ui::engine::Application for StatusApp {
             CustomEvent::ToggleHideModules => {
                 self.status_hide_mode = !self.status_hide_mode;
                 let cmd = if self.status_hide_mode { "true" } else { "false" };
-                let _ = std::process::Command::new(get_cce_cmd())
-                    .args(["control", "status-hide-mode", cmd])
-                    .status();
+                if let Err(e) = std::process::Command::new(get_ccectl_cmd())
+                    .args(["status-hide-mode", cmd])
+                    .status()
+                {
+                    log::warn!("[hide-mode] ccectl status-hide-mode failed: {:?}", e);
+                }
                 *needs_rebuild = true;
             }
             CustomEvent::ToggleAdjustPositionMode => {
-                self.adjust_position_mode = std::path::Path::new("/tmp/cce-status-interface-adjust-mode").exists();
-                self.adjust_position_mode = !self.adjust_position_mode;
+                // The compositor is the source of truth: sync to its state,
+                // then send the flipped value.
+                self.adjust_position_mode =
+                    !query_adjust_position_mode().unwrap_or(self.adjust_position_mode);
                 let cmd = if self.adjust_position_mode { "true" } else { "false" };
-                let _ = std::process::Command::new(get_cce_cmd())
-                    .args(["control", "adjust-position-mode", cmd])
-                    .status();
+                if let Err(e) = std::process::Command::new(get_ccectl_cmd())
+                    .args(["adjust-position-mode", cmd])
+                    .status()
+                {
+                    log::warn!("[adjust-mode] ccectl adjust-position-mode failed: {:?}", e);
+                }
                 *needs_rebuild = true;
             }
         }
@@ -1319,7 +1365,7 @@ impl cce_ui::engine::Application for StatusApp {
                     // There is an active dialog open.
                     // Kill it regardless of which one it is.
                     log::debug!("[tray-click] cce-cloud (PID {}) is running, killing it", pid);
-                    let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
+                    send_sigterm(pid);
                     self.active_cloud_pid = None;
 
                     // If it was clicked for the SAME tray icon, this is a toggle-off.
@@ -1428,7 +1474,8 @@ impl cce_ui::engine::Application for StatusApp {
             }
 
             if button == MouseButton::Right {
-                self.adjust_position_mode = std::path::Path::new("/tmp/cce-status-interface-adjust-mode").exists();
+                self.adjust_position_mode =
+                    query_adjust_position_mode().unwrap_or(self.adjust_position_mode);
                 // Find which module was right-clicked
                 let mut clicked_module = None;
                 for mb in &self.module_bounds {
@@ -1456,7 +1503,7 @@ impl cce_ui::engine::Application for StatusApp {
 
                     if let Some(pid) = running_cloud_pid {
                         log::debug!("[module-right-click] cce-cloud (PID {}) is running, killing it", pid);
-                        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
+                        send_sigterm(pid);
                         self.active_cloud_pid = None;
 
                         // If it was clicked for the same context menu, this is a toggle-off
@@ -1591,7 +1638,7 @@ impl cce_ui::engine::Application for StatusApp {
                         // There is an active dialog open.
                         // Kill it regardless of which one it is.
                         log::debug!("[layout-click] cce-cloud (PID {}) is running, killing it", pid);
-                        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
+                        send_sigterm(pid);
                         self.active_cloud_pid = None;
 
                         // If it was clicked for the layout menu, this is a toggle-off.
@@ -2065,12 +2112,51 @@ mod tests {
 
     #[test]
     fn ccectl_windows_title_truncates_at_inner_quote() {
-        // Known wire-format limitation: titles are not escaped, so an inner
-        // quote truncates the title. Documented here, to be fixed by the
-        // --json output in PROPOSAL.md phase 3.
+        // Known limitation of the legacy text format kept as the fallback for
+        // pre---json compositors: titles are not escaped, so an inner quote
+        // truncates the title. The JSON path below handles this correctly.
         let out = parse_ccectl_windows("window id=3 app_id=x title=\"say \"hi\"\" focused=false");
         assert_eq!(out.len(), 1);
         assert_eq!(out[0].2, "say ");
     }
+
+    // --- parse_ccectl_windows, JSON format (`windows --json`) ---
+
+    #[test]
+    fn ccectl_windows_json_full_line() {
+        let out = parse_ccectl_windows(
+            r#"{"id":3,"app_id":"firefox","title":"hello","mode":"grid","x":0,"y":0,"w":800,"h":600,"vx":0.0,"vy":0.0,"minimized":false,"has_parent":false,"focused":true,"ssd":false}"#,
+        );
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0], ("3".to_string(), "firefox".to_string(), "hello".to_string(), true));
+    }
+
+    #[test]
+    fn ccectl_windows_json_title_with_quotes_and_spaces() {
+        // The reason --json exists: titles survive quoting untouched.
+        let out = parse_ccectl_windows(
+            r#"{"id":3,"app_id":"x","title":"say \"hi\" title=fake","focused":false}"#,
+        );
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].2, "say \"hi\" title=fake");
+    }
+
+    #[test]
+    fn ccectl_windows_json_filters_own_surfaces() {
+        let out = parse_ccectl_windows(
+            "{\"id\":1,\"app_id\":\"cce-status\",\"title\":\"\",\"focused\":false}\n\
+             {\"id\":2,\"app_id\":\"cce-cloud\",\"title\":\"\",\"focused\":false}\n\
+             {\"id\":3,\"app_id\":\"firefox\",\"title\":\"\",\"focused\":false}",
+        );
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, "firefox");
+    }
+
+    #[test]
+    fn ccectl_windows_json_missing_required_fields_skips_line() {
+        assert!(parse_ccectl_windows(r#"{"app_id":"x","title":"no id"}"#).is_empty());
+        assert!(parse_ccectl_windows(r#"{"id":3,"title":"no app_id"}"#).is_empty());
+        assert!(parse_ccectl_windows("{not json").is_empty());
+    }
 }