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