1mod capture;
2mod feedback;
3mod ipc;
4mod protocols;
5#[cfg(test)]
6mod transport_tests;
7
8use std::{error::Error, io::Write, path::Path};
9type Result<T> = std::result::Result<T, Box<dyn Error>>;
10
11fn emit(value: serde_json::Value) -> Result<()> {
12 println!("{value}");
13 std::io::stdout().flush()?;
14 Ok(())
15}
16
17/// Metadata of the captured window, or none if it changed while the capture ran.
18fn captured_metadata(captured: &ipc::Window, after: Vec<ipc::Window>) -> Option<serde_json::Value> {
19 // Never pair text with a different/closed window or stale geometry.
20 after
21 .into_iter()
22 .find(|w| w.address == captured.address)
23 .filter(|w| captured.matches_after_capture(w))
24 .map(|_| captured.metadata())
25}
26
27fn run() -> Result<()> {
28 ipc::session_directory()?;
29 let args: Vec<String> = std::env::args().collect();
30 match args.get(1).map(String::as_str) {
31 Some("check") => emit(serde_json::json!({"feedbackAvailable": capture::check()?})),
32 Some("capture") if args.len() == 3 => {
33 ipc::ensure_unlocked()?;
34 let window = ipc::active_window()?;
35 capture::capture(window.address()?, Path::new(&args[2]))?;
36 ipc::ensure_unlocked()?;
37 emit(serde_json::json!({"window": captured_metadata(&window, ipc::windows()?)}))
38 }
39 Some("activate") if args.len() == 4 => {
40 ipc::activate(args[2].parse()?, &args[3])?;
41 Ok(())
42 }
43 Some("feedback") if args.len() == 4 => {
44 feedback::run(Path::new(&args[2]), serde_json::from_str(&args[3])?)
45 }
46 _ => Err("Expected check, capture <directory>, activate <pid> <title>, or feedback <directory> <options>".into()),
47 }
48}
49
50fn main() {
51 if let Err(error) = run() {
52 eprintln!("{error}");
53 std::process::exit(1);
54 }
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60 use serde_json::{Value, json};
61
62 // Shaped like Hyprland's j/activewindow and j/clients replies, including fields we ignore.
63 fn client(address: &str, title: &str, patch: Value) -> Value {
64 let mut client = json!({
65 "address": address, "mapped": true, "hidden": false,
66 "at": [-1920, 20], "size": [1000, 800], "workspace": {"id": 2, "name": "2"},
67 "floating": false, "monitor": 1, "class": "kitty", "title": title,
68 "initialClass": "kitty", "initialTitle": "kitty", "pid": 4242, "xwayland": false,
69 });
70 client
71 .as_object_mut()
72 .unwrap()
73 .extend(patch.as_object().unwrap().clone());
74 client
75 }
76
77 fn parse<T: serde::de::DeserializeOwned>(reply: Value) -> T {
78 serde_json::from_str(&reply.to_string()).unwrap()
79 }
80
81 fn metadata_after(before: Value, after: Vec<Value>) -> Option<Value> {
82 captured_metadata(&parse(before), parse(Value::Array(after)))
83 }
84
85 #[test]
86 fn keeps_captured_metadata_when_only_the_title_spinner_ticks() {
87 for (before, after) in [("⠋ Document", "⠙ Document"), ("⠏ Document", "Document")] {
88 let metadata = metadata_after(
89 client("0xabc", before, json!({})),
90 vec![
91 client("0xdef", "Other", json!({})),
92 client("0xabc", after, json!({})),
93 ],
94 )
95 .unwrap_or_else(|| panic!("{before:?} -> {after:?} dropped metadata"));
96 assert_eq!(metadata["title"], before);
97 assert_eq!(metadata["processId"], 4242);
98 assert_eq!(metadata["appName"], "kitty");
99 assert_eq!(
100 metadata["bounds"],
101 json!({"x": -1920.0, "y": 20.0, "width": 1000.0, "height": 800.0})
102 );
103 }
104 }
105
106 #[test]
107 fn drops_metadata_when_title_whitespace_changes() {
108 for (before, after) in [
109 ("Document", " Document "),
110 ("Document", "Document "),
111 (" Document", "Document"),
112 ("⠋ Document", "⠙ Document "),
113 ] {
114 assert_eq!(
115 metadata_after(
116 client("0xabc", before, json!({})),
117 vec![client("0xabc", after, json!({}))],
118 ),
119 None,
120 "{before:?} -> {after:?}",
121 );
122 }
123 }
124
125 #[test]
126 fn drops_metadata_when_the_window_identity_or_state_changed() {
127 let before = || client("0xabc", "⠋ Document", json!({}));
128 for after in [
129 client("0xabc", "⠙ Other", json!({})),
130 client("0xabc", "⠁ Document", json!({})),
131 client("0xabc", "⠙Document", json!({})),
132 client("0xabc", "⠙ Document", json!({"at": [-1900, 20]})),
133 client("0xabc", "⠙ Document", json!({"size": [1000, 700]})),
134 client("0xabc", "⠙ Document", json!({"pid": 99})),
135 client("0xabc", "⠙ Document", json!({"class": "foot"})),
136 client("0xabc", "⠙ Document", json!({"hidden": true})),
137 client("0xabc", "⠙ Document", json!({"mapped": false})),
138 client("0xdef", "⠋ Document", json!({})),
139 ] {
140 assert_eq!(
141 metadata_after(before(), vec![after.clone()]),
142 None,
143 "{after}"
144 );
145 }
146 assert_eq!(metadata_after(before(), vec![]), None);
147 }
148}