waydriver_capture_mutter/lib.rs
1//! Mutter implementation of [`waydriver::CaptureBackend`].
2//!
3//! Creates a ScreenCast session on mutter's private D-Bus, records the
4//! virtual monitor, waits for the `PipeWireStreamAdded` signal to learn the
5//! PipeWire node id, and hands that off to the shared `waydriver::capture::grab_png`
6//! helper via the trait's default `take_screenshot` impl.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use futures_util::StreamExt;
14use zbus::zvariant::{OwnedObjectPath, Value};
15
16use waydriver::{CaptureBackend, Error, PipeWireStream, Result, StreamToken};
17use waydriver_compositor_mutter::MutterState;
18
19/// Mutter ScreenCast + PipeWire capture backend.
20pub struct MutterCapture {
21 state: Arc<MutterState>,
22}
23
24impl MutterCapture {
25 /// Create a new capture backend from shared compositor state.
26 pub fn new(state: Arc<MutterState>) -> Self {
27 Self { state }
28 }
29
30 /// Open a ScreenCast monitor-record stream and resolve its PipeWire node id.
31 ///
32 /// `link_rd` selects between the two stream flavors waydriver needs:
33 ///
34 /// - `true` — the interactive/keepalive stream. The ScreenCast session is
35 /// linked to the RemoteDesktop session so mutter accepts
36 /// `NotifyPointerMotionAbsolute`, the session is driven via
37 /// `RemoteDesktop.Session.Start` (mutter rejects
38 /// `ScreenCast.Session.Start` on a linked session), and the resulting
39 /// stream path is published as the active stream for pointer routing.
40 /// - `false` — a standalone stream for the video recorder. It is *not*
41 /// linked to RemoteDesktop (the recorder needs only pixels), is started
42 /// via `ScreenCast.Session.Start` directly, and does not touch the
43 /// active-stream path. Keeping the recorder on its own node is what
44 /// prevents it from starving the screenshot consumer on the shared
45 /// keepalive node — see [`CaptureBackend::start_recording_stream`].
46 async fn create_stream(&self, link_rd: bool) -> Result<PipeWireStream> {
47 let conn = self.state.conn();
48
49 // Step 1: Create the ScreenCast session. For the interactive stream we
50 // link it to the existing RemoteDesktop session so absolute pointer
51 // motion works (mutter routes NotifyPointerMotionAbsolute through the
52 // linked stream). The recorder's standalone stream skips the link.
53 let empty_opts: HashMap<&str, Value> = HashMap::new();
54 let mut create_opts: HashMap<&str, Value> = HashMap::new();
55 if link_rd {
56 create_opts.insert(
57 "remote-desktop-session-id",
58 Value::from(self.state.rd_session_id()),
59 );
60 }
61 let reply = conn
62 .call_method(
63 Some("org.gnome.Mutter.ScreenCast"),
64 "/org/gnome/Mutter/ScreenCast",
65 Some("org.gnome.Mutter.ScreenCast"),
66 "CreateSession",
67 &(create_opts,),
68 )
69 .await
70 .map_err(|e| Error::screenshot_with("CreateSession", e))?;
71 let session_path: OwnedObjectPath = reply
72 .body()
73 .deserialize()
74 .map_err(|e| Error::screenshot_with("parse session path", e))?;
75
76 // Step 2: RecordMonitor on the session.
77 let reply = conn
78 .call_method(
79 Some("org.gnome.Mutter.ScreenCast"),
80 session_path.as_str(),
81 Some("org.gnome.Mutter.ScreenCast.Session"),
82 "RecordMonitor",
83 &("", empty_opts),
84 )
85 .await
86 .map_err(|e| Error::screenshot_with("RecordMonitor", e))?;
87 let stream_path: OwnedObjectPath = reply
88 .body()
89 .deserialize()
90 .map_err(|e| Error::screenshot_with("parse stream path", e))?;
91
92 // Step 3: Subscribe to PipeWireStreamAdded BEFORE starting.
93 // This ordering is load-bearing — mutter emits the signal synchronously
94 // during `Session.Start`, so a late subscribe misses it.
95 let stream_proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(conn)
96 .destination("org.gnome.Mutter.ScreenCast")
97 .map_err(|e| Error::screenshot_with("proxy destination", e))?
98 .path(stream_path.as_str())
99 .map_err(|e| Error::screenshot_with("proxy path", e))?
100 .interface("org.gnome.Mutter.ScreenCast.Stream")
101 .map_err(|e| Error::screenshot_with("proxy interface", e))?
102 .build()
103 .await
104 .map_err(|e| Error::screenshot_with("build stream proxy", e))?;
105
106 let mut signal_stream = stream_proxy
107 .receive_signal("PipeWireStreamAdded")
108 .await
109 .map_err(|e| Error::screenshot_with("receive_signal", e))?;
110
111 // Step 4: Start the ScreenCast session. A linked (RD) session must be
112 // driven via `RemoteDesktop.Session.Start` — calling
113 // `ScreenCast.Session.Start` on it yields "Must be started from remote
114 // desktop session". Starting RD also unlocks
115 // `NotifyPointerMotionAbsolute` on the input backend. Only the first
116 // linked stream triggers RD.Start; subsequent linked streams share the
117 // same RD session and skip. A standalone (non-linked) stream — the
118 // recorder's — is started directly via `ScreenCast.Session.Start` and
119 // never touches the RD-started flag.
120 let should_start_rd = link_rd && {
121 let mut guard = self.state.rd_started_lock()?;
122 if *guard {
123 false
124 } else {
125 *guard = true;
126 true
127 }
128 };
129 if should_start_rd {
130 conn.call_method(
131 Some("org.gnome.Mutter.RemoteDesktop"),
132 self.state.rd_session_path(),
133 Some("org.gnome.Mutter.RemoteDesktop.Session"),
134 "Start",
135 &(),
136 )
137 .await
138 .map_err(|e| Error::screenshot_with("RemoteDesktop Start", e))?;
139 } else {
140 conn.call_method(
141 Some("org.gnome.Mutter.ScreenCast"),
142 session_path.as_str(),
143 Some("org.gnome.Mutter.ScreenCast.Session"),
144 "Start",
145 &(),
146 )
147 .await
148 .map_err(|e| Error::screenshot_with("Start", e))?;
149 }
150
151 // Step 5: Wait for PipeWireStreamAdded signal to get the node id.
152 let node_id: u32 = tokio::time::timeout(std::time::Duration::from_secs(5), async {
153 let signal = signal_stream
154 .next()
155 .await
156 .ok_or_else(|| Error::screenshot("signal stream ended"))?;
157 signal
158 .body()
159 .deserialize::<u32>()
160 .map_err(|e| Error::screenshot_with("parse node_id", e))
161 })
162 .await
163 .map_err(|_| Error::screenshot("timeout waiting for PipeWireStreamAdded"))??;
164
165 tracing::debug!(node_id, link_rd, "got PipeWire node id");
166
167 // Publish the stream object path so MutterInput can route
168 // NotifyPointerMotionAbsolute at the correct monitor. Only the
169 // interactive (RD-linked) stream owns pointer routing; the recorder's
170 // standalone stream must not overwrite it.
171 if link_rd {
172 *self.state.active_stream_path_lock()? = Some(stream_path.to_string());
173 }
174
175 Ok(PipeWireStream {
176 node_id,
177 // The session_path is an `OwnedObjectPath`; `stop_stream`
178 // below downcasts back to it. `StreamToken::downcast`
179 // returns a typed error if a future change ever feeds a
180 // different value here, instead of a `()` from
181 // `Box::downcast`.
182 token: StreamToken::new(session_path),
183 })
184 }
185
186 /// Stop a ScreenCast session by object path. Shared by `stop_stream` and
187 /// `stop_recording_stream`; best-effort (a failed `Session.Stop` on a
188 /// teardown path is logged by the caller, not surfaced).
189 async fn stop_session(&self, stream: PipeWireStream) -> Result<()> {
190 let session_path = stream.token.downcast::<OwnedObjectPath>()?;
191 let _ = self
192 .state
193 .conn()
194 .call_method(
195 Some("org.gnome.Mutter.ScreenCast"),
196 session_path.as_str(),
197 Some("org.gnome.Mutter.ScreenCast.Session"),
198 "Stop",
199 &(),
200 )
201 .await;
202 Ok(())
203 }
204}
205
206#[async_trait]
207impl CaptureBackend for MutterCapture {
208 async fn start_stream(&self) -> Result<PipeWireStream> {
209 self.create_stream(true).await
210 }
211
212 async fn stop_stream(&self, stream: PipeWireStream) -> Result<()> {
213 self.stop_session(stream).await?;
214 // The interactive stream owns pointer routing; clear it on teardown.
215 *self.state.active_stream_path_lock()? = None;
216 Ok(())
217 }
218
219 async fn start_recording_stream(&self) -> Result<PipeWireStream> {
220 self.create_stream(false).await
221 }
222
223 async fn stop_recording_stream(&self, stream: PipeWireStream) -> Result<()> {
224 // Standalone stream: never published itself as the active stream, so
225 // it must not clear the interactive stream's pointer-routing path.
226 self.stop_session(stream).await
227 }
228
229 fn pipewire_socket(&self) -> PathBuf {
230 self.state.runtime_dir().join("pipewire-0")
231 }
232}