| 1 | //! Exercise real D-Bus FD passing and reply types without accessing the desktop bus. |
| 2 | use super::*; |
| 3 | use 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")] |
| 14 | enum CaptureError { |
| 15 | NoAuthorized(String), |
| 16 | InvalidWindow(String), |
| 17 | } |
| 18 | |
| 19 | #[derive(Default)] |
| 20 | struct 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 | } |
| 29 | struct Screenshots(Arc<State>); |
| 30 | #[zbus::interface(name = "org.kde.KWin.ScreenShot2")] |
| 31 | impl 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 | } |
| 63 | impl 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 | |
| 85 | struct Scripting(Arc<State>); |
| 86 | #[zbus::interface(name = "org.kde.kwin.Scripting")] |
| 87 | impl 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 | } |
| 99 | struct LoadedScript(Arc<State>); |
| 100 | #[zbus::interface(name = "org.kde.kwin.Script")] |
| 101 | impl 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 | |
| 140 | pub(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 | } |
| 148 | impl 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 | } |
| 202 | impl 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] |
| 211 | fn 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] |
| 227 | fn 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] |
| 254 | fn 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] |
| 262 | fn 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 | } |