//! A short-lived QML overlay hosted by KWin, not a persistent desktop effect. //! Commands arrive on our parent's private stdin; only KWin can consume them. use super::*; use std::io::BufRead; const OBJECT: &str = "/com/t3tools/KdeCapture/Feedback"; #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct Options { bounds: Bounds, pid: u32, flash: bool, animate: bool, } #[derive(Deserialize, Serialize)] #[serde(tag = "event", rename_all = "camelCase")] enum Event { Ready { animate: bool }, Landed, Done, } struct Bridge { owner: String, commands: async_channel::Receiver, done: mpsc::SyncSender<()>, } impl Bridge { fn authorize(&self, header: &zbus::message::Header<'_>) -> zbus::fdo::Result<()> { if header.sender().map(|name| name.as_str()) == Some(self.owner.as_str()) { Ok(()) } else { Err(zbus::fdo::Error::AccessDenied("Not KWin".into())) } } } #[zbus::interface(name = "com.t3tools.KdeCapture.Feedback")] impl Bridge { // An async pending call is an event channel, not a timer or a blocking KWin call. async fn next( &self, #[zbus(header)] header: zbus::message::Header<'_>, ) -> zbus::fdo::Result { self.authorize(&header)?; Ok(self .commands .recv() .await .unwrap_or_else(|_| "{\"command\":\"close\"}".into())) } fn event( &self, value: &str, #[zbus(header)] header: zbus::message::Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize(&header)?; let event: Event = serde_json::from_str(value) .map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid feedback event".into()))?; let mut stdout = std::io::stdout().lock(); let _ = writeln!(stdout, "{}", serde_json::to_string(&event).unwrap()); let _ = stdout.flush(); if matches!(event, Event::Done) { let _ = self.done.try_send(()); } Ok(()) } } pub(super) fn supported(connection: &Connection) -> bool { let Ok(proxy) = Proxy::new( connection, "org.kde.KWin", "/Scripting", "org.freedesktop.DBus.Introspectable", ) else { return false; }; proxy .call::<_, _, String>("Introspect", &()) .is_ok_and(|xml| xml.contains("name=\"loadDeclarativeScript\"")) } pub(super) fn run(connection: &Connection, directory: &Path, options: &str) -> Result<()> { let mut options: Options = serde_json::from_str(options)?; if options.bounds.width == 0 || options.bounds.height == 0 || options.pid == 0 { return Err("Invalid capture feedback geometry".into()); } // Use KDE's config reader to respect config layering and the desktop's motion setting. let factor = std::process::Command::new("kreadconfig6") .args([ "--file", "kdeglobals", "--group", "KDE", "--key", "AnimationDurationFactor", "--default", "1", ]) .output()?; if !factor.status.success() { return Err("Couldn't read KDE animation preferences".into()); } let factor: f64 = String::from_utf8(factor.stdout)?.trim().parse()?; if !factor.is_finite() || factor < 0.0 { return Err("Invalid KDE animation preference".into()); } options.animate &= factor > 0.0; let owner = zbus::blocking::fdo::DBusProxy::new(connection)? .get_name_owner("org.kde.KWin".try_into()?)? .to_string(); let (send, commands) = async_channel::bounded(4); let (done, finished) = mpsc::sync_channel(1); connection.object_server().at( OBJECT, Bridge { owner, commands, done, }, )?; // Subscribe before checking, so a lock racing setup can't expose the snapshot. let locker = Proxy::new( connection, "org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", )?; let signals = locker.receive_signal("ActiveChanged")?; if locker.call::<_, _, bool>("GetActive", &())? { return Err("Screen is locked".into()); } let lock_commands = send.clone(); std::thread::spawn(move || { for signal in signals { if signal .body() .deserialize::<(bool,)>() .is_ok_and(|(active,)| active) { let _ = lock_commands.send_blocking("{\"command\":\"close\"}".into()); break; } } }); std::thread::spawn(move || { let mut input = std::io::stdin().lock(); loop { let mut line = String::new(); match input.by_ref().take(8193).read_line(&mut line) { Ok(0) | Err(_) => break, Ok(_) if line.len() > 8192 => break, _ => { if send.send_blocking(line).is_err() { return; } } } } let _ = send.send_blocking("{\"command\":\"close\"}".into()); }); let path = directory.join("feedback.qml"); let name = format!("t3-capture-feedback-{}", std::process::id()); let bus = serde_json::to_string( connection .unique_name() .ok_or("Missing bus identity")? .as_str(), )?; let source = include_str!("feedback.qml") .replace("/*OPTIONS*/ null", &serde_json::to_string(&options)?) .replace("/*BUS*/ \"\"", &bus) .replace("/*NAME*/ \"\"", &serde_json::to_string(&name)?) .replace("/*FACTOR*/ 1", &factor.clamp(0.0, 3.0).to_string()); std::fs::write(&path, source)?; std::fs::write( directory.join("feedbackGeometry.js"), include_str!("feedbackGeometry.js"), )?; let scripting = Proxy::new( connection, "org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting", )?; let result = (|| { let id: i32 = scripting.call( "loadDeclarativeScript", &(path.to_str().ok_or("Invalid script path")?, &name), )?; if id < 0 { return Err("KWin could not load capture feedback".into()); } let object = format!("/Scripting/Script{id}"); let loaded = Proxy::new( connection, "org.kde.KWin", object.as_str(), "org.kde.kwin.Script", )?; let _: () = loaded.call("run", &())?; finished.recv_timeout(Duration::from_secs(12))?; Ok(()) })(); let _: zbus::Result = scripting.call("unloadScript", &(&name,)); let _ = connection.object_server().remove::(OBJECT); result } #[cfg(test)] mod tests { use super::*; #[test] fn only_kwin_can_read_commands_or_acknowledge_feedback() { let f = crate::transport_tests::Fixture::new(); let (send, commands) = async_channel::bounded(4); let (done, finished) = mpsc::sync_channel(1); // Build with the bridge registered so zbus waits for its method-call listener. // Registering it after build can drop the first call while that listener starts. let bridge = Builder::address(f.address.as_str()) .unwrap() .method_timeout(DEADLINE) .serve_at( OBJECT, Bridge { owner: f._server.unique_name().unwrap().to_string(), commands, done, }, ) .unwrap() .build() .unwrap(); let destination = bridge.unique_name().unwrap().to_string(); let stranger = Builder::address(f.address.as_str()) .unwrap() .method_timeout(DEADLINE) .build() .unwrap(); let unrelated = Proxy::new( &stranger, destination.as_str(), OBJECT, "com.t3tools.KdeCapture.Feedback", ) .unwrap(); let error = unrelated.call::<_, _, String>("Next", &()).unwrap_err(); assert!( matches!(&error, zbus::Error::MethodError(name, _, _) if name.as_str() == "org.freedesktop.DBus.Error.AccessDenied"), "{error:?}" ); assert!( unrelated .call::<_, _, ()>("Event", &("{\"event\":\"done\"}",)) .is_err() ); assert!(finished.try_recv().is_err()); let kwin = Proxy::new( &f._server, destination.clone(), OBJECT, "com.t3tools.KdeCapture.Feedback", ) .unwrap(); // Event remains responsive while Next waits. No blocking the D-Bus executor. let next = kwin.clone(); let request = std::thread::spawn(move || next.call::<_, _, String>("Next", &()).unwrap()); kwin.call::<_, _, ()>("Event", &("{\"event\":\"ready\",\"animate\":true}",)) .unwrap(); send.send_blocking("{\"command\":\"close\"}".into()) .unwrap(); assert_eq!(request.join().unwrap(), "{\"command\":\"close\"}"); kwin.call::<_, _, ()>("Event", &("{\"event\":\"done\"}",)) .unwrap(); finished.recv_timeout(DEADLINE).unwrap(); bridge.object_server().remove::(OBJECT).unwrap(); } #[test] fn capability_probe_rejects_missing_declarative_api_without_running_a_script() { let f = crate::transport_tests::Fixture::new(); assert!(!supported(&f.client)); } }