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.
3use super::*;
4use std::io::BufRead;
5
6const OBJECT: &str = "/com/t3tools/KdeCapture/Feedback";
7
8#[derive(Deserialize, Serialize)]
9#[serde(rename_all = "camelCase")]
10struct 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")]
19enum Event {
20 Ready { animate: bool },
21 Landed,
22 Done,
23}
24
25struct Bridge {
26 owner: String,
27 commands: async_channel::Receiver<String>,
28 done: mpsc::SyncSender<()>,
29}
30impl 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")]
40impl 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
72pub(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
86pub(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)]
216mod 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}