//! 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<String>, 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<String> { 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<bool> = scripting.call("unloadScript", &(&name,)); let _ = connection.object_server().remove::<Bridge, _>(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::<Bridge, _>(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)); }}
//! 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<String>,
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<String> {
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<bool> = scripting.call("unloadScript", &(&name,));
let _ = connection.object_server().remove::<Bridge, _>(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::<Bridge, _>(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));
}
}