sesame/
wm.rs

1use anyhow::Context;
2use comfy_table::{Table, presets::UTF8_FULL};
3use core_types::{EventKind, SecurityLevel};
4use owo_colors::OwoColorize;
5use std::time::Duration;
6
7use crate::ipc::{connect, rpc};
8
9pub(crate) async fn cmd_wm_list() -> anyhow::Result<()> {
10    let client = connect().await?;
11
12    match rpc(&client, EventKind::WmListWindows, SecurityLevel::Internal).await? {
13        EventKind::WmListWindowsResponse { windows } => {
14            if windows.is_empty() {
15                println!("{}", "No windows tracked.".dimmed());
16                return Ok(());
17            }
18
19            let mut table = Table::new();
20            table.load_preset(UTF8_FULL);
21            table.set_header(vec!["ID", "App", "Title", "Focused"]);
22
23            for w in &windows {
24                let focused = if w.is_focused {
25                    "yes".green().to_string()
26                } else {
27                    "".to_string()
28                };
29                table.add_row(vec![
30                    &w.id.to_string(),
31                    &w.app_id.to_string(),
32                    &w.title,
33                    &focused,
34                ]);
35            }
36
37            println!("{table}");
38        }
39        other => anyhow::bail!("unexpected response: {other:?}"),
40    }
41
42    Ok(())
43}
44
45pub(crate) async fn cmd_wm_switch(backward: bool) -> anyhow::Result<()> {
46    let client = connect().await?;
47
48    // List windows, pick next/previous in MRU order.
49    let windows = match rpc(&client, EventKind::WmListWindows, SecurityLevel::Internal).await? {
50        EventKind::WmListWindowsResponse { windows } => windows,
51        other => anyhow::bail!("unexpected response: {other:?}"),
52    };
53
54    if windows.is_empty() {
55        println!("{}", "No windows to switch to.".dimmed());
56        return Ok(());
57    }
58
59    // The WmListWindowsResponse returns windows in MRU order (most recent first).
60    // Index 0 = currently focused (MRU top). Forward = index 1 (previous window).
61    // Backward = last index (least recently used).
62    if windows.len() <= 1 {
63        tracing::debug!("only one window open, nothing to switch to");
64        return Ok(());
65    }
66    let target_idx = if backward { windows.len() - 1 } else { 1 };
67
68    let target_id = windows[target_idx].id.to_string();
69
70    match rpc(
71        &client,
72        EventKind::WmActivateWindow {
73            window_id: target_id.clone(),
74        },
75        SecurityLevel::Internal,
76    )
77    .await?
78    {
79        EventKind::WmActivateWindowResponse { success: true } => {
80            println!(
81                "Switched to: {} ({})",
82                windows[target_idx].title.green(),
83                windows[target_idx].app_id,
84            );
85        }
86        EventKind::WmActivateWindowResponse { success: false } => {
87            anyhow::bail!("failed to activate window '{target_id}'");
88        }
89        other => anyhow::bail!("unexpected response: {other:?}"),
90    }
91
92    Ok(())
93}
94
95pub(crate) async fn cmd_wm_focus(window_id: &str) -> anyhow::Result<()> {
96    let client = connect().await?;
97
98    match rpc(
99        &client,
100        EventKind::WmActivateWindow {
101            window_id: window_id.to_owned(),
102        },
103        SecurityLevel::Internal,
104    )
105    .await?
106    {
107        EventKind::WmActivateWindowResponse { success: true } => {
108            println!("Focused window: {}", window_id.green());
109        }
110        EventKind::WmActivateWindowResponse { success: false } => {
111            anyhow::bail!("window '{window_id}' not found");
112        }
113        other => anyhow::bail!("unexpected response: {other:?}"),
114    }
115
116    Ok(())
117}
118
119pub(crate) async fn cmd_wm_overlay(launcher: bool, backward: bool) -> anyhow::Result<()> {
120    let variant = match (launcher, backward) {
121        (true, true) => "overlay-launcher-backward",
122        (true, false) => "overlay-launcher",
123        (false, true) => "overlay-backward",
124        (false, false) => "overlay",
125    };
126
127    // Fast path: send datagram to resident process (~2ms).
128    if try_send_fast_path(variant) {
129        return Ok(());
130    }
131
132    // Slow path: full Noise IK connect + publish.
133    let client = connect().await?;
134    let event = match variant {
135        "overlay-launcher" => EventKind::WmActivateOverlayLauncher,
136        "overlay-launcher-backward" => EventKind::WmActivateOverlayLauncherBackward,
137        "overlay-backward" => EventKind::WmActivateOverlayBackward,
138        _ => EventKind::WmActivateOverlay,
139    };
140    client
141        .publish(event, SecurityLevel::Internal)
142        .await
143        .map_err(|e| anyhow::anyhow!("{e}"))?;
144
145    // Spawn resident in background for next invocation.
146    spawn_resident();
147
148    client.shutdown().await;
149    Ok(())
150}
151
152/// Send an overlay command to the resident fast-path process via Unix datagram.
153///
154/// Returns `true` if the datagram was sent (resident is alive).
155fn try_send_fast_path(variant: &str) -> bool {
156    let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") else {
157        return false;
158    };
159    let pid_path = format!("{runtime_dir}/pds/wm-fast.pid");
160    let sock_path = format!("{runtime_dir}/pds/wm-fast.sock");
161
162    // Check PID file for liveness.
163    let Ok(pid_content) = std::fs::read_to_string(&pid_path) else {
164        return false;
165    };
166    let Ok(pid) = pid_content.trim().parse::<i32>() else {
167        return false;
168    };
169
170    // Verify process is alive via kill(pid, 0).
171    if unsafe { libc::kill(pid, 0) } != 0 {
172        let _ = std::fs::remove_file(&pid_path);
173        let _ = std::fs::remove_file(&sock_path);
174        return false;
175    }
176
177    // Send datagram (blocking — this is a ~0.1ms operation).
178    let Ok(sock) = std::os::unix::net::UnixDatagram::unbound() else {
179        return false;
180    };
181    sock.send_to(variant.as_bytes(), &sock_path).is_ok()
182}
183
184/// Fork a resident fast-path process in the background.
185fn spawn_resident() {
186    let Ok(exe) = std::env::current_exe() else {
187        return;
188    };
189    let _ = std::process::Command::new(exe)
190        .args(["wm", "overlay-resident"])
191        .stdin(std::process::Stdio::null())
192        .stdout(std::process::Stdio::null())
193        .stderr(std::process::Stdio::null())
194        .spawn();
195}
196
197/// Resident fast-path daemon: holds an IPC connection, listens for datagrams.
198///
199/// Exits on IPC disconnect, datagram error, or 5-minute idle timeout.
200pub(crate) async fn cmd_wm_overlay_resident() -> anyhow::Result<()> {
201    let runtime_dir = std::env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR not set")?;
202    let pds_dir = format!("{runtime_dir}/pds");
203    let pid_path = format!("{pds_dir}/wm-fast.pid");
204    let sock_path = format!("{pds_dir}/wm-fast.sock");
205
206    // Check if another resident is already running.
207    if let Ok(existing_pid) = std::fs::read_to_string(&pid_path)
208        && let Ok(pid) = existing_pid.trim().parse::<i32>()
209        && unsafe { libc::kill(pid, 0) } == 0
210    {
211        return Ok(());
212    }
213
214    // Write PID file.
215    std::fs::write(&pid_path, std::process::id().to_string())?;
216
217    // Bind datagram socket with 0600 permissions.
218    let _ = std::fs::remove_file(&sock_path);
219    let dgram = tokio::net::UnixDatagram::bind(&sock_path)?;
220
221    #[cfg(unix)]
222    {
223        use std::os::unix::fs::PermissionsExt;
224        std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?;
225    }
226
227    // Establish IPC connection (full Noise IK handshake — done once).
228    let client = connect().await?;
229
230    // Event loop: receive datagrams, publish to IPC bus.
231    let idle_timeout = Duration::from_secs(300);
232    let mut buf = [0u8; 64];
233
234    loop {
235        match tokio::time::timeout(idle_timeout, dgram.recv(&mut buf)).await {
236            Ok(Ok(n)) => {
237                let variant = std::str::from_utf8(&buf[..n]).unwrap_or("");
238                let event = match variant {
239                    "overlay" => EventKind::WmActivateOverlay,
240                    "overlay-backward" => EventKind::WmActivateOverlayBackward,
241                    "overlay-launcher" => EventKind::WmActivateOverlayLauncher,
242                    "overlay-launcher-backward" => EventKind::WmActivateOverlayLauncherBackward,
243                    _ => continue,
244                };
245                if client
246                    .publish(event, SecurityLevel::Internal)
247                    .await
248                    .is_err()
249                {
250                    break;
251                }
252            }
253            Ok(Err(_)) => break,
254            Err(_) => break, // Idle timeout.
255        }
256    }
257
258    // Cleanup.
259    let _ = std::fs::remove_file(&sock_path);
260    let _ = std::fs::remove_file(&pid_path);
261    client.shutdown().await;
262    Ok(())
263}