| 1 |
# Sprint 6: IPC System |
| 2 |
|
| 3 |
**Goal:** External control via Unix socket with JSON protocol. |
| 4 |
|
| 5 |
## Objectives |
| 6 |
|
| 7 |
- Unix socket server for IPC |
| 8 |
- JSON-based command/response protocol |
| 9 |
- Command-line tool (`garctl`) for interaction |
| 10 |
- Event subscription for external tools |
| 11 |
|
| 12 |
## Prerequisites |
| 13 |
|
| 14 |
- Sprint 5 complete (floating windows) |
| 15 |
|
| 16 |
## Protocol Design |
| 17 |
|
| 18 |
### Message Format |
| 19 |
|
| 20 |
```json |
| 21 |
// Command (client -> server) |
| 22 |
{ |
| 23 |
"type": "command", |
| 24 |
"command": "focus", |
| 25 |
"args": { "direction": "left" } |
| 26 |
} |
| 27 |
|
| 28 |
// Response (server -> client) |
| 29 |
{ |
| 30 |
"type": "response", |
| 31 |
"success": true, |
| 32 |
"data": { /* optional result */ } |
| 33 |
} |
| 34 |
|
| 35 |
// Error response |
| 36 |
{ |
| 37 |
"type": "response", |
| 38 |
"success": false, |
| 39 |
"error": "Window not found" |
| 40 |
} |
| 41 |
|
| 42 |
// Event (server -> subscribed clients) |
| 43 |
{ |
| 44 |
"type": "event", |
| 45 |
"event": "window_focus", |
| 46 |
"data": { "window_id": 12345, "title": "Terminal" } |
| 47 |
} |
| 48 |
``` |
| 49 |
|
| 50 |
### Commands |
| 51 |
|
| 52 |
| Command | Args | Description | |
| 53 |
|---------|------|-------------| |
| 54 |
| focus | direction | Focus window in direction | |
| 55 |
| swap | direction | Swap with window in direction | |
| 56 |
| resize | direction, amount | Resize split | |
| 57 |
| close | - | Close focused window | |
| 58 |
| workspace | number | Switch to workspace | |
| 59 |
| move_to_workspace | number | Move window to workspace | |
| 60 |
| toggle_floating | - | Toggle floating state | |
| 61 |
| reload | - | Reload configuration | |
| 62 |
| exit | - | Exit gar | |
| 63 |
| get_tree | - | Get window tree | |
| 64 |
| get_workspaces | - | Get workspace info | |
| 65 |
| get_focused | - | Get focused window | |
| 66 |
| subscribe | events[] | Subscribe to events | |
| 67 |
|
| 68 |
### Events |
| 69 |
|
| 70 |
| Event | Data | Description | |
| 71 |
|-------|------|-------------| |
| 72 |
| window_new | window info | New window created | |
| 73 |
| window_close | window_id | Window closed | |
| 74 |
| window_focus | window info | Focus changed | |
| 75 |
| window_move | window_id, workspace | Window moved | |
| 76 |
| workspace_focus | workspace info | Workspace changed | |
| 77 |
| mode | mode name | Mode changed (later) | |
| 78 |
|
| 79 |
## Tasks |
| 80 |
|
| 81 |
### 6.1 Socket Server Setup |
| 82 |
- [ ] Create `src/ipc/mod.rs`, `server.rs`, `protocol.rs` |
| 83 |
- [ ] Add tokio dependency for async I/O |
| 84 |
- [ ] Create Unix socket at `$XDG_RUNTIME_DIR/gar.sock` |
| 85 |
- [ ] Handle multiple concurrent clients |
| 86 |
- [ ] Clean up socket on exit |
| 87 |
|
| 88 |
```rust |
| 89 |
use tokio::net::{UnixListener, UnixStream}; |
| 90 |
|
| 91 |
pub struct IpcServer { |
| 92 |
listener: UnixListener, |
| 93 |
clients: Vec<Client>, |
| 94 |
} |
| 95 |
|
| 96 |
impl IpcServer { |
| 97 |
pub async fn new() -> Result<Self> { |
| 98 |
let path = std::env::var("XDG_RUNTIME_DIR") |
| 99 |
.map(|dir| format!("{}/gar.sock", dir)) |
| 100 |
.unwrap_or_else(|_| "/tmp/gar.sock".to_string()); |
| 101 |
|
| 102 |
// Remove existing socket |
| 103 |
let _ = std::fs::remove_file(&path); |
| 104 |
|
| 105 |
let listener = UnixListener::bind(&path)?; |
| 106 |
Ok(Self { listener, clients: Vec::new() }) |
| 107 |
} |
| 108 |
} |
| 109 |
``` |
| 110 |
|
| 111 |
### 6.2 Protocol Types |
| 112 |
- [ ] Define message types with serde |
| 113 |
- [ ] Implement JSON serialization |
| 114 |
- [ ] Handle malformed messages gracefully |
| 115 |
|
| 116 |
```rust |
| 117 |
use serde::{Deserialize, Serialize}; |
| 118 |
|
| 119 |
#[derive(Debug, Deserialize)] |
| 120 |
#[serde(tag = "type")] |
| 121 |
pub enum Request { |
| 122 |
#[serde(rename = "command")] |
| 123 |
Command { command: String, args: serde_json::Value }, |
| 124 |
} |
| 125 |
|
| 126 |
#[derive(Debug, Serialize)] |
| 127 |
#[serde(tag = "type")] |
| 128 |
pub enum Response { |
| 129 |
#[serde(rename = "response")] |
| 130 |
Success { success: bool, data: Option<serde_json::Value> }, |
| 131 |
#[serde(rename = "response")] |
| 132 |
Error { success: bool, error: String }, |
| 133 |
} |
| 134 |
|
| 135 |
#[derive(Debug, Serialize)] |
| 136 |
#[serde(tag = "type")] |
| 137 |
pub enum Event { |
| 138 |
#[serde(rename = "event")] |
| 139 |
Event { event: String, data: serde_json::Value }, |
| 140 |
} |
| 141 |
``` |
| 142 |
|
| 143 |
### 6.3 Command Dispatch |
| 144 |
- [ ] Parse incoming commands |
| 145 |
- [ ] Map to window manager actions |
| 146 |
- [ ] Execute and return result |
| 147 |
- [ ] Handle unknown commands |
| 148 |
|
| 149 |
```rust |
| 150 |
impl IpcServer { |
| 151 |
fn dispatch(&self, wm: &mut WindowManager, cmd: &str, args: Value) -> Response { |
| 152 |
match cmd { |
| 153 |
"focus" => { |
| 154 |
let direction = args["direction"].as_str().unwrap(); |
| 155 |
wm.focus_direction(direction.parse()?)?; |
| 156 |
Response::success(None) |
| 157 |
} |
| 158 |
"get_tree" => { |
| 159 |
let tree = wm.get_tree_json(); |
| 160 |
Response::success(Some(tree)) |
| 161 |
} |
| 162 |
_ => Response::error(format!("Unknown command: {}", cmd)), |
| 163 |
} |
| 164 |
} |
| 165 |
} |
| 166 |
``` |
| 167 |
|
| 168 |
### 6.4 Query Commands |
| 169 |
- [ ] `get_tree` - return workspace trees as JSON |
| 170 |
- [ ] `get_workspaces` - return workspace list |
| 171 |
- [ ] `get_focused` - return focused window info |
| 172 |
- [ ] `get_outputs` - return monitor info (for multi-monitor) |
| 173 |
|
| 174 |
```rust |
| 175 |
fn get_tree_json(&self) -> Value { |
| 176 |
json!({ |
| 177 |
"workspaces": self.workspaces.iter().map(|ws| { |
| 178 |
json!({ |
| 179 |
"name": ws.name, |
| 180 |
"focused": ws.focused, |
| 181 |
"nodes": tree_to_json(&ws.tree), |
| 182 |
}) |
| 183 |
}).collect::<Vec<_>>() |
| 184 |
}) |
| 185 |
} |
| 186 |
``` |
| 187 |
|
| 188 |
### 6.5 Event Subscription |
| 189 |
- [ ] Track subscribed clients per event type |
| 190 |
- [ ] Broadcast events to subscribers |
| 191 |
- [ ] Handle client disconnection |
| 192 |
- [ ] Implement subscription command |
| 193 |
|
| 194 |
```rust |
| 195 |
struct Client { |
| 196 |
stream: UnixStream, |
| 197 |
subscriptions: HashSet<String>, |
| 198 |
} |
| 199 |
|
| 200 |
impl IpcServer { |
| 201 |
fn broadcast_event(&mut self, event: &str, data: Value) { |
| 202 |
let msg = Event { event: event.into(), data }; |
| 203 |
let json = serde_json::to_string(&msg).unwrap(); |
| 204 |
|
| 205 |
self.clients.retain(|client| { |
| 206 |
if client.subscriptions.contains(event) { |
| 207 |
client.stream.try_write(json.as_bytes()).is_ok() |
| 208 |
} else { |
| 209 |
true |
| 210 |
} |
| 211 |
}); |
| 212 |
} |
| 213 |
} |
| 214 |
``` |
| 215 |
|
| 216 |
### 6.6 Integration with Event Loop |
| 217 |
- [ ] Run IPC server alongside X event loop |
| 218 |
- [ ] Use tokio runtime or poll-based approach |
| 219 |
- [ ] Handle commands without blocking X events |
| 220 |
- [ ] Thread-safe communication with WM state |
| 221 |
|
| 222 |
```rust |
| 223 |
// Option 1: Tokio with channels |
| 224 |
fn main() { |
| 225 |
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(100); |
| 226 |
let (event_tx, event_rx) = tokio::sync::broadcast::channel(100); |
| 227 |
|
| 228 |
// Spawn IPC server task |
| 229 |
tokio::spawn(async move { |
| 230 |
ipc_server.run(cmd_tx, event_rx).await; |
| 231 |
}); |
| 232 |
|
| 233 |
// X event loop |
| 234 |
loop { |
| 235 |
// Check for IPC commands |
| 236 |
while let Ok(cmd) = cmd_rx.try_recv() { |
| 237 |
handle_ipc_command(cmd); |
| 238 |
} |
| 239 |
|
| 240 |
// Handle X events |
| 241 |
let event = conn.wait_for_event()?; |
| 242 |
handle_x_event(event); |
| 243 |
|
| 244 |
// Broadcast events |
| 245 |
event_tx.send(/* ... */); |
| 246 |
} |
| 247 |
} |
| 248 |
``` |
| 249 |
|
| 250 |
### 6.7 garctl CLI Tool |
| 251 |
- [ ] Create `garctl/src/main.rs` |
| 252 |
- [ ] Connect to gar socket |
| 253 |
- [ ] Send commands from CLI args |
| 254 |
- [ ] Print responses |
| 255 |
- [ ] Support event monitoring mode |
| 256 |
|
| 257 |
```rust |
| 258 |
// garctl/src/main.rs |
| 259 |
use clap::Parser; |
| 260 |
|
| 261 |
#[derive(Parser)] |
| 262 |
struct Cli { |
| 263 |
#[clap(subcommand)] |
| 264 |
command: Command, |
| 265 |
} |
| 266 |
|
| 267 |
#[derive(Parser)] |
| 268 |
enum Command { |
| 269 |
Focus { direction: String }, |
| 270 |
Workspace { number: u32 }, |
| 271 |
GetTree, |
| 272 |
Subscribe { events: Vec<String> }, |
| 273 |
// ... |
| 274 |
} |
| 275 |
|
| 276 |
fn main() -> Result<()> { |
| 277 |
let cli = Cli::parse(); |
| 278 |
let mut socket = UnixStream::connect(get_socket_path())?; |
| 279 |
|
| 280 |
let request = match cli.command { |
| 281 |
Command::Focus { direction } => { |
| 282 |
json!({ "type": "command", "command": "focus", "args": { "direction": direction } }) |
| 283 |
} |
| 284 |
// ... |
| 285 |
}; |
| 286 |
|
| 287 |
socket.write_all(serde_json::to_string(&request)?.as_bytes())?; |
| 288 |
|
| 289 |
let mut response = String::new(); |
| 290 |
socket.read_to_string(&mut response)?; |
| 291 |
println!("{}", response); |
| 292 |
|
| 293 |
Ok(()) |
| 294 |
} |
| 295 |
``` |
| 296 |
|
| 297 |
## garctl Usage |
| 298 |
|
| 299 |
```bash |
| 300 |
# Focus direction |
| 301 |
garctl focus left |
| 302 |
garctl focus right |
| 303 |
|
| 304 |
# Workspaces |
| 305 |
garctl workspace 2 |
| 306 |
garctl move-to-workspace 3 |
| 307 |
|
| 308 |
# Queries |
| 309 |
garctl get-tree |
| 310 |
garctl get-workspaces |
| 311 |
garctl get-focused |
| 312 |
|
| 313 |
# Events (stays open, prints events) |
| 314 |
garctl subscribe window_focus workspace_focus |
| 315 |
``` |
| 316 |
|
| 317 |
## Acceptance Criteria |
| 318 |
|
| 319 |
1. Socket created at `$XDG_RUNTIME_DIR/gar.sock` |
| 320 |
2. `garctl` can send commands and receive responses |
| 321 |
3. All WM actions available via IPC |
| 322 |
4. Query commands return proper JSON |
| 323 |
5. Event subscription works for focus/workspace changes |
| 324 |
6. Multiple concurrent clients supported |
| 325 |
|
| 326 |
## Testing Strategy |
| 327 |
|
| 328 |
```bash |
| 329 |
# Start gar |
| 330 |
DISPLAY=:1 cargo run |
| 331 |
|
| 332 |
# Test command |
| 333 |
garctl focus left # Should return success |
| 334 |
|
| 335 |
# Test query |
| 336 |
garctl get-tree | jq . # Should show tree structure |
| 337 |
|
| 338 |
# Test events |
| 339 |
garctl subscribe window_focus & |
| 340 |
# Open/focus windows, events should print |
| 341 |
``` |
| 342 |
|
| 343 |
## Notes |
| 344 |
|
| 345 |
- Socket permissions should be user-only (0600) |
| 346 |
- Consider i3-compatible message format for polybar compatibility |
| 347 |
- Add timeout for client reads to prevent blocking |
| 348 |
- Log all IPC activity for debugging |