Skip to main content

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}