gestura_core_ipc/
hotkey_ipc.rs1use 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
20const MSG_TOGGLE_RECORDING: u8 = b'T';
22const MSG_ACK: u8 = b'K';
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct CliHotkeyEndpoint {
28 pub version: String,
30 pub port: u16,
32 pub pid: u32,
34 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
52pub 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 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 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
115pub 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
125pub 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 let _ = std::fs::remove_file(port_file);
142 return Ok(false);
143 }
144 };
145
146 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
158pub 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
171pub 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 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 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 let got = tokio::time::timeout(Duration::from_millis(250), rx.recv())
233 .await
234 .unwrap();
235 assert!(got.is_some());
236 }
237}