gestura_core_ipc/
hotkey_ipc.rs

1//! Local IPC for routing the GUI global listen hotkey to a running CLI session.
2//!
3//! ## Why this exists
4//! The GUI registers an OS-wide (global) hotkey via Tauri. When a user is
5//! actively working in the terminal, pressing that hotkey should prefer the
6//! active CLI session instead of starting a GUI listening session.
7//!
8//! We intentionally avoid NATS here: this is a lightweight, local-only,
9//! best-effort mechanism.
10
11use serde::{Deserialize, Serialize};
12use std::io;
13use std::net::{IpAddr, Ipv4Addr, SocketAddr};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16use tokio::io::{AsyncReadExt, AsyncWriteExt};
17use tokio::net::{TcpListener, TcpStream};
18use tokio::sync::mpsc;
19
20/// Byte sent by the GUI to request the CLI toggles recording.
21const MSG_TOGGLE_RECORDING: u8 = b'T';
22/// Byte sent by the CLI to acknowledge receipt.
23const MSG_ACK: u8 = b'K';
24
25/// On-disk discovery record for the currently-running CLI hotkey server.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct CliHotkeyEndpoint {
28    /// Protocol version string.
29    pub version: String,
30    /// TCP port bound on 127.0.0.1.
31    pub port: u16,
32    /// PID of the CLI process that created the endpoint.
33    pub pid: u32,
34    /// Unix epoch millis when written.
35    pub created_at_ms: u128,
36}
37
38impl CliHotkeyEndpoint {
39    fn new(port: u16) -> Self {
40        Self {
41            version: "gestura-cli-hotkey-v1".to_string(),
42            port,
43            pid: std::process::id(),
44            created_at_ms: SystemTime::now()
45                .duration_since(UNIX_EPOCH)
46                .unwrap_or_default()
47                .as_millis(),
48        }
49    }
50}
51
52/// Returns the default port file path used for CLI hotkey discovery.
53///
54/// We use the OS temp dir to avoid Unix socket path-length issues and because
55/// the file is ephemeral (only meaningful while a CLI session is running).
56pub fn default_cli_hotkey_port_file() -> PathBuf {
57    let suffix = user_suffix();
58    std::env::temp_dir().join(format!("gestura-cli-hotkey-{suffix}.json"))
59}
60
61fn user_suffix() -> String {
62    #[cfg(unix)]
63    {
64        // Best-effort unique-per-user identifier.
65        //
66        // We intentionally avoid depending on `nix` user features here; env vars
67        // are sufficient for a temp-dir discovery file suffix.
68        std::env::var("UID")
69            .or_else(|_| std::env::var("USER"))
70            .or_else(|_| std::env::var("LOGNAME"))
71            .unwrap_or_else(|_| "default".to_string())
72            .chars()
73            .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
74            .collect::<String>()
75    }
76
77    #[cfg(not(unix))]
78    {
79        std::env::var("USERNAME")
80            .or_else(|_| std::env::var("USER"))
81            .unwrap_or_else(|_| "default".to_string())
82            .chars()
83            .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
84            .collect::<String>()
85    }
86}
87
88fn localhost_addr(port: u16) -> SocketAddr {
89    SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
90}
91
92fn write_port_file_atomic(path: &Path, endpoint: &CliHotkeyEndpoint) -> io::Result<()> {
93    let parent = path.parent().unwrap_or_else(|| Path::new("."));
94    std::fs::create_dir_all(parent)?;
95
96    let tmp = path.with_extension("json.tmp");
97    let bytes = serde_json::to_vec(endpoint)
98        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
99    std::fs::write(&tmp, bytes)?;
100
101    // Best-effort atomic replace.
102    if path.exists() {
103        let _ = std::fs::remove_file(path);
104    }
105    std::fs::rename(&tmp, path)?;
106    Ok(())
107}
108
109fn read_port_file(path: &Path) -> io::Result<CliHotkeyEndpoint> {
110    let bytes = std::fs::read(path)?;
111    serde_json::from_slice(&bytes)
112        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
113}
114
115/// Try to send a hotkey trigger to a running CLI session.
116///
117/// Returns:
118/// - `Ok(true)` if a CLI server was found and acknowledged the request.
119/// - `Ok(false)` if no CLI server is running (or endpoint is stale/unreachable).
120/// - `Err(_)` for unexpected I/O errors (rare; callers typically treat as `false`).
121pub async fn try_send_hotkey_trigger_to_cli(timeout: Duration) -> io::Result<bool> {
122    try_send_hotkey_trigger_to_cli_with_file(&default_cli_hotkey_port_file(), timeout).await
123}
124
125/// Same as [`try_send_hotkey_trigger_to_cli`] but allows injecting a custom port file path.
126pub async fn try_send_hotkey_trigger_to_cli_with_file(
127    port_file: &Path,
128    timeout: Duration,
129) -> io::Result<bool> {
130    let endpoint = match read_port_file(port_file) {
131        Ok(v) => v,
132        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
133        Err(e) => return Err(e),
134    };
135
136    let addr = localhost_addr(endpoint.port);
137    let mut stream = match tokio::time::timeout(timeout, TcpStream::connect(addr)).await {
138        Ok(Ok(s)) => s,
139        Ok(Err(_)) | Err(_) => {
140            // Stale endpoint; remove so next press falls back quickly.
141            let _ = std::fs::remove_file(port_file);
142            return Ok(false);
143        }
144    };
145
146    // Send request + wait for ack.
147    stream.write_all(&[MSG_TOGGLE_RECORDING]).await?;
148    stream.flush().await?;
149    let mut ack = [0u8; 1];
150    match tokio::time::timeout(timeout, stream.read_exact(&mut ack)).await {
151        Ok(Ok(_)) if ack[0] == MSG_ACK => Ok(true),
152        Ok(Ok(_)) => Ok(false),
153        Ok(Err(e)) => Err(e),
154        Err(_) => Ok(false),
155    }
156}
157
158/// Guard that keeps the CLI hotkey server alive and removes the discovery file when dropped.
159pub struct CliHotkeyServerGuard {
160    port_file: PathBuf,
161    task: tokio::task::JoinHandle<()>,
162}
163
164impl Drop for CliHotkeyServerGuard {
165    fn drop(&mut self) {
166        self.task.abort();
167        let _ = std::fs::remove_file(&self.port_file);
168    }
169}
170
171/// Start a local TCP server that accepts hotkey triggers and forwards them to the provided channel.
172///
173/// The server binds to `127.0.0.1:0` (ephemeral) and writes its port to a discovery file.
174///
175/// - `tx` receives `()` for each incoming trigger.
176/// - `port_file` controls the discovery location; use [`default_cli_hotkey_port_file`]
177///   for production.
178pub async fn start_cli_hotkey_server(
179    tx: mpsc::UnboundedSender<()>,
180    port_file: PathBuf,
181) -> io::Result<CliHotkeyServerGuard> {
182    let listener = TcpListener::bind(localhost_addr(0)).await?;
183    let port = listener.local_addr()?.port();
184
185    write_port_file_atomic(&port_file, &CliHotkeyEndpoint::new(port))?;
186
187    let task = tokio::spawn(async move {
188        loop {
189            let (mut sock, _) = match listener.accept().await {
190                Ok(v) => v,
191                Err(_) => break,
192            };
193
194            // Handle each connection in its own task so a slow peer doesn't block accept.
195            let tx = tx.clone();
196            tokio::spawn(async move {
197                let mut b = [0u8; 1];
198                if sock.read_exact(&mut b).await.is_ok() && b[0] == MSG_TOGGLE_RECORDING {
199                    // Ack first (so GUI can decide not to fall back to GUI listening).
200                    let _ = sock.write_all(&[MSG_ACK]).await;
201                    let _ = sock.flush().await;
202                    let _ = tx.send(());
203                }
204            });
205        }
206    });
207
208    Ok(CliHotkeyServerGuard { port_file, task })
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use tempfile::tempdir;
215
216    #[tokio::test]
217    async fn send_trigger_roundtrip() {
218        let dir = tempdir().unwrap();
219        let port_file = dir.path().join("endpoint.json");
220
221        let (tx, mut rx) = mpsc::unbounded_channel::<()>();
222        let _guard = start_cli_hotkey_server(tx, port_file.clone())
223            .await
224            .unwrap();
225
226        let ok = try_send_hotkey_trigger_to_cli_with_file(&port_file, Duration::from_millis(250))
227            .await
228            .unwrap();
229        assert!(ok);
230
231        // Ensure the server forwarded a message.
232        let got = tokio::time::timeout(Duration::from_millis(250), rx.recv())
233            .await
234            .unwrap();
235        assert!(got.is_some());
236    }
237}