Skip to content

Commit 317dba3

Browse files
authored
Create protocol.rs
1 parent da3e11f commit 317dba3

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

hacker-mode/src/protocol.rs

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
use std::io::{BufRead, BufReader, Write};
2+
use std::os::unix::net::UnixStream;
3+
use std::path::PathBuf;
4+
use std::sync::atomic::{AtomicU64, Ordering};
5+
use std::time::Duration;
6+
7+
use serde::{Deserialize, Serialize};
8+
9+
#[derive(Debug, Clone, Serialize, Deserialize)]
10+
pub struct SdeRequest {
11+
pub id: u64,
12+
#[serde(flatten)]
13+
pub call: SdeCall,
14+
}
15+
16+
#[derive(Debug, Clone, Serialize, Deserialize)]
17+
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
18+
pub enum SdeCall {
19+
Ping,
20+
LaunchApp { command: String, args: Vec<String> },
21+
SetWallpaper { path: String },
22+
ListWindows,
23+
FocusWindow { id: u64 },
24+
CloseWindow { id: u64 },
25+
MinimizeWindow { id: u64 },
26+
UnminimizeWindow { id: u64 },
27+
MaximizeWindow { id: u64, maximized: bool },
28+
ToggleFloatingWindow { id: u64 },
29+
ListWorkspaces,
30+
SwitchWorkspace { id: u32 },
31+
MoveWindowToWorkspace { id: u64, workspace: u32 },
32+
SetTiling { workspace: u32, enabled: bool },
33+
PinSurface { app_id: String, edge: PinnedEdge, thickness_px: u32 },
34+
ReloadConfig,
35+
ListOutputs,
36+
Shutdown,
37+
Subscribe,
38+
}
39+
40+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41+
#[serde(rename_all = "snake_case")]
42+
pub enum PinnedEdge {
43+
Top,
44+
Bottom,
45+
}
46+
47+
#[derive(Debug, Clone, Serialize, Deserialize)]
48+
pub struct SdeResponse {
49+
pub id: u64,
50+
#[serde(flatten)]
51+
pub outcome: SdeOutcome,
52+
}
53+
54+
#[derive(Debug, Clone, Serialize, Deserialize)]
55+
#[serde(tag = "status", rename_all = "snake_case")]
56+
pub enum SdeOutcome {
57+
Ok { result: SdeResult },
58+
Err { message: String },
59+
}
60+
61+
#[derive(Debug, Clone, Serialize, Deserialize)]
62+
#[serde(tag = "kind", rename_all = "snake_case")]
63+
pub enum SdeResult {
64+
None,
65+
Pong,
66+
Windows(Vec<SdeWindowInfo>),
67+
Workspaces(Vec<SdeWorkspaceInfo>),
68+
Outputs(Vec<SdeOutputInfo>),
69+
}
70+
71+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72+
pub struct SdeWindowInfo {
73+
pub id: u64,
74+
pub title: String,
75+
pub app_id: String,
76+
pub workspace: u32,
77+
pub focused: bool,
78+
pub minimized: bool,
79+
pub maximized: bool,
80+
pub floating: bool,
81+
}
82+
83+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84+
pub struct SdeWorkspaceInfo {
85+
pub id: u32,
86+
pub name: String,
87+
pub window_count: u32,
88+
pub tiling_enabled: bool,
89+
pub active: bool,
90+
}
91+
92+
#[derive(Debug, Clone, Serialize, Deserialize)]
93+
pub struct SdeOutputInfo {
94+
pub name: String,
95+
pub width: i32,
96+
pub height: i32,
97+
pub refresh_mhz: i32,
98+
pub scale: f64,
99+
pub primary: bool,
100+
}
101+
102+
#[derive(Debug, Clone, Serialize, Deserialize)]
103+
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
104+
pub enum SdeEvent {
105+
Windows(Vec<SdeWindowInfo>),
106+
Workspaces(Vec<SdeWorkspaceInfo>),
107+
CompositorShuttingDown,
108+
}
109+
110+
#[derive(Debug, Clone, Serialize, Deserialize)]
111+
pub struct SdeEventMessage {
112+
pub event: SdeEvent,
113+
}
114+
115+
#[derive(Debug, thiserror::Error)]
116+
pub enum SdeIpcError {
117+
#[error("hackeros-comp is not running in --extern-{0} mode (no socket at {1:?})")]
118+
NotRunning(String, PathBuf),
119+
#[error("i/o error talking to hackeros-comp: {0}")]
120+
Io(#[from] std::io::Error),
121+
#[error("malformed message from hackeros-comp: {0}")]
122+
Serde(#[from] serde_json::Error),
123+
#[error("hackeros-comp rejected the request: {0}")]
124+
Rejected(String),
125+
#[error("response id {got} did not match request id {expected}")]
126+
IdMismatch { expected: u64, got: u64 },
127+
}
128+
129+
/// `$XDG_RUNTIME_DIR/sde`, falling back to `/tmp/sde-<uid>`. MUST match
130+
/// `hackeros-comp`'s own `src/ipc/protocol.rs::runtime_dir()` exactly -
131+
/// see that function's doc comment.
132+
pub fn runtime_dir() -> PathBuf {
133+
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
134+
if !dir.is_empty() {
135+
return PathBuf::from(dir).join("sde");
136+
}
137+
}
138+
let uid = unsafe { libc_getuid() };
139+
PathBuf::from(format!("/tmp/sde-{uid}"))
140+
}
141+
142+
pub fn socket_path_for(extern_name: &str) -> PathBuf {
143+
runtime_dir().join(format!("hackeros-comp-{extern_name}.sock"))
144+
}
145+
146+
// Deliberately not a `libc` dependency for one syscall in a crate that
147+
// otherwise has none - `getuid()` can never fail and has no meaningful
148+
// error path, so a single `extern "C"` declaration is simpler than a
149+
// whole crate for it. (`hackeros-comp` itself already depends on `libc`
150+
// for far more than this, so its own `protocol.rs` just uses it
151+
// directly instead - see that module.)
152+
extern "C" {
153+
#[link_name = "getuid"]
154+
fn c_getuid() -> u32;
155+
}
156+
unsafe fn libc_getuid() -> u32 {
157+
c_getuid()
158+
}
159+
160+
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
161+
162+
/// Sends one request to `hackeros-comp --extern-<extern_name>` and waits
163+
/// for its response.
164+
pub fn call(extern_name: &str, request: SdeCall, timeout: Duration) -> Result<SdeResult, SdeIpcError> {
165+
let path = socket_path_for(extern_name);
166+
if !path.exists() {
167+
return Err(SdeIpcError::NotRunning(extern_name.to_string(), path));
168+
}
169+
170+
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
171+
let req = SdeRequest { id, call: request };
172+
173+
let mut stream = UnixStream::connect(&path)?;
174+
stream.set_read_timeout(Some(timeout))?;
175+
stream.set_write_timeout(Some(timeout))?;
176+
177+
let mut line = serde_json::to_string(&req)?;
178+
line.push('\n');
179+
stream.write_all(line.as_bytes())?;
180+
stream.flush()?;
181+
182+
let mut reader = BufReader::new(stream);
183+
let mut response_line = String::new();
184+
reader.read_line(&mut response_line)?;
185+
186+
let response: SdeResponse = serde_json::from_str(response_line.trim())?;
187+
if response.id != id {
188+
return Err(SdeIpcError::IdMismatch { expected: id, got: response.id });
189+
}
190+
match response.outcome {
191+
SdeOutcome::Ok { result } => Ok(result),
192+
SdeOutcome::Err { message } => Err(SdeIpcError::Rejected(message)),
193+
}
194+
}
195+
196+
/// True if a `hackeros-comp --extern-<extern_name>` compositor is
197+
/// currently reachable and answers `Ping` with `Pong`.
198+
pub fn is_running(extern_name: &str) -> bool {
199+
matches!(call(extern_name, SdeCall::Ping, Duration::from_millis(300)), Ok(SdeResult::Pong))
200+
}
201+
202+
/// A live `Subscribe` event stream, opened by [`subscribe`].
203+
pub struct Subscription {
204+
reader: BufReader<UnixStream>,
205+
}
206+
207+
impl Subscription {
208+
/// Blocks until the next event arrives (or the connection drops).
209+
pub fn recv(&mut self) -> Result<SdeEvent, SdeIpcError> {
210+
let mut line = String::new();
211+
let n = self.reader.read_line(&mut line)?;
212+
if n == 0 {
213+
return Err(SdeIpcError::Io(std::io::Error::new(
214+
std::io::ErrorKind::UnexpectedEof,
215+
"sde-ipc subscription closed by hackeros-comp",
216+
)));
217+
}
218+
let msg: SdeEventMessage = serde_json::from_str(line.trim())?;
219+
Ok(msg.event)
220+
}
221+
}
222+
223+
/// Opens a live event stream (window/workspace changes) from
224+
/// `hackeros-comp --extern-<extern_name>`.
225+
pub fn subscribe(extern_name: &str) -> Result<Subscription, SdeIpcError> {
226+
let path = socket_path_for(extern_name);
227+
if !path.exists() {
228+
return Err(SdeIpcError::NotRunning(extern_name.to_string(), path));
229+
}
230+
231+
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
232+
let req = SdeRequest { id, call: SdeCall::Subscribe };
233+
234+
let stream = UnixStream::connect(&path)?;
235+
stream.set_write_timeout(Some(Duration::from_millis(800)))?;
236+
237+
let mut line = serde_json::to_string(&req)?;
238+
line.push('\n');
239+
(&stream).write_all(line.as_bytes())?;
240+
(&stream).flush()?;
241+
242+
let mut reader = BufReader::new(stream);
243+
let mut ack_line = String::new();
244+
reader.read_line(&mut ack_line)?;
245+
let ack: SdeResponse = serde_json::from_str(ack_line.trim())?;
246+
if ack.id != id {
247+
return Err(SdeIpcError::IdMismatch { expected: id, got: ack.id });
248+
}
249+
match ack.outcome {
250+
SdeOutcome::Ok { .. } => {
251+
reader.get_ref().set_read_timeout(None)?;
252+
Ok(Subscription { reader })
253+
}
254+
SdeOutcome::Err { message } => Err(SdeIpcError::Rejected(message)),
255+
}
256+
}
257+
258+
#[cfg(test)]
259+
mod tests {
260+
use super::*;
261+
262+
#[test]
263+
fn socket_path_matches_hackeros_comp_naming() {
264+
// hackeros-comp's own `src/ipc/protocol.rs::socket_path_for`
265+
// builds `<runtime_dir>/hackeros-comp-<name>.sock` - this MUST
266+
// match, or a real compositor and this client would each be
267+
// listening on / connecting to different paths.
268+
let expected = runtime_dir().join("hackeros-comp-hacker-mode.sock");
269+
assert_eq!(socket_path_for("hacker-mode"), expected);
270+
}
271+
272+
#[test]
273+
fn request_round_trips_through_json() {
274+
let req = SdeRequest { id: 7, call: SdeCall::FocusWindow { id: 42 } };
275+
let json = serde_json::to_string(&req).unwrap();
276+
let back: SdeRequest = serde_json::from_str(&json).unwrap();
277+
assert_eq!(back.id, 7);
278+
assert!(matches!(back.call, SdeCall::FocusWindow { id: 42 }));
279+
}
280+
}

0 commit comments

Comments
 (0)