| 1 | //! A short-lived QML overlay hosted by KWin, not a persistent desktop effect. |
| 2 | //! Commands arrive on our parent's private stdin; only KWin can consume them. |
| 3 | use super::*; |
| 4 | use std::io::BufRead; |
| 5 | |
| 6 | const OBJECT: &str = "/com/t3tools/KdeCapture/Feedback"; |
| 7 | |
| 8 | #[derive(Deserialize, Serialize)] |
| 9 | #[serde(rename_all = "camelCase")] |
| 10 | struct Options { |
| 11 | bounds: Bounds, |
| 12 | pid: u32, |
| 13 | flash: bool, |
| 14 | animate: bool, |
| 15 | } |
| 16 | |
| 17 | #[derive(Deserialize, Serialize)] |
| 18 | #[serde(tag = "event", rename_all = "camelCase")] |
| 19 | enum Event { |
| 20 | Ready { animate: bool }, |
| 21 | Landed, |
| 22 | Done, |
| 23 | } |
| 24 | |
| 25 | struct Bridge { |
| 26 | owner: String, |
| 27 | commands: async_channel::Receiver<String>, |
| 28 | done: mpsc::SyncSender<()>, |
| 29 | } |
| 30 | impl Bridge { |
| 31 | fn authorize(&self, header: &zbus::message::Header<'_>) -> zbus::fdo::Result<()> { |
| 32 | if header.sender().map(|name| name.as_str()) == Some(self.owner.as_str()) { |
| 33 | Ok(()) |
| 34 | } else { |
| 35 | Err(zbus::fdo::Error::AccessDenied("Not KWin".into())) |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | #[zbus::interface(name = "com.t3tools.KdeCapture.Feedback")] |
| 40 | impl Bridge { |
| 41 | // An async pending call is an event channel, not a timer or a blocking KWin call. |
| 42 | async fn next( |
| 43 | &self, |
| 44 | #[zbus(header)] header: zbus::message::Header<'_>, |
| 45 | ) -> zbus::fdo::Result<String> { |
| 46 | self.authorize(&header)?; |
| 47 | Ok(self |
| 48 | .commands |
| 49 | .recv() |
| 50 | .await |
| 51 | .unwrap_or_else(|_| "{\"command\":\"close\"}".into())) |
| 52 | } |
| 53 | |
| 54 | fn event( |
| 55 | &self, |
| 56 | value: &str, |
| 57 | #[zbus(header)] header: zbus::message::Header<'_>, |
| 58 | ) -> zbus::fdo::Result<()> { |
| 59 | self.authorize(&header)?; |
| 60 | let event: Event = serde_json::from_str(value) |
| 61 | .map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid feedback event".into()))?; |
| 62 | let mut stdout = std::io::stdout().lock(); |
| 63 | let _ = writeln!(stdout, "{}", serde_json::to_string(&event).unwrap()); |
| 64 | let _ = stdout.flush(); |
| 65 | if matches!(event, Event::Done) { |
| 66 | let _ = self.done.try_send(()); |
| 67 | } |
| 68 | Ok(()) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | pub(super) fn supported(connection: &Connection) -> bool { |
| 73 | let Ok(proxy) = Proxy::new( |
| 74 | connection, |
| 75 | "org.kde.KWin", |
| 76 | "/Scripting", |
| 77 | "org.freedesktop.DBus.Introspectable", |
| 78 | ) else { |
| 79 | return false; |
| 80 | }; |
| 81 | proxy |
| 82 | .call::<_, _, String>("Introspect", &()) |
| 83 | .is_ok_and(|xml| xml.contains("name=\"loadDeclarativeScript\"")) |
| 84 | } |
| 85 | |
| 86 | pub(super) fn run(connection: &Connection, directory: &Path, options: &str) -> Result<()> { |
| 87 | let mut options: Options = serde_json::from_str(options)?; |
| 88 | if options.bounds.width == 0 || options.bounds.height == 0 || options.pid == 0 { |
| 89 | return Err("Invalid capture feedback geometry".into()); |
| 90 | } |
| 91 | // Use KDE's config reader to respect config layering and the desktop's motion setting. |
| 92 | let factor = std::process::Command::new("kreadconfig6") |
| 93 | .args([ |
| 94 | "--file", |
| 95 | "kdeglobals", |
| 96 | "--group", |
| 97 | "KDE", |
| 98 | "--key", |
| 99 | "AnimationDurationFactor", |
| 100 | "--default", |
| 101 | "1", |
| 102 | ]) |
| 103 | .output()?; |
| 104 | if !factor.status.success() { |
| 105 | return Err("Couldn't read KDE animation preferences".into()); |
| 106 | } |
| 107 | let factor: f64 = String::from_utf8(factor.stdout)?.trim().parse()?; |
| 108 | if !factor.is_finite() || factor < 0.0 { |
| 109 | return Err("Invalid KDE animation preference".into()); |
| 110 | } |
| 111 | options.animate &= factor > 0.0; |
| 112 | let owner = zbus::blocking::fdo::DBusProxy::new(connection)? |
| 113 | .get_name_owner("org.kde.KWin".try_into()?)? |
| 114 | .to_string(); |
| 115 | let (send, commands) = async_channel::bounded(4); |
| 116 | let (done, finished) = mpsc::sync_channel(1); |
| 117 | connection.object_server().at( |
| 118 | OBJECT, |
| 119 | Bridge { |
| 120 | owner, |
| 121 | commands, |
| 122 | done, |
| 123 | }, |
| 124 | )?; |
| 125 | |
| 126 | // Subscribe before checking, so a lock racing setup can't expose the snapshot. |
| 127 | let locker = Proxy::new( |
| 128 | connection, |
| 129 | "org.freedesktop.ScreenSaver", |
| 130 | "/ScreenSaver", |
| 131 | "org.freedesktop.ScreenSaver", |
| 132 | )?; |
| 133 | let signals = locker.receive_signal("ActiveChanged")?; |
| 134 | if locker.call::<_, _, bool>("GetActive", &())? { |
| 135 | return Err("Screen is locked".into()); |
| 136 | } |
| 137 | let lock_commands = send.clone(); |
| 138 | std::thread::spawn(move || { |
| 139 | for signal in signals { |
| 140 | if signal |
| 141 | .body() |
| 142 | .deserialize::<(bool,)>() |
| 143 | .is_ok_and(|(active,)| active) |
| 144 | { |
| 145 | let _ = lock_commands.send_blocking("{\"command\":\"close\"}".into()); |
| 146 | break; |
| 147 | } |
| 148 | } |
| 149 | }); |
| 150 | std::thread::spawn(move || { |
| 151 | let mut input = std::io::stdin().lock(); |
| 152 | loop { |
| 153 | let mut line = String::new(); |
| 154 | match input.by_ref().take(8193).read_line(&mut line) { |
| 155 | Ok(0) | Err(_) => break, |
| 156 | Ok(_) if line.len() > 8192 => break, |
| 157 | _ => { |
| 158 | if send.send_blocking(line).is_err() { |
| 159 | return; |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | let _ = send.send_blocking("{\"command\":\"close\"}".into()); |
| 165 | }); |
| 166 | |
| 167 | let path = directory.join("feedback.qml"); |
| 168 | let name = format!("t3-capture-feedback-{}", std::process::id()); |
| 169 | let bus = serde_json::to_string( |
| 170 | connection |
| 171 | .unique_name() |
| 172 | .ok_or("Missing bus identity")? |
| 173 | .as_str(), |
| 174 | )?; |
| 175 | let source = include_str!("feedback.qml") |
| 176 | .replace("/*OPTIONS*/ null", &serde_json::to_string(&options)?) |
| 177 | .replace("/*BUS*/ \"\"", &bus) |
| 178 | .replace("/*NAME*/ \"\"", &serde_json::to_string(&name)?) |
| 179 | .replace("/*FACTOR*/ 1", &factor.clamp(0.0, 3.0).to_string()); |
| 180 | std::fs::write(&path, source)?; |
| 181 | std::fs::write( |
| 182 | directory.join("feedbackGeometry.js"), |
| 183 | include_str!("feedbackGeometry.js"), |
| 184 | )?; |
| 185 | let scripting = Proxy::new( |
| 186 | connection, |
| 187 | "org.kde.KWin", |
| 188 | "/Scripting", |
| 189 | "org.kde.kwin.Scripting", |
| 190 | )?; |
| 191 | let result = (|| { |
| 192 | let id: i32 = scripting.call( |
| 193 | "loadDeclarativeScript", |
| 194 | &(path.to_str().ok_or("Invalid script path")?, &name), |
| 195 | )?; |
| 196 | if id < 0 { |
| 197 | return Err("KWin could not load capture feedback".into()); |
| 198 | } |
| 199 | let object = format!("/Scripting/Script{id}"); |
| 200 | let loaded = Proxy::new( |
| 201 | connection, |
| 202 | "org.kde.KWin", |
| 203 | object.as_str(), |
| 204 | "org.kde.kwin.Script", |
| 205 | )?; |
| 206 | let _: () = loaded.call("run", &())?; |
| 207 | finished.recv_timeout(Duration::from_secs(12))?; |
| 208 | Ok(()) |
| 209 | })(); |
| 210 | let _: zbus::Result<bool> = scripting.call("unloadScript", &(&name,)); |
| 211 | let _ = connection.object_server().remove::<Bridge, _>(OBJECT); |
| 212 | result |
| 213 | } |
| 214 | |
| 215 | #[cfg(test)] |
| 216 | mod tests { |
| 217 | use super::*; |
| 218 | |
| 219 | #[test] |
| 220 | fn only_kwin_can_read_commands_or_acknowledge_feedback() { |
| 221 | let f = crate::transport_tests::Fixture::new(); |
| 222 | let (send, commands) = async_channel::bounded(4); |
| 223 | let (done, finished) = mpsc::sync_channel(1); |
| 224 | // Build with the bridge registered so zbus waits for its method-call listener. |
| 225 | // Registering it after build can drop the first call while that listener starts. |
| 226 | let bridge = Builder::address(f.address.as_str()) |
| 227 | .unwrap() |
| 228 | .method_timeout(DEADLINE) |
| 229 | .serve_at( |
| 230 | OBJECT, |
| 231 | Bridge { |
| 232 | owner: f._server.unique_name().unwrap().to_string(), |
| 233 | commands, |
| 234 | done, |
| 235 | }, |
| 236 | ) |
| 237 | .unwrap() |
| 238 | .build() |
| 239 | .unwrap(); |
| 240 | let destination = bridge.unique_name().unwrap().to_string(); |
| 241 | let stranger = Builder::address(f.address.as_str()) |
| 242 | .unwrap() |
| 243 | .method_timeout(DEADLINE) |
| 244 | .build() |
| 245 | .unwrap(); |
| 246 | let unrelated = Proxy::new( |
| 247 | &stranger, |
| 248 | destination.as_str(), |
| 249 | OBJECT, |
| 250 | "com.t3tools.KdeCapture.Feedback", |
| 251 | ) |
| 252 | .unwrap(); |
| 253 | let error = unrelated.call::<_, _, String>("Next", &()).unwrap_err(); |
| 254 | assert!( |
| 255 | matches!(&error, zbus::Error::MethodError(name, _, _) if name.as_str() == "org.freedesktop.DBus.Error.AccessDenied"), |
| 256 | "{error:?}" |
| 257 | ); |
| 258 | assert!( |
| 259 | unrelated |
| 260 | .call::<_, _, ()>("Event", &("{\"event\":\"done\"}",)) |
| 261 | .is_err() |
| 262 | ); |
| 263 | assert!(finished.try_recv().is_err()); |
| 264 | |
| 265 | let kwin = Proxy::new( |
| 266 | &f._server, |
| 267 | destination.clone(), |
| 268 | OBJECT, |
| 269 | "com.t3tools.KdeCapture.Feedback", |
| 270 | ) |
| 271 | .unwrap(); |
| 272 | // Event remains responsive while Next waits. No blocking the D-Bus executor. |
| 273 | let next = kwin.clone(); |
| 274 | let request = std::thread::spawn(move || next.call::<_, _, String>("Next", &()).unwrap()); |
| 275 | kwin.call::<_, _, ()>("Event", &("{\"event\":\"ready\",\"animate\":true}",)) |
| 276 | .unwrap(); |
| 277 | send.send_blocking("{\"command\":\"close\"}".into()) |
| 278 | .unwrap(); |
| 279 | assert_eq!(request.join().unwrap(), "{\"command\":\"close\"}"); |
| 280 | kwin.call::<_, _, ()>("Event", &("{\"event\":\"done\"}",)) |
| 281 | .unwrap(); |
| 282 | finished.recv_timeout(DEADLINE).unwrap(); |
| 283 | bridge.object_server().remove::<Bridge, _>(OBJECT).unwrap(); |
| 284 | } |
| 285 | |
| 286 | #[test] |
| 287 | fn capability_probe_rejects_missing_declarative_api_without_running_a_script() { |
| 288 | let f = crate::transport_tests::Fixture::new(); |
| 289 | assert!(!supported(&f.client)); |
| 290 | } |
| 291 | } |