Skip to main content

waydriver_compositor_mutter/
lib.rs

1//! Mutter implementation of [`waydriver::CompositorRuntime`].
2//!
3//! Owns the private-bus `dbus-daemon`, the `pipewire` + `wireplumber` pair,
4//! and a headless `mutter --wayland` instance. After [`MutterCompositor::start`]
5//! returns, [`MutterCompositor::state`] exposes an `Arc<MutterState>` that
6//! sibling backends (`waydriver-input-mutter`, `waydriver-capture-mutter`) use
7//! to talk to the same mutter D-Bus session.
8//!
9//! ## Shared-state invariant
10//!
11//! While any `Arc<MutterState>` exists, the mutter child processes and the
12//! private D-Bus connection MUST remain alive. [`waydriver::Session::kill`]
13//! enforces this by dropping input and capture trait objects before calling
14//! `compositor.stop().await`.
15
16use std::path::{Path, PathBuf};
17use std::process::Stdio;
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use tokio::process::{Child, Command};
22
23use waydriver::{CompositorRuntime, Error, Result};
24
25/// Default virtual-monitor geometry passed to mutter when the caller doesn't
26/// override it. Matches mutter's own implicit default.
27const DEFAULT_RESOLUTION: &str = "1024x768";
28
29/// Shared mutter-backend state consumed by `waydriver-input-mutter` and
30/// `waydriver-capture-mutter`.
31///
32/// **Invariant:** while any `Arc<MutterState>` exists, the underlying D-Bus
33/// connection and the mutter child process must remain alive. See the
34/// module docs for details.
35pub struct MutterState {
36    /// Persistent connection to mutter's private D-Bus.
37    pub conn: zbus::Connection,
38    /// RemoteDesktop session path, used by input injection.
39    pub rd_session_path: String,
40    /// Per-session XDG_RUNTIME_DIR, used by capture to locate the PipeWire socket.
41    pub runtime_dir: PathBuf,
42}
43
44/// Headless mutter instance.
45pub struct MutterCompositor {
46    id: String,
47    wayland_display: String,
48    runtime_dir: PathBuf,
49    mutter_dbus_address: String,
50    mutter_dbus_pid: Option<u32>,
51    mutter: Option<Child>,
52    pipewire: Option<Child>,
53    wireplumber: Option<Child>,
54    state: Option<Arc<MutterState>>,
55}
56
57impl MutterCompositor {
58    /// Construct but do not start. Generates the session id and computes
59    /// where the Wayland socket and runtime dir will live. No I/O.
60    pub fn new() -> Self {
61        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
62        let wayland_display = format!("wayland-wd-{}", id);
63
64        let host_runtime = std::env::var("XDG_RUNTIME_DIR")
65            .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
66        let runtime_dir = PathBuf::from(&host_runtime).join(format!("wd-session-{}", id));
67
68        Self {
69            id,
70            wayland_display,
71            runtime_dir,
72            mutter_dbus_address: String::new(),
73            mutter_dbus_pid: None,
74            mutter: None,
75            pipewire: None,
76            wireplumber: None,
77            state: None,
78        }
79    }
80
81    /// Returns the shared `Arc<MutterState>` for passing to sibling backends.
82    ///
83    /// # Panics
84    /// Panics if called before [`CompositorRuntime::start`] has completed, or
85    /// after [`CompositorRuntime::stop`]. Callers are expected to follow the
86    /// fixed sequence: `new()` → `start().await?` → `state()`.
87    pub fn state(&self) -> Arc<MutterState> {
88        self.state
89            .as_ref()
90            .expect("MutterCompositor::state() called before start() or after stop()")
91            .clone()
92    }
93}
94
95impl Default for MutterCompositor {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101#[async_trait]
102impl CompositorRuntime for MutterCompositor {
103    async fn start(&mut self, resolution: Option<&str>) -> Result<()> {
104        let resolution = resolution.unwrap_or(DEFAULT_RESOLUTION);
105        // Validate before we start spawning subprocesses — mutter silently
106        // ignores bad --virtual-monitor values and falls back to its own
107        // default, which would surprise the caller.
108        parse_resolution(resolution)?;
109
110        tracing::info!(id = self.id, resolution, "starting mutter compositor");
111
112        tokio::fs::create_dir_all(&self.runtime_dir).await?;
113        let runtime_str = self.runtime_dir.to_str().unwrap().to_string();
114
115        // Step 1: Private D-Bus for mutter (so its ScreenCast API doesn't conflict with host).
116        let dbus_output = Command::new("dbus-launch")
117            .arg("--sh-syntax")
118            .output()
119            .await?;
120        if !dbus_output.status.success() {
121            return Err(Error::Process(format!(
122                "dbus-launch failed: {}",
123                String::from_utf8_lossy(&dbus_output.stderr)
124            )));
125        }
126        let dbus_stdout = String::from_utf8_lossy(&dbus_output.stdout);
127        self.mutter_dbus_address = parse_dbus_address(&dbus_stdout)?;
128        self.mutter_dbus_pid = Some(parse_dbus_pid(&dbus_stdout)?);
129        tracing::debug!(id = self.id, mutter_dbus_address = %self.mutter_dbus_address, "private D-Bus for mutter");
130
131        // Step 2: PipeWire + WirePlumber (for screenshots via ScreenCast).
132        let pipewire = Command::new("pipewire")
133            .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
134            .env("XDG_RUNTIME_DIR", &runtime_str)
135            .stdout(Stdio::null())
136            .stderr(Stdio::null())
137            .spawn()
138            .map_err(|e| Error::Process(format!("pipewire: {e}")))?;
139        self.pipewire = Some(pipewire);
140
141        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
142
143        let wireplumber = Command::new("wireplumber")
144            .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
145            .env("XDG_RUNTIME_DIR", &runtime_str)
146            .stdout(Stdio::null())
147            .stderr(Stdio::null())
148            .spawn()
149            .map_err(|e| Error::Process(format!("wireplumber: {e}")))?;
150        self.wireplumber = Some(wireplumber);
151
152        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
153        tracing::debug!(id = self.id, "PipeWire + WirePlumber started");
154
155        // Step 3: mutter in headless Wayland mode (on its private D-Bus).
156        let mutter = Command::new("mutter")
157            .args([
158                "--headless",
159                "--wayland",
160                "--no-x11",
161                "--wayland-display",
162                &self.wayland_display,
163                "--virtual-monitor",
164                resolution,
165            ])
166            .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
167            .env("XDG_RUNTIME_DIR", &runtime_str)
168            .stdout(Stdio::null())
169            .stderr(Stdio::inherit())
170            .spawn()
171            .map_err(|e| Error::Process(format!("mutter: {e}")))?;
172        self.mutter = Some(mutter);
173        tracing::debug!(id = self.id, wayland_display = %self.wayland_display, "mutter spawned");
174
175        // Step 4: Wait for the Wayland socket.
176        wait_for_wayland_socket(&runtime_str, &self.wayland_display).await?;
177        tracing::debug!(id = self.id, "wayland socket ready");
178
179        // Step 5: Connect to mutter's private D-Bus and start RemoteDesktop session.
180        let mutter_addr: zbus::address::Address = self
181            .mutter_dbus_address
182            .as_str()
183            .try_into()
184            .map_err(|e: zbus::Error| {
185                Error::Process(format!("invalid mutter dbus address: {e}"))
186            })?;
187        let mutter_conn = zbus::connection::Builder::address(mutter_addr)?
188            .build()
189            .await
190            .map_err(|e| Error::Process(format!("connect to mutter dbus: {e}")))?;
191
192        // Wait for mutter to register its D-Bus services (may take a moment after socket appears)
193        let mut rd_reply = None;
194        for i in 0..50 {
195            match mutter_conn
196                .call_method(
197                    Some("org.gnome.Mutter.RemoteDesktop"),
198                    "/org/gnome/Mutter/RemoteDesktop",
199                    Some("org.gnome.Mutter.RemoteDesktop"),
200                    "CreateSession",
201                    &(),
202                )
203                .await
204            {
205                Ok(reply) => {
206                    rd_reply = Some(reply);
207                    break;
208                }
209                Err(e) if i < 49 => {
210                    tracing::debug!(
211                        id = self.id,
212                        attempt = i,
213                        "waiting for RemoteDesktop service: {e}"
214                    );
215                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
216                }
217                Err(e) => {
218                    return Err(Error::Process(format!("RemoteDesktop CreateSession: {e}")));
219                }
220            }
221        }
222        let rd_reply = rd_reply.unwrap();
223        let rd_session_path: zbus::zvariant::OwnedObjectPath = rd_reply
224            .body()
225            .deserialize()
226            .map_err(|e| Error::Process(format!("parse RD session path: {e}")))?;
227        // Start the RemoteDesktop session.
228        mutter_conn
229            .call_method(
230                Some("org.gnome.Mutter.RemoteDesktop"),
231                rd_session_path.as_str(),
232                Some("org.gnome.Mutter.RemoteDesktop.Session"),
233                "Start",
234                &(),
235            )
236            .await
237            .map_err(|e| Error::Process(format!("RemoteDesktop Start: {e}")))?;
238        let rd_session_path = rd_session_path.to_string();
239        tracing::debug!(id = self.id, rd_session_path = %rd_session_path, "RemoteDesktop session started");
240
241        self.state = Some(Arc::new(MutterState {
242            conn: mutter_conn,
243            rd_session_path,
244            runtime_dir: self.runtime_dir.clone(),
245        }));
246
247        Ok(())
248    }
249
250    async fn stop(&mut self) -> Result<()> {
251        tracing::info!(id = self.id, "stopping mutter compositor");
252
253        // Stop RemoteDesktop session if still reachable.
254        if let Some(state) = &self.state {
255            let _ = state
256                .conn
257                .call_method(
258                    Some("org.gnome.Mutter.RemoteDesktop"),
259                    state.rd_session_path.as_str(),
260                    Some("org.gnome.Mutter.RemoteDesktop.Session"),
261                    "Stop",
262                    &(),
263                )
264                .await;
265        }
266
267        // Drop our strong ref to the shared state. If callers haven't dropped
268        // theirs (the input/capture trait objects), their Arc still points at
269        // the D-Bus connection we're about to tear down below — any method
270        // call on them after this will fail with "connection closed".
271        self.state = None;
272
273        if let Some(mut mutter) = self.mutter.take() {
274            let _ = mutter.kill().await;
275            let _ = mutter.wait().await;
276        }
277        if let Some(mut wireplumber) = self.wireplumber.take() {
278            let _ = wireplumber.kill().await;
279            let _ = wireplumber.wait().await;
280        }
281        if let Some(mut pipewire) = self.pipewire.take() {
282            let _ = pipewire.kill().await;
283            let _ = pipewire.wait().await;
284        }
285
286        if let Some(pid) = self.mutter_dbus_pid.take() {
287            unsafe {
288                libc::kill(pid as i32, libc::SIGTERM);
289            }
290        }
291
292        let _ = tokio::fs::remove_dir_all(&self.runtime_dir).await;
293
294        tracing::debug!(id = self.id, "mutter compositor stopped");
295        Ok(())
296    }
297
298    fn id(&self) -> &str {
299        &self.id
300    }
301
302    fn wayland_display(&self) -> &str {
303        &self.wayland_display
304    }
305
306    fn runtime_dir(&self) -> &Path {
307        &self.runtime_dir
308    }
309}
310
311impl Drop for MutterCompositor {
312    fn drop(&mut self) {
313        // Best-effort cleanup when dropped without calling stop().
314        // Can't use async here, so send SIGKILL synchronously.
315        self.state = None;
316
317        if let Some(ref mut child) = self.mutter {
318            let _ = child.start_kill();
319        }
320        if let Some(ref mut child) = self.wireplumber {
321            let _ = child.start_kill();
322        }
323        if let Some(ref mut child) = self.pipewire {
324            let _ = child.start_kill();
325        }
326        if let Some(pid) = self.mutter_dbus_pid {
327            unsafe {
328                libc::kill(pid as i32, libc::SIGKILL);
329            }
330        }
331        let _ = std::fs::remove_dir_all(&self.runtime_dir);
332    }
333}
334
335// ── Helpers ─────────────────────────────────────────────────────────────────
336
337fn parse_dbus_address(output: &str) -> Result<String> {
338    for line in output.lines() {
339        if let Some(rest) = line.strip_prefix("DBUS_SESSION_BUS_ADDRESS='") {
340            if let Some(addr) = rest.strip_suffix("';") {
341                return Ok(addr.to_string());
342            }
343        }
344    }
345    Err(Error::Process(
346        "could not parse DBUS_SESSION_BUS_ADDRESS from dbus-launch".to_string(),
347    ))
348}
349
350fn parse_dbus_pid(output: &str) -> Result<u32> {
351    for line in output.lines() {
352        if let Some(rest) = line.strip_prefix("DBUS_SESSION_BUS_PID=") {
353            let pid_str = rest.trim_end_matches(';').trim();
354            return pid_str
355                .parse()
356                .map_err(|e| Error::Process(format!("invalid dbus PID: {e}")));
357        }
358    }
359    Err(Error::Process(
360        "could not parse DBUS_SESSION_BUS_PID from dbus-launch".to_string(),
361    ))
362}
363
364fn parse_resolution(s: &str) -> Result<(u32, u32)> {
365    let (w, h) = s.split_once('x').ok_or_else(|| {
366        Error::Process(format!("invalid resolution '{s}': expected WIDTHxHEIGHT"))
367    })?;
368    let parse = |part: &str| -> Result<u32> {
369        part.parse::<u32>().ok().filter(|n| *n > 0).ok_or_else(|| {
370            Error::Process(format!("invalid resolution '{s}': expected WIDTHxHEIGHT"))
371        })
372    };
373    Ok((parse(w)?, parse(h)?))
374}
375
376async fn wait_for_wayland_socket(runtime_dir: &str, display: &str) -> Result<()> {
377    let socket_path = PathBuf::from(runtime_dir).join(display);
378    for _ in 0..50 {
379        if socket_path.exists() {
380            return Ok(());
381        }
382        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
383    }
384    Err(Error::Timeout(format!(
385        "wayland socket {} did not appear within 5s",
386        socket_path.display()
387    )))
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_parse_dbus_address_valid() {
396        let output = "DBUS_SESSION_BUS_ADDRESS='unix:abstract=/tmp/dbus-XXX,guid=abc123';\nDBUS_SESSION_BUS_PID=12345;\n";
397        let addr = parse_dbus_address(output).unwrap();
398        assert_eq!(addr, "unix:abstract=/tmp/dbus-XXX,guid=abc123");
399    }
400
401    #[test]
402    fn test_parse_dbus_address_missing() {
403        let output = "DBUS_SESSION_BUS_PID=12345;\n";
404        assert!(parse_dbus_address(output).is_err());
405    }
406
407    #[test]
408    fn test_parse_dbus_pid_valid() {
409        let output = "DBUS_SESSION_BUS_ADDRESS='unix:abstract=/tmp/dbus-XXX,guid=abc123';\nDBUS_SESSION_BUS_PID=12345;\n";
410        let pid = parse_dbus_pid(output).unwrap();
411        assert_eq!(pid, 12345);
412    }
413
414    #[test]
415    fn test_parse_dbus_pid_missing() {
416        let output = "DBUS_SESSION_BUS_ADDRESS='unix:abstract=/tmp/dbus-XXX,guid=abc123';\n";
417        assert!(parse_dbus_pid(output).is_err());
418    }
419
420    #[test]
421    fn test_parse_dbus_pid_invalid() {
422        let output = "DBUS_SESSION_BUS_PID=notanumber;\n";
423        assert!(parse_dbus_pid(output).is_err());
424    }
425
426    #[tokio::test]
427    async fn test_wait_for_socket_found() {
428        let dir = tempfile::tempdir().unwrap();
429        let runtime_dir = dir.path().to_str().unwrap().to_string();
430        let display = "wayland-test-99";
431        std::fs::File::create(dir.path().join(display)).unwrap();
432        wait_for_wayland_socket(&runtime_dir, display)
433            .await
434            .unwrap();
435    }
436
437    #[tokio::test]
438    async fn test_wait_for_socket_timeout() {
439        let dir = tempfile::tempdir().unwrap();
440        let runtime_dir = dir.path().to_str().unwrap().to_string();
441        let display = "wayland-nonexistent-0";
442        let err = wait_for_wayland_socket(&runtime_dir, display)
443            .await
444            .unwrap_err();
445        assert!(
446            matches!(err, Error::Timeout(_)),
447            "expected Timeout, got: {err}"
448        );
449    }
450
451    #[test]
452    fn test_new_generates_unique_ids() {
453        let a = MutterCompositor::new();
454        let b = MutterCompositor::new();
455        assert_ne!(a.id(), b.id());
456    }
457
458    #[test]
459    fn test_new_wayland_display_contains_id() {
460        let c = MutterCompositor::new();
461        assert!(
462            c.wayland_display().contains(c.id()),
463            "display '{}' should contain id '{}'",
464            c.wayland_display(),
465            c.id()
466        );
467    }
468
469    #[test]
470    fn test_new_runtime_dir_contains_id() {
471        let c = MutterCompositor::new();
472        let dir_str = c.runtime_dir().to_str().unwrap();
473        assert!(
474            dir_str.contains(c.id()),
475            "runtime_dir '{}' should contain id '{}'",
476            dir_str,
477            c.id()
478        );
479    }
480
481    #[test]
482    fn test_new_wayland_display_prefix() {
483        let c = MutterCompositor::new();
484        assert!(c.wayland_display().starts_with("wayland-wd-"));
485    }
486
487    #[test]
488    fn test_new_runtime_dir_contains_session_prefix() {
489        let c = MutterCompositor::new();
490        let dir_str = c.runtime_dir().to_str().unwrap();
491        assert!(dir_str.contains("wd-session-"));
492    }
493
494    #[test]
495    #[should_panic(expected = "before start")]
496    fn test_state_panics_before_start() {
497        let c = MutterCompositor::new();
498        let _ = c.state();
499    }
500
501    #[test]
502    fn test_parse_resolution_accepts_hd() {
503        assert_eq!(parse_resolution("1920x1080").unwrap(), (1920, 1080));
504        assert_eq!(parse_resolution("1024x768").unwrap(), (1024, 768));
505    }
506
507    #[test]
508    fn test_parse_resolution_rejects_garbage() {
509        for bad in [
510            "",
511            "1920",
512            "1920x",
513            "x1080",
514            "0x0",
515            "1920x0",
516            "0x1080",
517            "1920x1080x1",
518            "abcxdef",
519            "-1x1080",
520            "1920 x 1080",
521        ] {
522            assert!(parse_resolution(bad).is_err(), "expected error for {bad:?}");
523        }
524    }
525
526    #[test]
527    fn test_default_same_structure_as_new() {
528        let c = MutterCompositor::default();
529        assert!(c.wayland_display().starts_with("wayland-wd-"));
530        assert!(c.runtime_dir().to_str().unwrap().contains("wd-session-"));
531    }
532}