status bar
git clone https://git.lucas.co/cce-status-interface.git
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());
+ }
}