1//! Exercise real D-Bus FD passing and reply types without accessing the desktop bus.
2use super::*;
3use std::{
4 io::{BufRead, BufReader},
5 process::{Child, Command, Stdio},
6 sync::{
7 Arc, Mutex,
8 atomic::{AtomicBool, AtomicUsize, Ordering},
9 },
10};
11
12#[derive(Debug, zbus::DBusError)]
13#[zbus(prefix = "org.kde.KWin.ScreenShot2.Error")]
14enum CaptureError {
15 NoAuthorized(String),
16 InvalidWindow(String),
17}
18
19#[derive(Default)]
20struct State {
21 denied: AtomicBool,
22 change_identity: AtomicBool,
23 spin_title: AtomicBool,
24 captures: AtomicUsize,
25 unloaded: AtomicUsize,
26 script: Mutex<String>,
27 captured_ids: Mutex<Vec<String>>,
28}
29struct Screenshots(Arc<State>);
30#[zbus::interface(name = "org.kde.KWin.ScreenShot2")]
31impl Screenshots {
32 #[zbus(property)]
33 fn version(&self) -> u32 {
34 5
35 }
36
37 fn capture_window(
38 &self,
39 handle: &str,
40 _options: HashMap<String, OwnedValue>,
41 pipe: Fd<'_>,
42 ) -> std::result::Result<HashMap<String, OwnedValue>, CaptureError> {
43 if self.0.denied.load(Ordering::SeqCst) {
44 return Err(CaptureError::NoAuthorized("Denied".into()));
45 }
46 if handle != "{window-1}" {
47 return Err(CaptureError::InvalidWindow("Not a window".into()));
48 }
49 self.0.captured_ids.lock().unwrap().push(handle.into());
50 self.write(pipe)
51 }
52 fn capture_active_window(
53 &self,
54 _options: HashMap<String, OwnedValue>,
55 pipe: Fd<'_>,
56 ) -> std::result::Result<HashMap<String, OwnedValue>, CaptureError> {
57 if self.0.denied.load(Ordering::SeqCst) {
58 return Err(CaptureError::NoAuthorized("Denied".into()));
59 }
60 self.write(pipe)
61 }
62}
63impl Screenshots {
64 fn write(
65 &self,
66 pipe: Fd<'_>,
67 ) -> std::result::Result<HashMap<String, OwnedValue>, CaptureError> {
68 self.0.captures.fetch_add(1, Ordering::SeqCst);
69 let fd = pipe.as_fd().try_clone_to_owned().unwrap();
70 std::thread::spawn(move || {
71 let mut file = std::fs::File::from(fd);
72 file.write_all(&[0xff112233_u32.to_ne_bytes(), 0xff445566_u32.to_ne_bytes()].concat())
73 .unwrap();
74 });
75 Ok(HashMap::from([
76 ("type".into(), Value::from("raw").try_to_owned().unwrap()),
77 ("width".into(), 2_u32.into()),
78 ("height".into(), 1_u32.into()),
79 ("stride".into(), 8_u32.into()),
80 ("format".into(), 5_u32.into()),
81 ]))
82 }
83}
84
85struct Scripting(Arc<State>);
86#[zbus::interface(name = "org.kde.kwin.Scripting")]
87impl Scripting {
88 #[zbus(name = "loadScript")]
89 fn load_script(&self, path: &str, _name: &str) -> i32 {
90 *self.0.script.lock().unwrap() = std::fs::read_to_string(path).unwrap();
91 1
92 }
93 #[zbus(name = "unloadScript")]
94 fn unload_script(&self, _name: &str) -> bool {
95 self.0.unloaded.fetch_add(1, Ordering::SeqCst);
96 true
97 }
98}
99struct LoadedScript(Arc<State>);
100#[zbus::interface(name = "org.kde.kwin.Script")]
101impl LoadedScript {
102 #[zbus(name = "run")]
103 async fn run(&self, #[zbus(connection)] connection: &zbus::Connection) {
104 let source = self.0.script.lock().unwrap().clone();
105 let destination = source
106 .split("callDBus(")
107 .nth(1)
108 .unwrap()
109 .split(',')
110 .next()
111 .unwrap();
112 let destination: String = serde_json::from_str(destination).unwrap();
113 let changed = self.0.change_identity.load(Ordering::SeqCst)
114 && self.0.captures.load(Ordering::SeqCst) > 0;
115 let title = if changed {
116 "New document"
117 } else if self.0.spin_title.load(Ordering::SeqCst) {
118 if self.0.captures.load(Ordering::SeqCst) > 0 {
119 "⠙ Document"
120 } else {
121 "⠋ Document"
122 }
123 } else {
124 "Document"
125 };
126 let value = serde_json::json!({"id": "{window-1}", "title": title, "appName": "Kate", "appIdentifier": "org.kde.kate", "processId": 123, "bounds": {"x": 0, "y": 0, "width": 800, "height": 600}, "clientBounds": {"x": 0, "y": 29, "width": 800, "height": 571}}).to_string();
127 connection
128 .call_method(
129 Some(destination.as_str()),
130 "/com/t3tools/KdeCapture",
131 Some("com.t3tools.KdeCapture"),
132 "Reply",
133 &(value,),
134 )
135 .await
136 .unwrap();
137 }
138}
139
140pub(super) struct Fixture {
141 daemon: Child,
142 directory: std::path::PathBuf,
143 pub(super) _server: Connection,
144 pub(super) client: Connection,
145 pub(super) address: String,
146 state: Arc<State>,
147}
148impl Fixture {
149 pub(super) fn new() -> Self {
150 // mktemp provides a private directory; no predictable paths or desktop bus env changes.
151 let output = Command::new("mktemp")
152 .args(["-d", "/tmp/t3-kde-bus-XXXXXX"])
153 .output()
154 .unwrap();
155 assert!(output.status.success());
156 let directory = std::path::PathBuf::from(String::from_utf8(output.stdout).unwrap().trim());
157 let mut daemon = Command::new("dbus-daemon")
158 .args([
159 "--session",
160 "--nofork",
161 "--nopidfile",
162 "--print-address",
163 &format!("--address=unix:path={}/bus", directory.display()),
164 ])
165 .stdout(Stdio::piped())
166 .spawn()
167 .unwrap();
168 let mut address = String::new();
169 BufReader::new(daemon.stdout.take().unwrap())
170 .read_line(&mut address)
171 .unwrap();
172 let state = Arc::new(State::default());
173 let server = Builder::address(address.trim())
174 .unwrap()
175 .name(SCREENSHOT)
176 .unwrap()
177 .name("org.kde.KWin")
178 .unwrap()
179 .serve_at("/org/kde/KWin/ScreenShot2", Screenshots(state.clone()))
180 .unwrap()
181 .serve_at("/Scripting", Scripting(state.clone()))
182 .unwrap()
183 .serve_at("/Scripting/Script1", LoadedScript(state.clone()))
184 .unwrap()
185 .build()
186 .unwrap();
187 let client = Builder::address(address.trim())
188 .unwrap()
189 .method_timeout(DEADLINE)
190 .build()
191 .unwrap();
192 Self {
193 daemon,
194 directory,
195 _server: server,
196 client,
197 address: address.trim().to_owned(),
198 state,
199 }
200 }
201}
202impl Drop for Fixture {
203 fn drop(&mut self) {
204 let _ = self.daemon.kill();
205 let _ = self.daemon.wait();
206 let _ = std::fs::remove_dir_all(&self.directory);
207 }
208}
209
210#[test]
211fn permission_probe_never_captures_and_rejects_denial() {
212 let f = Fixture::new();
213 check(&f.client).unwrap();
214 assert_eq!(f.state.captures.load(Ordering::SeqCst), 0);
215 f.state.denied.store(true, Ordering::SeqCst);
216 assert!(
217 check(&f.client)
218 .unwrap_err()
219 .to_string()
220 .contains("NoAuthorized")
221 );
222 assert!(capture(&f.client, &f.directory).is_err());
223 assert_eq!(f.state.captures.load(Ordering::SeqCst), 0);
224}
225
226#[test]
227fn captures_pinned_window_through_real_fd_and_cleans_scripts() {
228 let f = Fixture::new();
229 let identity = capture(&f.client, &f.directory).unwrap().unwrap();
230 assert_eq!(identity.process_id, 123);
231 assert_eq!(
232 identity.client_bounds,
233 Bounds {
234 x: 0,
235 y: 29,
236 width: 800,
237 height: 571
238 }
239 );
240 assert_eq!(*f.state.captured_ids.lock().unwrap(), ["{window-1}"]);
241 assert_eq!(f.state.unloaded.load(Ordering::SeqCst), 2);
242 assert!(!f.directory.join("window.js").exists());
243 let mut decoder = png::Decoder::new(BufReader::new(
244 std::fs::File::open(f.directory.join("capture.png")).unwrap(),
245 ))
246 .read_info()
247 .unwrap();
248 let mut pixels = vec![0; decoder.output_buffer_size().unwrap()];
249 decoder.next_frame(&mut pixels).unwrap();
250 assert_eq!(pixels, [0x11, 0x22, 0x33, 255, 0x44, 0x55, 0x66, 255]);
251}
252
253#[test]
254fn omits_identity_when_window_changes_during_capture() {
255 let f = Fixture::new();
256 f.state.change_identity.store(true, Ordering::SeqCst);
257 assert!(capture(&f.client, &f.directory).unwrap().is_none());
258 assert!(f.directory.join("capture.png").exists());
259}
260
261#[test]
262fn keeps_captured_identity_when_only_the_title_spinner_changes() {
263 let f = Fixture::new();
264 f.state.spin_title.store(true, Ordering::SeqCst);
265 let identity = capture(&f.client, &f.directory).unwrap().unwrap();
266 assert_eq!(identity.title, "⠋ Document");
267 assert_eq!(identity.process_id, 123);
268 assert_eq!(*f.state.captured_ids.lock().unwrap(), ["{window-1}"]);
269 assert_eq!(f.state.unloaded.load(Ordering::SeqCst), 2);
270}