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
25const DEFAULT_RESOLUTION: &str = "1024x768";
28
29pub struct MutterState {
36 pub conn: zbus::Connection,
38 pub rd_session_path: String,
40 pub runtime_dir: PathBuf,
42}
43
44pub 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 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 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 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 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 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 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 wait_for_wayland_socket(&runtime_str, &self.wayland_display).await?;
177 tracing::debug!(id = self.id, "wayland socket ready");
178
179 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 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 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 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 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 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
335fn 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}