1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
|
//! Control CLI for srdwm's control socket (`crates/platform/src/ipc.rs`,
//! shared by every backend that has one). Deliberately dumb: builds one
//! request line from argv, writes it, reads the response(s), prints them.
//! All real logic - and all JSON encoding - lives server-side; this
//! stays a thin pipe so it never needs a JSON dependency of its own.
//!
//! Usage:
//! srd clients list windows, one JSON object
//! srd workspaces list workspaces, one JSON object
//! srd keyboard layout active XKB layout name, one JSON object
//! srd subscribe like `clients`, then one JSON
//! object per line forever, each time
//! the window list, workspace list, or
//! keyboard layout actually changes
//! (one "clients"/"workspaces"/
//! "keyboard_layout"-tagged line per
//! event)
//!
//! `dispatch` reads as a real verb phrase, not a joined identifier --
//! `toggle floating`, not `toggle_floating`. Multi-word actions are
//! genuinely separate arguments (ordinary shell word-splitting), matched
//! here on the words that follow the leading verb rather than one fused
//! snake_case token:
//! srd dispatch focus ID
//! srd dispatch close ID
//! srd dispatch toggle visibility ID hide/show a window
//! srd dispatch toggle maximize ID
//! srd dispatch toggle fullscreen ID
//! srd dispatch toggle floating ID
//! srd dispatch toggle pinned ID
//! srd dispatch move window ID left|right|up|down
//! srd dispatch move workspace ID WORKSPACE
//! srd dispatch activate workspace ID
//! srd dispatch cycle keyboard layout no ID - there's only ever one keyboard
//! srd set border_width 3 live theme values, applied immediately
//! srd set border_color '#cba6f7' (hex string)
//! srd set corner_radius 10
//! srd set gap_inner 8
//! srd set gap_outer 16
//! srd set shadows true
//! srd set rounded_corners true
//! srd set decoration_mode server|client
//!
//! The socket is Unix-domain; on platforms without one this always fails
//! cleanly rather than not building at all, since it's one binary in a
//! workspace that otherwise targets Windows and macOS too.
#[cfg(unix)]
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let request = match build_request(&args) {
Ok(r) => r,
Err(msg) => {
eprintln!("srd: {msg}");
print_usage();
std::process::exit(2);
}
};
if args.first().map(String::as_str) == Some("subscribe") {
if let Err(e) = unix::stream(&request) {
eprintln!("srd: {e}");
std::process::exit(1);
}
return;
}
let response = match unix::send(&request) {
Ok(r) => r,
Err(e) => {
eprintln!("srd: {e}");
std::process::exit(1);
}
};
println!("{response}");
// A rejected `dispatch`/`set` (a bad key, a malformed value, a window
// id that doesn't exist) is still a *successful* request/response --
// `unix::send` returns `Ok` either way, since nothing went wrong at
// the transport level - so a caller checking only the exit code, not
// the JSON body, saw every rejection as a success. Reported live by an
// AGS peer session: it memoizes the last value sent per `srd set` key
// to avoid re-spawning a process on every option change, and a
// rejected write counted as a success would have pinned that setting
// permanently. `{"ok":false,...}` is the one response shape that ever
// contains a literal `"ok":` field at all - every query command
// (`clients`/`monitors`/`workspaces`/`keyboard_layout`) returns a
// differently-shaped body with no such field, so this can't misfire
// against one of those. A plain substring check, not real JSON
// parsing, on purpose: `crates/platform/src/ipc.rs`'s `err`/`ok`
// helpers always emit this exact literal (`serde_json` preserves
// struct field declaration order), and this binary is deliberately
// dumb/dependency-free - see this file's own module doc comment.
if response.contains(r#""ok":false"#) {
std::process::exit(1);
}
}
#[cfg(not(unix))]
fn main() {
eprintln!("srd: srdwm's control socket is Unix-only; nothing to connect to on this platform");
std::process::exit(1);
}
fn build_request(args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
Some("clients") => Ok(r#"{"cmd":"clients"}"#.to_string()),
Some("monitors") => Ok(r#"{"cmd":"monitors"}"#.to_string()),
Some("workspaces") => Ok(r#"{"cmd":"workspaces"}"#.to_string()),
Some("keyboard") if args.get(1).map(String::as_str) == Some("layout") => Ok(r#"{"cmd":"keyboard_layout"}"#.to_string()),
Some("keyboard") => Err("did you mean 'srd keyboard layout'?".to_string()),
Some("subscribe") => Ok(r#"{"cmd":"subscribe"}"#.to_string()),
Some("dispatch") => build_dispatch(&args[1..]),
// `srd capture workspace <id> <path> [<width>x<height>]` - an
// off-screen render of that workspace's window tree, written to
// `path` as a PPM image. Not under `dispatch`: every `dispatch`
// action mutates window/workspace state, this only reads it. Sits
// next to `clients`/`monitors` in spirit, just backend-owned
// (needs a renderer) rather than answerable from core state alone
// - see `WindowManager::request_capture_workspace`'s own doc
// comment for why this exists (a workspace switcher's thumbnail
// for a workspace that isn't the one currently on screen, which no
// screencopy protocol can see).
Some("capture") => {
if args.get(1).map(String::as_str) != Some("workspace") {
return Err("'capture' only supports 'workspace' - usage: srd capture workspace <id> <path> [<width>x<height>]".to_string());
}
let id: u64 = args.get(2).ok_or("capture needs a workspace id")?.parse().map_err(|_| "id must be a number".to_string())?;
let path = args.get(3).ok_or("capture needs an output path")?;
let size = match args.get(4) {
None => String::new(),
Some(spec) => {
let (w, h) = spec.split_once('x').ok_or("size must look like <width>x<height>")?;
let w: u64 = w.parse().map_err(|_| "width must be a number".to_string())?;
let h: u64 = h.parse().map_err(|_| "height must be a number".to_string())?;
format!(r#","width":{w},"height":{h}"#)
}
};
Ok(format!(r#"{{"cmd":"capture_workspace","id":{id},"path":{path:?}{size}}}"#))
}
// Value encoding is picked per key rather than guessed from the
// string's shape (e.g. treating anything that parses as a number
// as one): `border_color` is a hex string that happens to start
// with punctuation, not a bare word, and JSON's `true`/`false`
// need to land unquoted for the server's `as_bool()` to see them
// as booleans at all, not a string it then has to reject.
Some("set") => {
let key = args
.get(1)
.ok_or("set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/shadows/rounded_corners/decoration_mode)")?;
let raw = args.get(2).ok_or("set needs a value")?;
let value = match key.as_str() {
"border_width" | "corner_radius" | "gap_inner" | "gap_outer" => {
raw.parse::<u64>().map_err(|_| format!("{key} needs a numeric value"))?.to_string()
}
"shadows" | "rounded_corners" => match raw.as_str() {
"true" | "false" => raw.clone(),
_ => return Err(format!("{key} needs 'true' or 'false'")),
},
"decoration_mode" => match raw.as_str() {
"server" | "client" => format!("{:?}", raw),
_ => return Err(format!("{key} needs 'server' or 'client'")),
},
"border_color" => format!("{:?}", raw),
_ => return Err(format!("unknown set key '{key}'")),
};
Ok(format!(r#"{{"cmd":"set","key":"{key}","value":{value}}}"#))
}
_ => Err(
"expected 'clients', 'monitors', 'workspaces', 'keyboard layout', 'subscribe', 'dispatch <action> <id>', 'capture workspace <id> <path>', or 'set <key> <value>'"
.to_string(),
),
}
}
/// `dispatch`'s own verb-phrase parser, split out of `build_request` for
/// its own sake - reads like a real command (`toggle floating`, `move
/// window`), not a single joined snake_case token (`toggle_floating`).
/// Live report: a joined identifier reads as an internal detail leaking
/// into the CLI surface, not a real command a person would type.
///
/// `set`'s keys (`corner_radius`, `border_color`, ...) deliberately keep
/// their existing snake_case names, unlike `dispatch`'s actions - they're
/// property/setting names, not verb phrases (the same distinction `git
/// config user.name` or `systemctl set-property CPUQuota=` draw: a key
/// stays an identifier, a command reads like English), and renaming them
/// would break the AGS peer's already-working live Settings panel, which
/// calls `srd set` directly today. `dispatch`'s actions carry no such
/// cost yet - the peer's own integration for these is either brand new
/// (the four just added) or already being migrated to `zwlr-foreign-
/// toplevel` instead of `srd dispatch` entirely, so this is the moment to
/// fix the syntax before more depends on it, not after.
fn build_dispatch(args: &[String]) -> Result<String, String> {
let verb = args.first().ok_or("dispatch needs an action, e.g. 'focus', 'close', 'toggle maximize', 'move window'")?;
let usage_hint =
"expected one of: focus, close, lock, toggle visibility/maximize/fullscreen/floating/pinned, move window/workspace, activate workspace, cycle keyboard layout";
match verb.as_str() {
"focus" | "close" => {
let id: u64 = args.get(1).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?;
Ok(format!(r#"{{"cmd":"{verb}","id":{id}}}"#))
}
// No id - there's only ever one session to lock.
"lock" => Ok(r#"{"cmd":"lock"}"#.to_string()),
"toggle" => {
let noun = args.get(1).ok_or("'toggle' needs a target: visibility, maximize, fullscreen, floating, or pinned")?;
let cmd = match noun.as_str() {
"visibility" => "toggle_visibility",
"maximize" => "toggle_maximize",
"fullscreen" => "toggle_fullscreen",
"floating" => "toggle_floating",
"pinned" => "toggle_pinned",
_ => return Err(format!("unknown 'toggle' target '{noun}' - {usage_hint}")),
};
let id: u64 = args.get(2).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?;
Ok(format!(r#"{{"cmd":"{cmd}","id":{id}}}"#))
}
"move" => {
let noun = args.get(1).ok_or("'move' needs a target: window or workspace")?;
let id: u64 = args.get(2).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?;
match noun.as_str() {
"window" => {
let direction = args.get(3).ok_or("'move window' needs a direction (left/right/up/down)")?;
if !matches!(direction.as_str(), "left" | "right" | "up" | "down") {
return Err(format!("direction must be one of: left, right, up, down (got '{direction}')"));
}
Ok(format!(r#"{{"cmd":"move_window","id":{id},"direction":"{direction}"}}"#))
}
"workspace" => {
let workspace: u64 =
args.get(3).ok_or("'move workspace' needs a target workspace")?.parse().map_err(|_| "workspace must be a number".to_string())?;
Ok(format!(r#"{{"cmd":"move_to_workspace","id":{id},"workspace":{workspace}}}"#))
}
_ => Err(format!("unknown 'move' target '{noun}' - {usage_hint}")),
}
}
"activate" => {
if args.get(1).map(String::as_str) != Some("workspace") {
return Err(format!("'activate' only supports 'workspace' - {usage_hint}"));
}
let id: u64 = args.get(2).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?;
Ok(format!(r#"{{"cmd":"activate_workspace","id":{id}}}"#))
}
// No id - there's only ever one keyboard as far as this IPC is
// concerned, unlike every other action here which targets a
// specific window or workspace.
"cycle" => {
if args.get(1).map(String::as_str) != Some("keyboard") || args.get(2).map(String::as_str) != Some("layout") {
return Err(format!("'cycle' only supports 'keyboard layout' - {usage_hint}"));
}
Ok(r#"{"cmd":"cycle_keyboard_layout"}"#.to_string())
}
_ => Err(format!("unknown dispatch action '{verb}' - {usage_hint}")),
}
}
fn print_usage() {
eprintln!("usage:");
eprintln!(" srd clients");
eprintln!(" srd monitors");
eprintln!(" srd workspaces");
eprintln!(" srd keyboard layout");
eprintln!(" srd subscribe");
eprintln!(" srd dispatch focus <id>");
eprintln!(" srd dispatch lock");
eprintln!(" srd dispatch close <id>");
eprintln!(" srd dispatch toggle visibility <id>");
eprintln!(" srd dispatch toggle maximize <id>");
eprintln!(" srd dispatch toggle fullscreen <id>");
eprintln!(" srd dispatch toggle floating <id>");
eprintln!(" srd dispatch toggle pinned <id>");
eprintln!(" srd dispatch move window <id> <left|right|up|down>");
eprintln!(" srd dispatch move workspace <id> <workspace>");
eprintln!(" srd dispatch activate workspace <id>");
eprintln!(" srd dispatch cycle keyboard layout");
eprintln!(" srd capture workspace <id> <path> [<width>x<height>]");
eprintln!(" srd set border_width <n>");
eprintln!(" srd set border_color <#hex>");
eprintln!(" srd set corner_radius <n>");
eprintln!(" srd set gap_inner <n>");
eprintln!(" srd set gap_outer <n>");
eprintln!(" srd set shadows <true|false>");
eprintln!(" srd set rounded_corners <true|false>");
eprintln!(" srd set decoration_mode <server|client>");
}
#[cfg(test)]
mod tests {
use super::build_request;
fn args(strs: &[&str]) -> Vec<String> {
strs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn toggle_floating_and_toggle_pinned_read_as_two_words() {
assert_eq!(build_request(&args(&["dispatch", "toggle", "floating", "42"])).unwrap(), r#"{"cmd":"toggle_floating","id":42}"#);
assert_eq!(build_request(&args(&["dispatch", "toggle", "pinned", "42"])).unwrap(), r#"{"cmd":"toggle_pinned","id":42}"#);
}
#[test]
fn toggle_with_an_unknown_target_is_rejected_client_side() {
assert!(build_request(&args(&["dispatch", "toggle", "sideways", "42"])).is_err());
}
#[test]
fn capture_workspace_with_and_without_a_size() {
assert_eq!(
build_request(&args(&["capture", "workspace", "1", "/tmp/ws1.ppm"])).unwrap(),
r#"{"cmd":"capture_workspace","id":1,"path":"/tmp/ws1.ppm"}"#
);
assert_eq!(
build_request(&args(&["capture", "workspace", "1", "/tmp/ws1.ppm", "200x120"])).unwrap(),
r#"{"cmd":"capture_workspace","id":1,"path":"/tmp/ws1.ppm","width":200,"height":120}"#
);
assert!(build_request(&args(&["capture", "workspace", "1", "/tmp/ws1.ppm", "bogus"])).is_err());
assert!(build_request(&args(&["capture", "workspace", "1"])).is_err(), "missing path must error");
assert!(build_request(&args(&["capture", "monitor", "1", "/tmp/x.ppm"])).is_err(), "only 'workspace' is a valid capture target");
}
#[test]
fn move_window_needs_a_direction_and_validates_it() {
assert_eq!(build_request(&args(&["dispatch", "move", "window", "42", "left"])).unwrap(), r#"{"cmd":"move_window","id":42,"direction":"left"}"#);
assert!(build_request(&args(&["dispatch", "move", "window", "42"])).is_err(), "missing direction must error, not silently omit it");
assert!(build_request(&args(&["dispatch", "move", "window", "42", "sideways"])).is_err(), "an invalid direction must be rejected client-side");
}
#[test]
fn move_workspace_needs_a_numeric_workspace() {
assert_eq!(build_request(&args(&["dispatch", "move", "workspace", "42", "2"])).unwrap(), r#"{"cmd":"move_to_workspace","id":42,"workspace":2}"#);
assert!(build_request(&args(&["dispatch", "move", "workspace", "42"])).is_err(), "missing workspace must error");
assert!(build_request(&args(&["dispatch", "move", "workspace", "42", "not-a-number"])).is_err());
}
#[test]
fn lock_needs_no_id() {
assert_eq!(build_request(&args(&["dispatch", "lock"])).unwrap(), r#"{"cmd":"lock"}"#);
}
#[test]
fn focus_and_close_stay_single_word_verbs() {
assert_eq!(build_request(&args(&["dispatch", "focus", "42"])).unwrap(), r#"{"cmd":"focus","id":42}"#);
assert_eq!(build_request(&args(&["dispatch", "close", "42"])).unwrap(), r#"{"cmd":"close","id":42}"#);
}
#[test]
fn activate_workspace_and_cycle_keyboard_layout_read_as_real_phrases() {
assert_eq!(build_request(&args(&["dispatch", "activate", "workspace", "1"])).unwrap(), r#"{"cmd":"activate_workspace","id":1}"#);
assert_eq!(build_request(&args(&["dispatch", "cycle", "keyboard", "layout"])).unwrap(), r#"{"cmd":"cycle_keyboard_layout"}"#);
}
#[test]
fn a_joined_snake_case_action_is_no_longer_accepted() {
// The old single-token spelling must not silently keep working --
// a caller still using it should get a clear error, not a wrong
// command reaching the server.
assert!(build_request(&args(&["dispatch", "toggle_floating", "42"])).is_err());
}
#[test]
fn keyboard_layout_query_reads_as_two_words_too() {
assert_eq!(build_request(&args(&["keyboard", "layout"])).unwrap(), r#"{"cmd":"keyboard_layout"}"#);
assert!(build_request(&args(&["keyboard_layout"])).is_err());
}
#[test]
fn set_decoration_mode_accepts_only_server_or_client() {
assert_eq!(build_request(&args(&["set", "decoration_mode", "server"])).unwrap(), r#"{"cmd":"set","key":"decoration_mode","value":"server"}"#);
assert_eq!(build_request(&args(&["set", "decoration_mode", "client"])).unwrap(), r#"{"cmd":"set","key":"decoration_mode","value":"client"}"#);
assert!(build_request(&args(&["set", "decoration_mode", "both"])).is_err());
}
}
#[cfg(unix)]
mod unix {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
/// `<display>` in `srdwm-<display>.sock` is whichever env var the
/// compositor itself used to name it: `WAYLAND_DISPLAY` (Wayland
/// backend) or `DISPLAY` (X11 backend) - see `srdwm_platform::
/// IpcServer::bind`'s callers in `crates/wayland`/`crates/x11` for the
/// matching naming choice on the server side. Reuses `srdwm_platform::
/// detect()` rather than re-deriving the same Wayland-vs-X11 decision
/// a second, potentially-drifting way here.
fn socket_path() -> Result<PathBuf, String> {
let dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).ok_or("XDG_RUNTIME_DIR is not set")?;
let display = match srdwm_platform::detect() {
srdwm_platform::PlatformKind::X11 => std::env::var("DISPLAY").map_err(|_| "DISPLAY is not set".to_string())?,
_ => std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string()),
};
Ok(dir.join(format!("srdwm-{display}.sock")))
}
fn connect() -> Result<UnixStream, String> {
let path = socket_path()?;
UnixStream::connect(&path)
.map_err(|e| format!("can't reach srdwm's control socket at {} ({e}) - is srdwm running, and are WAYLAND_DISPLAY/DISPLAY set correctly?", path.display()))
}
pub fn send(request: &str) -> Result<String, String> {
let mut stream = connect()?;
stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;
stream.write_all(b"\n").map_err(|e| e.to_string())?;
let mut line = String::new();
BufReader::new(&stream).read_line(&mut line).map_err(|e| e.to_string())?;
Ok(line.trim_end().to_string())
}
/// `subscribe`'s connection: unlike `send`, this never closes on its
/// own - the server keeps it open and writes one more line every time
/// the window list changes, so this prints each one as it arrives
/// until the process is killed or the compositor closes the socket.
pub fn stream(request: &str) -> Result<(), String> {
let mut conn = connect()?;
conn.write_all(request.as_bytes()).map_err(|e| e.to_string())?;
conn.write_all(b"\n").map_err(|e| e.to_string())?;
let reader = BufReader::new(conn);
for line in reader.lines() {
match line {
Ok(line) => println!("{line}"),
Err(e) => return Err(e.to_string()),
}
}
Ok(())
}
}
|