waydriver_compositor_mutter/
lib.rs1use 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
25pub struct MutterState {
32 pub conn: zbus::Connection,
34 pub rd_session_path: String,
36 pub runtime_dir: PathBuf,
38}
39
40pub 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 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 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 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 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 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 wait_for_wayland_socket(&runtime_str, &self.wayland_display).await?;
167 tracing::debug!(id = self.id, "wayland socket ready");
168
169 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 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 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 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 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 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
325fn 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}