|
| 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