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
16mod error;
17
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::process::Stdio;
21use std::sync::{Arc, LazyLock, Mutex};
22
23use async_trait::async_trait;
24use tokio::process::{Child, Command};
25use zbus::zvariant::OwnedValue;
26
27use waydriver::gsettings::{self, GSettingEntry, GSettingsConfig};
28use waydriver::{CompositorRuntime, Result};
29
30use crate::error::MutterError;
31
32/// Default virtual-monitor geometry passed to mutter when the caller doesn't
33/// override it. Matches mutter's own implicit default.
34const DEFAULT_RESOLUTION: &str = "1024x768";
35
36/// Default logical-monitor scale: 1:1, i.e. `resolution` pixels are also the
37/// logical (application) size. Any other value drives the HiDPI path in
38/// [`apply_scale`].
39const DEFAULT_SCALE: f64 = 1.0;
40
41/// GVariant-text value seeded into `org.gnome.mutter experimental-features`
42/// when GSettings isolation is on. `scale-monitor-framebuffer` switches the
43/// native headless backend to logical layout mode, which is what makes
44/// fractional scales (1.5, 1.75, …) appear in a mode's `supported-scales` and
45/// be accepted by `ApplyMonitorsConfig`. Harmless at integer/1.0 scales.
46const MUTTER_FRACTIONAL_SCALING: &str = "['scale-monitor-framebuffer']";
47
48/// Accepted scale range. Below 0.5 the UI is unusably small; above 4.0 mutter
49/// won't offer the scale for any virtual-monitor mode we'd create. Validated
50/// up-front so a typo fails before we spawn any subprocess.
51const MIN_SCALE: f64 = 0.5;
52const MAX_SCALE: f64 = 4.0;
53
54/// How far a requested scale may sit from the nearest mutter-supported scale
55/// before we log a warning about snapping to it. Mutter only accepts scales it
56/// lists in a mode's `supported-scales`, so an exact arbitrary value (e.g.
57/// 1.66) may be nudged to the closest legal step.
58const SCALE_SNAP_TOLERANCE: f64 = 0.01;
59
60// ── DisplayConfig D-Bus shapes ───────────────────────────────────────────────
61//
62// Type aliases mirroring the `org.gnome.Mutter.DisplayConfig` wire types so
63// `body().deserialize::<CurrentState>()` validates the reply against the exact
64// signature mutter sends. The `a{sv}` property dicts are kept as
65// `HashMap<String, OwnedValue>` (signature `a{sv}`) and ignored — we only need
66// the connector, mode id, and supported-scales list.
67
68/// `a{sv}` — a D-Bus property dict.
69type DbusProps = HashMap<String, OwnedValue>;
70/// `(siiddada{sv})` — one monitor mode: id, width, height, refresh, preferred
71/// scale, supported scales, properties.
72type MonitorMode = (String, i32, i32, f64, f64, Vec<f64>, DbusProps);
73/// `(ssss)` — connector, vendor, product, serial.
74type MonitorSpec = (String, String, String, String);
75/// `((ssss)a(siiddada{sv})a{sv})` — one physical monitor: spec, modes, props.
76type PhysicalMonitor = (MonitorSpec, Vec<MonitorMode>, DbusProps);
77/// `(iiduba(ssss)a{sv})` — one logical monitor in the current layout.
78type LogicalMonitor = (i32, i32, f64, u32, bool, Vec<MonitorSpec>, DbusProps);
79/// Return tuple of `GetCurrentState`: `(serial, monitors, logical, props)`.
80type CurrentState = (u32, Vec<PhysicalMonitor>, Vec<LogicalMonitor>, DbusProps);
81
82/// `(ssa{sv})` — one monitor assignment in an `ApplyMonitorsConfig` request:
83/// connector, mode id, properties.
84type MonitorAssignment = (String, String, DbusProps);
85/// `(iiduba(ssa{sv}))` — one logical monitor to apply: x, y, scale, transform,
86/// primary, assigned monitors.
87type LogicalMonitorConfig = (i32, i32, f64, u32, bool, Vec<MonitorAssignment>);
88
89/// Shared mutter-backend state consumed by `waydriver-input-mutter` and
90/// `waydriver-capture-mutter`.
91///
92/// **Invariant:** while any `Arc<MutterState>` exists, the underlying D-Bus
93/// connection and the mutter child process must remain alive. See the
94/// module docs for details.
95///
96/// Fields are private — all access goes through the accessor methods
97/// below. Sibling crates (`waydriver-input-mutter`,
98/// `waydriver-capture-mutter`) that previously read fields directly
99/// now call `state.conn()`, `state.rd_session_path()`, etc. The
100/// shape of the underlying storage (e.g. how `active_stream_path` is
101/// guarded) is therefore an implementation detail that can change
102/// without breaking those callers — the contract lives entirely in
103/// the method signatures.
104pub struct MutterState {
105    conn: zbus::Connection,
106    rd_session_path: String,
107    rd_session_id: String,
108    rd_started: Arc<Mutex<bool>>,
109    runtime_dir: PathBuf,
110    active_stream_path: Arc<Mutex<Option<String>>>,
111}
112
113impl MutterState {
114    /// Persistent connection to mutter's private D-Bus.
115    ///
116    /// Both sibling backends (`waydriver-input-mutter`,
117    /// `waydriver-capture-mutter`) issue all their RemoteDesktop and
118    /// ScreenCast method calls through this connection.
119    pub fn conn(&self) -> &zbus::Connection {
120        &self.conn
121    }
122
123    /// RemoteDesktop session object path. Used by
124    /// `waydriver-input-mutter` as the `path` argument on every
125    /// pointer / keyboard `Notify*` D-Bus call.
126    pub fn rd_session_path(&self) -> &str {
127        &self.rd_session_path
128    }
129
130    /// RemoteDesktop session id, read from the `SessionId` property on
131    /// the RD session. `waydriver-capture-mutter` passes this as the
132    /// `remote-desktop-session-id` option to
133    /// `ScreenCast.CreateSession` so mutter links the two; the link is
134    /// required for `NotifyPointerMotionAbsolute` to be accepted.
135    pub fn rd_session_id(&self) -> &str {
136        &self.rd_session_id
137    }
138
139    /// Per-session `XDG_RUNTIME_DIR`. `waydriver-capture-mutter` joins
140    /// this with `pipewire-0` to locate the PipeWire socket.
141    pub fn runtime_dir(&self) -> &Path {
142        &self.runtime_dir
143    }
144
145    /// Lock the "RD-started" flag.
146    ///
147    /// Acquires the underlying mutex and returns the guard so the
148    /// caller can perform a check-and-set under one critical section
149    /// (the capture backend defers `RD.Session.Start` until the first
150    /// linked `ScreenCast.CreateSession` succeeds — that's a load,
151    /// some D-Bus work, and a store; splitting the read and write
152    /// would race). `Error::Process` if the mutex is poisoned.
153    pub fn rd_started_lock(&self) -> Result<std::sync::MutexGuard<'_, bool>> {
154        self.rd_started
155            .lock()
156            .map_err(|_| waydriver::Error::process("rd_started mutex poisoned"))
157    }
158
159    /// Lock the active ScreenCast Stream object path.
160    ///
161    /// Set by `waydriver-capture-mutter` in `start_stream`, cleared in
162    /// `stop_stream`. `waydriver-input-mutter` reads it to route
163    /// `NotifyPointerMotionAbsolute` at the correct monitor. `None`
164    /// inside the guard means no stream is open — absolute pointer
165    /// motion will error.
166    pub fn active_stream_path_lock(&self) -> Result<std::sync::MutexGuard<'_, Option<String>>> {
167        self.active_stream_path
168            .lock()
169            .map_err(|_| waydriver::Error::process("active_stream_path mutex poisoned"))
170    }
171}
172
173/// Headless mutter instance.
174pub struct MutterCompositor {
175    id: String,
176    wayland_display: String,
177    runtime_dir: PathBuf,
178    mutter_dbus_address: String,
179    /// The private session bus, run as a *managed* `dbus-daemon` child (rather
180    /// than `dbus-launch`, which daemonizes it out of our process tree). Kept
181    /// alive for the compositor's lifetime; killed on `stop()`/`Drop` and —
182    /// via [`set_pdeathsig`] — reaped by the kernel if the controlling process
183    /// is hard-killed, taking the D-Bus-activated `at-spi-bus-launcher` down
184    /// with it instead of orphaning a stale a11y bus.
185    dbus_daemon: Option<Child>,
186    mutter: Option<Child>,
187    pipewire: Option<Child>,
188    wireplumber: Option<Child>,
189    state: Option<Arc<MutterState>>,
190    gsettings: GSettingsConfig,
191}
192
193/// The host runtime root under which every session's `wd-session-<id>`
194/// directory is created. Snapshotted once, lazily, on the first
195/// `MutterCompositor::new()` call.
196///
197/// This is deliberately read **once** and cached, rather than re-read from
198/// `XDG_RUNTIME_DIR` per session. `waydriver`'s screenshot and video pipelines
199/// (`waydriver::capture`) mutate the parent process's `XDG_RUNTIME_DIR` to
200/// point `pipewiresrc` at the *live* session's pipewire socket, and never
201/// restore it. If `new()` re-read the live env each time, session N+1's
202/// runtime dir would be created **inside** session N's dir
203/// (`…/wd-session-A/wd-session-B/…`), nesting one level deeper per session.
204/// After ~4 levels the `<dir>/pipewire-0` path exceeds the ~107-byte AF_UNIX
205/// `sun_path` limit, pipewire can no longer bind its socket, and every
206/// subsequent `start_session` fails with a "timeout: pipewire socket" error
207/// until the server is restarted (which resets the process env). Snapshotting
208/// the root keeps each session dir a flat sibling under the original
209/// `XDG_RUNTIME_DIR`, independent of how many sessions preceded it.
210///
211/// The first `new()` runs before any session exists, so the env is still the
212/// pristine value set by the launcher (e.g. the Docker entrypoint) — capturing
213/// it then is safe.
214static HOST_RUNTIME_ROOT: LazyLock<PathBuf> = LazyLock::new(|| {
215    let root = std::env::var("XDG_RUNTIME_DIR")
216        .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
217    PathBuf::from(root)
218});
219
220/// Eagerly capture [`HOST_RUNTIME_ROOT`] from the current `XDG_RUNTIME_DIR`
221/// and return it.
222///
223/// The snapshot is otherwise taken lazily on the first [`MutterCompositor::new`].
224/// Call this once at process startup — before any session is created and
225/// before anything can mutate `XDG_RUNTIME_DIR` — to pin the root to the
226/// pristine launcher value deterministically, rather than relying on `new()`
227/// happening first. Idempotent: subsequent calls (and `new()`) return the same
228/// captured value.
229pub fn establish_runtime_root() -> &'static std::path::Path {
230    HOST_RUNTIME_ROOT.as_path()
231}
232
233impl MutterCompositor {
234    /// Construct but do not start. Generates the session id and computes
235    /// where the Wayland socket and runtime dir will live. No I/O.
236    pub fn new() -> Self {
237        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
238        let wayland_display = format!("wayland-wd-{}", id);
239
240        let runtime_dir = HOST_RUNTIME_ROOT.join(format!("wd-session-{}", id));
241
242        Self {
243            id,
244            wayland_display,
245            runtime_dir,
246            mutter_dbus_address: String::new(),
247            dbus_daemon: None,
248            mutter: None,
249            pipewire: None,
250            wireplumber: None,
251            state: None,
252            gsettings: GSettingsConfig::default(),
253        }
254    }
255
256    /// Set the per-session GSettings isolation config (see
257    /// [`waydriver::gsettings`]). Defaults to isolated with no seeded entries.
258    /// When isolated, [`start`](Self::start) writes a private keyfile (seeded
259    /// with `org.gnome.mutter experimental-features` plus `config.initial`)
260    /// and points mutter at it, so fractional scales work and the host's dconf
261    /// is neither read nor written. Pass `isolated: false` to run mutter
262    /// against the host's GSettings instead.
263    pub fn with_gsettings(mut self, config: GSettingsConfig) -> Self {
264        self.gsettings = config;
265        self
266    }
267
268    /// Returns the shared `Arc<MutterState>` for passing to sibling
269    /// backends, or `None` when called outside the started window.
270    ///
271    /// `None` is returned when:
272    /// - `start()` has not yet completed (or returned an error), or
273    /// - `stop()` has been called and dropped the state.
274    ///
275    /// Callers that have just awaited `start()?` know the state is
276    /// present — `expect()` or `?`-with-typed-error is appropriate
277    /// there. Returning `Option` instead of panicking keeps the API
278    /// honest about the lifecycle and lets callers detect "stopped"
279    /// without first matching on a panic.
280    pub fn state(&self) -> Option<Arc<MutterState>> {
281        self.state.clone()
282    }
283}
284
285impl Default for MutterCompositor {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291impl MutterCompositor {
292    /// Typed-error implementation of `start`. The trait method calls
293    /// this and converts the result via `From<MutterError>`.
294    ///
295    /// Steps (each fails with a specific `MutterError` variant):
296    /// 1. validate resolution + scale,
297    /// 2. ensure the session runtime dir exists,
298    /// 3. spawn a private `dbus-daemon` and parse its address + PID,
299    /// 4. spawn `pipewire` + `wireplumber` on that bus,
300    /// 5. spawn headless `mutter --wayland`,
301    /// 6. wait for the Wayland socket,
302    /// 7. open a zbus connection, retry-create the RemoteDesktop session,
303    /// 8. read its `SessionId` property,
304    /// 9. apply a non-default logical-monitor scale via DisplayConfig,
305    /// 10. publish the `Arc<MutterState>` for sibling backends.
306    async fn start_inner(
307        &mut self,
308        resolution: Option<&str>,
309        scale: Option<f64>,
310    ) -> std::result::Result<(), MutterError> {
311        let resolution = resolution.unwrap_or(DEFAULT_RESOLUTION);
312        // Validate before we start spawning subprocesses — mutter silently
313        // ignores bad --virtual-monitor values and falls back to its own
314        // default, which would surprise the caller.
315        parse_resolution(resolution)?;
316        let scale = scale.unwrap_or(DEFAULT_SCALE);
317        // Fail fast on a nonsense scale too, for the same reason — the
318        // DisplayConfig apply that consumes it doesn't run until mutter is up.
319        validate_scale(scale)?;
320
321        tracing::info!(
322            id = self.id,
323            resolution,
324            scale,
325            isolated = self.gsettings.isolated,
326            "starting mutter compositor"
327        );
328
329        tokio::fs::create_dir_all(&self.runtime_dir).await?;
330        // `runtime_dir` is built in `new()` from a UTF-8 String
331        // (XDG_RUNTIME_DIR or `/run/user/<uid>`) joined with a UTF-8
332        // ASCII session id, so the path is guaranteed valid UTF-8.
333        // `expect` documents that invariant rather than re-deriving
334        // it via the `to_str()` `Option`.
335        let runtime_str = self
336            .runtime_dir
337            .to_str()
338            .expect("invariant: runtime_dir built from UTF-8 inputs in new()")
339            .to_string();
340
341        // GSettings isolation: when on, write the session's private keyfile
342        // (read by both mutter and the app — see `waydriver::gsettings`) and
343        // compute the env that points mutter at it. The keyfile is seeded with
344        // the fractional-scaling experimental feature so a non-integer `scale`
345        // is actually advertised by mutter, then any caller-supplied entries
346        // are appended (last-wins, so callers can override). When off, mutter
347        // reads the host's GSettings and `config_env` stays empty.
348        let config_env: Vec<(&str, String)> = if self.gsettings.isolated {
349            let mut entries = vec![GSettingEntry::new(
350                "org.gnome.mutter",
351                "experimental-features",
352                MUTTER_FRACTIONAL_SCALING,
353            )];
354            entries.extend(self.gsettings.initial.iter().cloned());
355            gsettings::write_keyfile(&self.runtime_dir, &entries)?;
356            let config_dir = gsettings::config_dir(&self.runtime_dir)
357                .to_str()
358                .expect("invariant: config_dir is runtime_dir (UTF-8) + ASCII suffix")
359                .to_string();
360            vec![
361                ("XDG_CONFIG_HOME", config_dir),
362                ("GSETTINGS_BACKEND", gsettings::KEYFILE_BACKEND.to_string()),
363            ]
364        } else {
365            Vec::new()
366        };
367
368        // Step 1: Private D-Bus for mutter (so its ScreenCast API doesn't
369        // conflict with host). Run `dbus-daemon` directly as a managed,
370        // PDEATHSIG-protected child instead of `dbus-launch`: dbus-launch
371        // daemonizes the bus out of our process tree, so a hard-killed
372        // controlling process would orphan it — and the `at-spi-bus-launcher`
373        // it D-Bus-activates, whose stale socket then GUID-mismatches every
374        // later run. As our own child, the kernel reaps it on hard-kill.
375        // Pick the bus socket ourselves (under the per-session runtime dir,
376        // alongside the wayland/pipewire sockets) and pass it via `--address`,
377        // rather than parsing the daemon's stdout for `--print-address`: reading
378        // stdout is fragile across distros/containers (an early stderr-only
379        // failure reads back as an empty address), and a chosen path lets us use
380        // the same socket-appears readiness poll as wayland/pipewire.
381        let bus_socket = self.runtime_dir.join("bus");
382        let address = format!("unix:path={}", bus_socket.display());
383        let mut dbus_cmd = Command::new("dbus-daemon");
384        dbus_cmd
385            .args(["--session", "--nofork", "--nopidfile"])
386            .arg(format!("--address={address}"))
387            .stdout(Stdio::null())
388            .stderr(Stdio::null())
389            .kill_on_drop(true);
390        set_pdeathsig(&mut dbus_cmd);
391        let dbus_daemon = dbus_cmd.spawn().map_err(|source| MutterError::Spawn {
392            process: "dbus-daemon",
393            source,
394        })?;
395        self.dbus_daemon = Some(dbus_daemon);
396        wait_for_dbus_socket(&bus_socket).await?;
397        self.mutter_dbus_address = address;
398        tracing::debug!(id = self.id, mutter_dbus_address = %self.mutter_dbus_address, "private D-Bus for mutter");
399
400        // Step 2: PipeWire + WirePlumber (for screenshots via ScreenCast).
401        //
402        // `env_remove("PIPEWIRE_REMOTE")` is load-bearing: `waydriver`'s
403        // `grab_png_sync` mutates the parent's process env to point
404        // `pipewiresrc` at the live session's pipewire socket. After a
405        // session stops, that socket is gone but the env var lingers in
406        // the parent. Without scrubbing it here, a freshly spawned
407        // `pipewire`/`wireplumber`/`mutter` for the next session would
408        // inherit the stale value and try to connect to the previous
409        // session's dead socket — wireplumber/mutter prefer
410        // `PIPEWIRE_REMOTE` over `XDG_RUNTIME_DIR/pipewire-0`, so the
411        // explicit `XDG_RUNTIME_DIR` override below isn't enough.
412        // Symptom: `ScreenCast.Start` fails with "Couldn't connect
413        // pipewire context" on every session after the first.
414        let mut pipewire_cmd = Command::new("pipewire");
415        pipewire_cmd
416            .env_remove("PIPEWIRE_REMOTE")
417            .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
418            .env("XDG_RUNTIME_DIR", &runtime_str)
419            .stdout(Stdio::null())
420            .stderr(Stdio::null());
421        set_pdeathsig(&mut pipewire_cmd);
422        let pipewire = pipewire_cmd.spawn().map_err(|source| MutterError::Spawn {
423            process: "pipewire",
424            source,
425        })?;
426        self.pipewire = Some(pipewire);
427
428        // Wait for pipewire's socket to appear before launching
429        // wireplumber. Polling for the socket file is the same
430        // readiness signal `wait_for_wayland_socket` uses for
431        // mutter: it's the actual handshake clients use, so any
432        // earlier signal would either be racier (process spawn) or
433        // just as expensive to probe.
434        wait_for_pipewire_socket(&runtime_str).await?;
435
436        let mut wireplumber_cmd = Command::new("wireplumber");
437        wireplumber_cmd
438            .env_remove("PIPEWIRE_REMOTE")
439            .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
440            .env("XDG_RUNTIME_DIR", &runtime_str)
441            .stdout(Stdio::null())
442            .stderr(Stdio::null());
443        set_pdeathsig(&mut wireplumber_cmd);
444        let wireplumber = wireplumber_cmd
445            .spawn()
446            .map_err(|source| MutterError::Spawn {
447                process: "wireplumber",
448                source,
449            })?;
450        self.wireplumber = Some(wireplumber);
451
452        // No bus-readiness signal poll for wireplumber: it's a
453        // session-policy daemon that doesn't register a stable D-Bus
454        // name we can probe, and its initialisation runs in parallel
455        // with mutter's own startup. The downstream
456        // `ScreenCast.CreateSession` retry loop in
457        // `waydriver-capture-mutter::start_stream` is what actually
458        // gates on wireplumber having joined the graph — putting a
459        // pessimistic sleep here as well would add startup latency
460        // without changing correctness.
461        tracing::debug!(id = self.id, "PipeWire + WirePlumber started");
462
463        // Step 3: mutter in headless Wayland mode (on its private D-Bus).
464        let mut mutter_cmd = Command::new("mutter");
465        mutter_cmd
466            .args([
467                "--headless",
468                "--wayland",
469                "--no-x11",
470                "--wayland-display",
471                &self.wayland_display,
472                "--virtual-monitor",
473                resolution,
474            ])
475            .env_remove("PIPEWIRE_REMOTE")
476            .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
477            .env("XDG_RUNTIME_DIR", &runtime_str)
478            // Empty when isolation is off; otherwise points mutter at the
479            // per-session keyfile GSettings store written above.
480            .envs(config_env.iter().map(|(k, v)| (*k, v.as_str())))
481            .stdout(Stdio::null())
482            .stderr(Stdio::inherit());
483        set_pdeathsig(&mut mutter_cmd);
484        let mutter = mutter_cmd.spawn().map_err(|source| MutterError::Spawn {
485            process: "mutter",
486            source,
487        })?;
488        self.mutter = Some(mutter);
489        tracing::debug!(id = self.id, wayland_display = %self.wayland_display, "mutter spawned");
490
491        // Step 4: Wait for the Wayland socket.
492        wait_for_wayland_socket(&runtime_str, &self.wayland_display).await?;
493        tracing::debug!(id = self.id, "wayland socket ready");
494
495        // Step 5: Connect to mutter's private D-Bus and start RemoteDesktop session.
496        let mutter_addr: zbus::address::Address = self
497            .mutter_dbus_address
498            .as_str()
499            .try_into()
500            .map_err(|source: zbus::Error| MutterError::DbusAddressInvalid {
501                addr: self.mutter_dbus_address.clone(),
502                source,
503            })?;
504        let mutter_conn = zbus::connection::Builder::address(mutter_addr)
505            .map_err(|source| MutterError::DbusConnect {
506                stage: "build connection builder",
507                source,
508            })?
509            .build()
510            .await
511            .map_err(|source| MutterError::DbusConnect {
512                stage: "connect",
513                source,
514            })?;
515
516        // Wait for mutter to register its D-Bus services (may take a moment after socket appears)
517        let mut rd_reply = None;
518        for i in 0..50 {
519            match mutter_conn
520                .call_method(
521                    Some("org.gnome.Mutter.RemoteDesktop"),
522                    "/org/gnome/Mutter/RemoteDesktop",
523                    Some("org.gnome.Mutter.RemoteDesktop"),
524                    "CreateSession",
525                    &(),
526                )
527                .await
528            {
529                Ok(reply) => {
530                    rd_reply = Some(reply);
531                    break;
532                }
533                Err(e) if i < 49 => {
534                    tracing::debug!(
535                        id = self.id,
536                        attempt = i,
537                        "waiting for RemoteDesktop service: {e}"
538                    );
539                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
540                }
541                Err(e) => {
542                    return Err(MutterError::RemoteDesktopCreate(e));
543                }
544            }
545        }
546        // The retry loop above either `break`s with `rd_reply = Some(_)`
547        // or returns `Err(...)` from the final attempt — `unwrap` here
548        // is unreachable by construction.
549        let rd_reply = rd_reply.expect("retry loop sets Some on break or returns Err");
550        let rd_session_path: zbus::zvariant::OwnedObjectPath = rd_reply
551            .body()
552            .deserialize()
553            .map_err(MutterError::RdSessionPathParse)?;
554        // Intentionally do NOT call `RemoteDesktop.Session.Start` here.
555        // Mutter only accepts `remote-desktop-session-id` on
556        // `ScreenCast.CreateSession` when the RD session is not yet
557        // started, so `waydriver-capture-mutter::start_stream` defers
558        // the Start call until after it has created the linked
559        // ScreenCast session.
560        // Read the RD session's `SessionId` property — it's the token
561        // ScreenCast.CreateSession needs in `remote-desktop-session-id`
562        // to link the two sessions. Without that link, mutter rejects
563        // NotifyPointerMotionAbsolute with "No screen cast active".
564        let rd_session_id_reply = mutter_conn
565            .call_method(
566                Some("org.gnome.Mutter.RemoteDesktop"),
567                rd_session_path.as_str(),
568                Some("org.freedesktop.DBus.Properties"),
569                "Get",
570                &("org.gnome.Mutter.RemoteDesktop.Session", "SessionId"),
571            )
572            .await
573            .map_err(MutterError::SessionIdGet)?;
574        // `Get` returns a variant; deserialize as `OwnedValue` to detach
575        // the string from the reply's body before the reply is dropped.
576        let rd_session_id_body = rd_session_id_reply.body();
577        let rd_session_id_variant: zbus::zvariant::OwnedValue = rd_session_id_body
578            .deserialize()
579            .map_err(MutterError::SessionIdVariantParse)?;
580        let rd_session_id: String = rd_session_id_variant
581            .try_into()
582            .map_err(MutterError::SessionIdNotString)?;
583
584        let rd_session_path = rd_session_path.to_string();
585        tracing::debug!(
586            id = self.id,
587            rd_session_path = %rd_session_path,
588            rd_session_id = %rd_session_id,
589            "RemoteDesktop session started"
590        );
591
592        // Step: apply a non-default logical-monitor scale. `--virtual-monitor`
593        // has no scale component, so HiDPI is configured here over mutter's
594        // private bus once DisplayConfig is up. Skipped at 1.0 to leave the
595        // default 1:1 path completely untouched.
596        if (scale - DEFAULT_SCALE).abs() > f64::EPSILON {
597            let applied = apply_scale(&mutter_conn, scale, &self.id).await?;
598            tracing::info!(
599                id = self.id,
600                requested = scale,
601                applied,
602                "applied logical-monitor scale"
603            );
604        }
605
606        self.state = Some(Arc::new(MutterState {
607            conn: mutter_conn,
608            rd_session_path,
609            rd_session_id,
610            rd_started: Arc::new(Mutex::new(false)),
611            runtime_dir: self.runtime_dir.clone(),
612            active_stream_path: Arc::new(Mutex::new(None)),
613        }));
614
615        Ok(())
616    }
617}
618
619#[async_trait]
620impl CompositorRuntime for MutterCompositor {
621    async fn start(&mut self, resolution: Option<&str>, scale: Option<f64>) -> Result<()> {
622        // Body uses the crate-local typed `MutterError`. The `?` at the
623        // end of `self.start_inner(...).await?` runs the
624        // `From<MutterError> for waydriver::Error` impl in `error.rs`,
625        // which is the single boundary at which the typed enum becomes
626        // the workspace's shared `waydriver::Error`.
627        Ok(self.start_inner(resolution, scale).await?)
628    }
629
630    async fn stop(&mut self) -> Result<()> {
631        tracing::info!(id = self.id, "stopping mutter compositor");
632
633        // Stop RemoteDesktop session if still reachable. We could
634        // touch the private fields directly here (same crate), but
635        // routing through the public accessors keeps the contract
636        // visible and means a future change to the field layout
637        // doesn't need to update this site.
638        if let Some(state) = &self.state {
639            let _ = state
640                .conn()
641                .call_method(
642                    Some("org.gnome.Mutter.RemoteDesktop"),
643                    state.rd_session_path(),
644                    Some("org.gnome.Mutter.RemoteDesktop.Session"),
645                    "Stop",
646                    &(),
647                )
648                .await;
649        }
650
651        // Drop our strong ref to the shared state. If callers haven't dropped
652        // theirs (the input/capture trait objects), their Arc still points at
653        // the D-Bus connection we're about to tear down below — any method
654        // call on them after this will fail with "connection closed".
655        self.state = None;
656
657        if let Some(mut mutter) = self.mutter.take() {
658            let _ = mutter.kill().await;
659            let _ = mutter.wait().await;
660        }
661        if let Some(mut wireplumber) = self.wireplumber.take() {
662            let _ = wireplumber.kill().await;
663            let _ = wireplumber.wait().await;
664        }
665        if let Some(mut pipewire) = self.pipewire.take() {
666            let _ = pipewire.kill().await;
667            let _ = pipewire.wait().await;
668        }
669
670        // Kill the private bus last: its death drops the a11y bus connection,
671        // so the D-Bus-activated `at-spi-bus-launcher` exits with it.
672        if let Some(mut dbus) = self.dbus_daemon.take() {
673            let _ = dbus.kill().await;
674            let _ = dbus.wait().await;
675        }
676
677        let _ = tokio::fs::remove_dir_all(&self.runtime_dir).await;
678
679        tracing::debug!(id = self.id, "mutter compositor stopped");
680        Ok(())
681    }
682
683    fn id(&self) -> &str {
684        &self.id
685    }
686
687    fn wayland_display(&self) -> &str {
688        &self.wayland_display
689    }
690
691    fn runtime_dir(&self) -> &Path {
692        &self.runtime_dir
693    }
694}
695
696impl Drop for MutterCompositor {
697    fn drop(&mut self) {
698        // Best-effort cleanup when dropped without calling stop().
699        // Can't use async here, so send SIGKILL synchronously.
700        self.state = None;
701
702        if let Some(ref mut child) = self.mutter {
703            let _ = child.start_kill();
704        }
705        if let Some(ref mut child) = self.wireplumber {
706            let _ = child.start_kill();
707        }
708        if let Some(ref mut child) = self.pipewire {
709            let _ = child.start_kill();
710        }
711        if let Some(ref mut child) = self.dbus_daemon {
712            let _ = child.start_kill();
713        }
714        let _ = std::fs::remove_dir_all(&self.runtime_dir);
715    }
716}
717
718// ── Helpers ─────────────────────────────────────────────────────────────────
719
720/// Linux parent-death protection for a spawned session daemon: ask the kernel
721/// to `SIGKILL` the child the instant the spawning thread dies.
722///
723/// Drop/`stop()`-based teardown can't run when the *controlling* process is
724/// itself hard-killed (`SIGKILL`, `panic = "abort"`, OOM, a CI/test-runner
725/// timeout). Without this, such a death orphans the whole session quartet —
726/// `dbus-daemon`, `pipewire`, `wireplumber`, `mutter` — and, worst of all, the
727/// D-Bus-activated `at-spi-bus-launcher`, whose stale a11y-bus socket then
728/// GUID-mismatches every later run. `PR_SET_PDEATHSIG` closes that hole at the
729/// kernel level. The `getppid` check covers the race where the parent dies
730/// between fork and exec (the child would otherwise miss the death signal).
731fn set_pdeathsig(cmd: &mut Command) {
732    // Capture our PID now (in the parent). The child compares it against its
733    // own parent after fork: a mismatch means we already died and it was
734    // reparented (to init, or a subreaper), so it should bail. We must NOT
735    // hard-code "getppid() == 1 → orphaned": in a container the controlling
736    // process often *is* PID 1, so every legitimately-parented child sees
737    // getppid() == 1 and would be killed at exec.
738    let parent = std::process::id();
739    // SAFETY: the closure runs in the forked child before exec and calls only
740    // async-signal-safe libc functions (prctl, getppid, _exit).
741    unsafe {
742        cmd.pre_exec(move || {
743            if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
744                return Err(std::io::Error::last_os_error());
745            }
746            if libc::getppid() != parent as i32 {
747                libc::_exit(0);
748            }
749            Ok(())
750        });
751    }
752}
753
754fn parse_resolution(s: &str) -> std::result::Result<(u32, u32), MutterError> {
755    let invalid = || MutterError::ResolutionInvalid {
756        value: s.to_string(),
757    };
758    let (w, h) = s.split_once('x').ok_or_else(invalid)?;
759    let parse = |part: &str| -> std::result::Result<u32, MutterError> {
760        part.parse::<u32>()
761            .ok()
762            .filter(|n| *n > 0)
763            .ok_or_else(invalid)
764    };
765    Ok((parse(w)?, parse(h)?))
766}
767
768/// Reject a scale that isn't a finite, positive factor inside
769/// [`MIN_SCALE`]..=[`MAX_SCALE`]. Run before any subprocess spawns so a bad
770/// value fails fast.
771fn validate_scale(scale: f64) -> std::result::Result<(), MutterError> {
772    if scale.is_finite() && (MIN_SCALE..=MAX_SCALE).contains(&scale) {
773        Ok(())
774    } else {
775        Err(MutterError::ScaleInvalid {
776            value: scale,
777            min: MIN_SCALE,
778            max: MAX_SCALE,
779        })
780    }
781}
782
783/// Pick the entry of `supported` closest to `requested`. Mutter only accepts
784/// scales it advertises for a mode, so an arbitrary request (e.g. 1.66) is
785/// snapped to the nearest legal step. Falls back to `requested` when the list
786/// is empty (mutter then validates — and likely rejects — it).
787fn nearest_supported_scale(requested: f64, supported: &[f64]) -> f64 {
788    supported
789        .iter()
790        .copied()
791        .min_by(|a, b| {
792            let da = (a - requested).abs();
793            let db = (b - requested).abs();
794            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
795        })
796        .unwrap_or(requested)
797}
798
799/// Apply `requested` as the logical-monitor scale of the (single) virtual
800/// monitor via `org.gnome.Mutter.DisplayConfig`, returning the scale mutter
801/// actually accepted (snapped to a supported step).
802///
803/// Reads `GetCurrentState` fresh each call so the `serial` is current —
804/// mutter rejects `ApplyMonitorsConfig` on a stale serial, and the serial
805/// bumps when the virtual monitor first appears. Fractional scales (1.5,
806/// 1.75, …) require the `scale-monitor-framebuffer` experimental feature; the
807/// container entrypoint enables it. Without it only integer scales are
808/// advertised, so [`nearest_supported_scale`] would snap a fractional request
809/// to 1.0 or 2.0.
810async fn apply_scale(
811    conn: &zbus::Connection,
812    requested: f64,
813    id: &str,
814) -> std::result::Result<f64, MutterError> {
815    let state_reply = conn
816        .call_method(
817            Some("org.gnome.Mutter.DisplayConfig"),
818            "/org/gnome/Mutter/DisplayConfig",
819            Some("org.gnome.Mutter.DisplayConfig"),
820            "GetCurrentState",
821            &(),
822        )
823        .await
824        .map_err(|source| MutterError::DisplayConfigState {
825            stage: "call",
826            source,
827        })?;
828    let state_body = state_reply.body();
829    let (serial, monitors, _logical, _props): CurrentState =
830        state_body
831            .deserialize()
832            .map_err(|source| MutterError::DisplayConfigState {
833                stage: "deserialize",
834                source,
835            })?;
836
837    // Headless mutter started with a single `--virtual-monitor` exposes
838    // exactly one monitor advertising exactly the mode we asked for, so the
839    // first monitor / first mode is the one to scale.
840    let (spec, modes, _mprops) = monitors
841        .into_iter()
842        .next()
843        .ok_or(MutterError::DisplayConfigNoMonitor)?;
844    let connector = spec.0;
845    let (mode_id, _w, _h, _refresh, _preferred, supported, _modeprops) =
846        modes
847            .into_iter()
848            .next()
849            .ok_or(MutterError::DisplayConfigNoMonitor)?;
850
851    let applied = nearest_supported_scale(requested, &supported);
852    if (applied - requested).abs() > SCALE_SNAP_TOLERANCE {
853        tracing::warn!(
854            id,
855            requested,
856            applied,
857            supported = ?supported,
858            "requested scale not advertised by mutter; snapped to nearest supported"
859        );
860    }
861
862    // (x, y, scale, transform, primary, [(connector, mode_id, {})]).
863    let logical: LogicalMonitorConfig = (
864        0,
865        0,
866        applied,
867        0,
868        true,
869        vec![(connector, mode_id, DbusProps::new())],
870    );
871    // method 1 = temporary: applies for this session without writing
872    // ~/.config/monitors.xml, which is all a throwaway headless run needs.
873    conn.call_method(
874        Some("org.gnome.Mutter.DisplayConfig"),
875        "/org/gnome/Mutter/DisplayConfig",
876        Some("org.gnome.Mutter.DisplayConfig"),
877        "ApplyMonitorsConfig",
878        &(serial, 1u32, vec![logical], DbusProps::new()),
879    )
880    .await
881    .map_err(|source| MutterError::DisplayConfigApply {
882        scale: applied,
883        source,
884    })?;
885
886    Ok(applied)
887}
888
889async fn wait_for_wayland_socket(
890    runtime_dir: &str,
891    display: &str,
892) -> std::result::Result<(), MutterError> {
893    let socket_path = PathBuf::from(runtime_dir).join(display);
894    for _ in 0..50 {
895        if socket_path.exists() {
896            return Ok(());
897        }
898        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
899    }
900    Err(MutterError::WaylandSocketTimeout {
901        socket: socket_path.display().to_string(),
902    })
903}
904
905/// PipeWire creates `<runtime_dir>/pipewire-0` as soon as it's ready
906/// to accept client connections. Polling for that file replaces the
907/// previous unconditional `sleep(1s)` after spawning the pipewire
908/// process — same readiness model as
909/// [`wait_for_wayland_socket`].
910async fn wait_for_pipewire_socket(runtime_dir: &str) -> std::result::Result<(), MutterError> {
911    let socket_path = PathBuf::from(runtime_dir).join("pipewire-0");
912    for _ in 0..50 {
913        if socket_path.exists() {
914            return Ok(());
915        }
916        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
917    }
918    Err(MutterError::PipewireSocketTimeout {
919        socket: socket_path.display().to_string(),
920    })
921}
922
923/// Poll for the managed `dbus-daemon`'s listen socket to appear before any
924/// client connects to it — the same readiness signal used for the wayland and
925/// pipewire sockets. A timeout means the daemon failed to bind (bad config,
926/// path too long for `AF_UNIX`, missing binary).
927async fn wait_for_dbus_socket(socket: &Path) -> std::result::Result<(), MutterError> {
928    for _ in 0..50 {
929        if socket.exists() {
930            return Ok(());
931        }
932        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
933    }
934    Err(MutterError::DbusLaunchFailed(format!(
935        "dbus-daemon socket never appeared at {}",
936        socket.display()
937    )))
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943
944    /// Proves [`set_pdeathsig`]'s mechanism: a child spawned with
945    /// `PR_SET_PDEATHSIG = SIGKILL` is reaped by the kernel when its parent is
946    /// hard-killed (bypassing all Drop/teardown) — the exact protection that
947    /// stops a SIGKILL'd run from orphaning the session daemons + the
948    /// `at-spi-bus-launcher`.
949    ///
950    /// Re-execs the test binary as a *helper*: the helper spawns `sleep 300`
951    /// with the same `pre_exec` as `set_pdeathsig`, writes its PID to a shared
952    /// pidfile, then blocks. The supervisor reads the PID, `SIGKILL`s the helper
953    /// (so no Drop runs), and asserts the kernel reaps the `sleep`.
954    #[test]
955    #[ignore = "spawns and SIGKILLs a subprocess; run manually with --ignored"]
956    fn pdeathsig_reaps_orphaned_child() {
957        use std::os::unix::process::CommandExt;
958        use std::process::Command as StdCommand;
959
960        // The supervisor passes the pidfile path; its presence selects the role.
961        if let Ok(pidfile) = std::env::var("WD_PDEATHSIG_PIDFILE") {
962            // Helper role: spawn `sleep` protected by PR_SET_PDEATHSIG.
963            let mut sleep = StdCommand::new("sleep");
964            sleep.arg("300");
965            let parent = std::process::id();
966            // SAFETY: only async-signal-safe libc calls before exec.
967            unsafe {
968                sleep.pre_exec(move || {
969                    if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
970                        return Err(std::io::Error::last_os_error());
971                    }
972                    if libc::getppid() != parent as i32 {
973                        libc::_exit(0);
974                    }
975                    Ok(())
976                });
977            }
978            let mut child = sleep.spawn().expect("spawn sleep");
979            // Write+rename so the supervisor never reads a half-written pid.
980            let tmp = format!("{pidfile}.tmp");
981            std::fs::write(&tmp, child.id().to_string()).unwrap();
982            std::fs::rename(&tmp, &pidfile).unwrap();
983            // Block on the sleep until the supervisor SIGKILLs us (also
984            // satisfies clippy::zombie_processes — the child is waited on).
985            let _ = child.wait();
986            return;
987        }
988
989        // Supervisor role: re-exec self as the helper.
990        let dir = tempfile::tempdir().unwrap();
991        let pidfile = dir.path().join("sleep.pid");
992        let exe = std::env::current_exe().unwrap();
993        let mut helper = StdCommand::new(exe)
994            .args([
995                "--exact",
996                "tests::pdeathsig_reaps_orphaned_child",
997                "--ignored",
998            ])
999            .env("WD_PDEATHSIG_PIDFILE", &pidfile)
1000            .spawn()
1001            .expect("spawn helper");
1002
1003        // Wait for the helper to publish the sleep PID.
1004        let mut sleep_pid = None;
1005        for _ in 0..100 {
1006            if let Ok(s) = std::fs::read_to_string(&pidfile) {
1007                if let Ok(pid) = s.trim().parse::<i32>() {
1008                    sleep_pid = Some(pid);
1009                    break;
1010                }
1011            }
1012            std::thread::sleep(std::time::Duration::from_millis(100));
1013        }
1014        let sleep_pid = sleep_pid.expect("helper never published the sleep PID");
1015
1016        // The sleep is alive while the helper lives.
1017        assert_eq!(
1018            unsafe { libc::kill(sleep_pid, 0) },
1019            0,
1020            "sleep ({sleep_pid}) should be running before the helper is killed"
1021        );
1022
1023        // Hard-kill the helper — no Drop, no teardown.
1024        unsafe {
1025            libc::kill(helper.id() as i32, libc::SIGKILL);
1026        }
1027        let _ = helper.wait();
1028
1029        // The kernel must SIGKILL the orphaned sleep; init reaps the zombie.
1030        let mut reaped = false;
1031        for _ in 0..50 {
1032            if unsafe { libc::kill(sleep_pid, 0) } != 0 {
1033                reaped = true;
1034                break;
1035            }
1036            std::thread::sleep(std::time::Duration::from_millis(100));
1037        }
1038        if !reaped {
1039            unsafe { libc::kill(sleep_pid, libc::SIGKILL) };
1040        }
1041        assert!(
1042            reaped,
1043            "PR_SET_PDEATHSIG did not reap the orphaned child after its parent was SIGKILLed"
1044        );
1045    }
1046
1047    /// Live end-to-end check that a fractional scale is actually applied by a
1048    /// real mutter. Requires the runtime stack (mutter, pipewire, wireplumber,
1049    /// dbus-launch) on `PATH` plus the GSettings schemas in `XDG_DATA_DIRS`, so
1050    /// it's `#[ignore]`d by default; run with the dev shell's env via
1051    /// `cargo test -p waydriver-compositor-mutter -- --ignored`.
1052    ///
1053    /// This exercises the whole chain at once: the per-session keyfile must
1054    /// enable `scale-monitor-framebuffer` (otherwise 1.5 is not advertised and
1055    /// would snap to 1.0/2.0), and `apply_scale` must drive DisplayConfig
1056    /// correctly. We read the scale straight back from `GetCurrentState`.
1057    #[tokio::test]
1058    #[ignore = "requires a live mutter/pipewire/dbus runtime stack"]
1059    async fn applies_fractional_scale_against_real_mutter() {
1060        let mut compositor = MutterCompositor::new();
1061        compositor
1062            .start(Some("1920x1080"), Some(1.5))
1063            .await
1064            .expect("compositor should start");
1065        let state = compositor.state().expect("state present after start");
1066
1067        let reply = state
1068            .conn()
1069            .call_method(
1070                Some("org.gnome.Mutter.DisplayConfig"),
1071                "/org/gnome/Mutter/DisplayConfig",
1072                Some("org.gnome.Mutter.DisplayConfig"),
1073                "GetCurrentState",
1074                &(),
1075            )
1076            .await
1077            .expect("GetCurrentState should succeed");
1078        let body = reply.body();
1079        let (_serial, _monitors, logical, _props): CurrentState = body
1080            .deserialize()
1081            .expect("GetCurrentState body should deserialize");
1082        let applied = logical.first().expect("at least one logical monitor").2;
1083
1084        compositor.stop().await.expect("compositor should stop");
1085
1086        assert!(
1087            (applied - 1.5).abs() < 0.01,
1088            "expected logical scale ~1.5 (fractional scaling enabled via keyfile), got {applied}"
1089        );
1090    }
1091
1092    #[tokio::test]
1093    async fn test_wait_for_socket_found() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let runtime_dir = dir.path().to_str().unwrap().to_string();
1096        let display = "wayland-test-99";
1097        std::fs::File::create(dir.path().join(display)).unwrap();
1098        wait_for_wayland_socket(&runtime_dir, display)
1099            .await
1100            .unwrap();
1101    }
1102
1103    #[tokio::test]
1104    async fn test_wait_for_pipewire_socket_found() {
1105        let dir = tempfile::tempdir().unwrap();
1106        let runtime_dir = dir.path().to_str().unwrap().to_string();
1107        std::fs::File::create(dir.path().join("pipewire-0")).unwrap();
1108        wait_for_pipewire_socket(&runtime_dir).await.unwrap();
1109    }
1110
1111    #[tokio::test]
1112    async fn test_wait_for_pipewire_socket_timeout() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let runtime_dir = dir.path().to_str().unwrap().to_string();
1115        let err = wait_for_pipewire_socket(&runtime_dir).await.unwrap_err();
1116        assert!(
1117            matches!(err, MutterError::PipewireSocketTimeout { .. }),
1118            "expected PipewireSocketTimeout, got: {err}"
1119        );
1120        // Public mapping: same Timeout bucket as the wayland one,
1121        // so workspace callers matching `Error::Timeout(_)` (e.g.
1122        // the e2e tests) keep working.
1123        let public: waydriver::Error = err.into();
1124        assert!(
1125            matches!(public, waydriver::Error::Timeout(_)),
1126            "expected waydriver::Error::Timeout, got: {public}"
1127        );
1128    }
1129
1130    #[tokio::test]
1131    async fn test_wait_for_socket_timeout() {
1132        let dir = tempfile::tempdir().unwrap();
1133        let runtime_dir = dir.path().to_str().unwrap().to_string();
1134        let display = "wayland-nonexistent-0";
1135        let err = wait_for_wayland_socket(&runtime_dir, display)
1136            .await
1137            .unwrap_err();
1138        assert!(
1139            matches!(err, MutterError::WaylandSocketTimeout { .. }),
1140            "expected WaylandSocketTimeout, got: {err}"
1141        );
1142        // And confirm the From<MutterError> -> waydriver::Error mapping
1143        // still produces the public Timeout variant — workspace callers
1144        // (notably the e2e tests) match on it.
1145        let public: waydriver::Error = err.into();
1146        assert!(
1147            matches!(public, waydriver::Error::Timeout(_)),
1148            "expected waydriver::Error::Timeout, got: {public}"
1149        );
1150    }
1151
1152    #[test]
1153    fn test_new_generates_unique_ids() {
1154        let a = MutterCompositor::new();
1155        let b = MutterCompositor::new();
1156        assert_ne!(a.id(), b.id());
1157    }
1158
1159    #[test]
1160    fn test_new_wayland_display_contains_id() {
1161        let c = MutterCompositor::new();
1162        assert!(
1163            c.wayland_display().contains(c.id()),
1164            "display '{}' should contain id '{}'",
1165            c.wayland_display(),
1166            c.id()
1167        );
1168    }
1169
1170    #[test]
1171    fn test_new_runtime_dir_contains_id() {
1172        let c = MutterCompositor::new();
1173        let dir_str = c.runtime_dir().to_str().unwrap();
1174        assert!(
1175            dir_str.contains(c.id()),
1176            "runtime_dir '{}' should contain id '{}'",
1177            dir_str,
1178            c.id()
1179        );
1180    }
1181
1182    /// Regression: session runtime dirs must be flat siblings under one root,
1183    /// never nested inside each other. `waydriver::capture` repoints the
1184    /// process-wide `XDG_RUNTIME_DIR` at the live session's dir after a
1185    /// screenshot/recording; if `new()` re-read that mutated value, each
1186    /// session would nest one level deeper and eventually overflow the
1187    /// AF_UNIX `sun_path` limit, wedging pipewire socket creation. See
1188    /// `HOST_RUNTIME_ROOT`.
1189    #[test]
1190    fn test_session_runtime_dirs_are_siblings_not_nested() {
1191        let a = MutterCompositor::new();
1192        let dir_a = a.runtime_dir().to_path_buf();
1193
1194        // Simulate what a screenshot/recording does: point XDG_RUNTIME_DIR at
1195        // the live session's runtime dir and leave it there.
1196        unsafe {
1197            std::env::set_var("XDG_RUNTIME_DIR", &dir_a);
1198        }
1199
1200        let b = MutterCompositor::new();
1201        let dir_b = b.runtime_dir().to_path_buf();
1202
1203        assert_eq!(
1204            dir_a.parent(),
1205            dir_b.parent(),
1206            "session dirs must share a parent (siblings), got a={dir_a:?} b={dir_b:?}"
1207        );
1208        assert!(
1209            !dir_b.starts_with(&dir_a),
1210            "session B nested inside session A: {dir_b:?}"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_new_wayland_display_prefix() {
1216        let c = MutterCompositor::new();
1217        assert!(c.wayland_display().starts_with("wayland-wd-"));
1218    }
1219
1220    #[test]
1221    fn test_new_runtime_dir_contains_session_prefix() {
1222        let c = MutterCompositor::new();
1223        let dir_str = c.runtime_dir().to_str().unwrap();
1224        assert!(dir_str.contains("wd-session-"));
1225    }
1226
1227    #[test]
1228    fn test_state_returns_none_before_start() {
1229        // `state()` previously panicked when called outside the started
1230        // window. The current contract returns `None` so callers can
1231        // detect the lifecycle without trapping a panic.
1232        let c = MutterCompositor::new();
1233        assert!(c.state().is_none());
1234    }
1235
1236    #[test]
1237    fn test_parse_resolution_accepts_hd() {
1238        assert_eq!(parse_resolution("1920x1080").unwrap(), (1920, 1080));
1239        assert_eq!(parse_resolution("1024x768").unwrap(), (1024, 768));
1240    }
1241
1242    #[test]
1243    fn test_parse_resolution_rejects_garbage() {
1244        for bad in [
1245            "",
1246            "1920",
1247            "1920x",
1248            "x1080",
1249            "0x0",
1250            "1920x0",
1251            "0x1080",
1252            "1920x1080x1",
1253            "abcxdef",
1254            "-1x1080",
1255            "1920 x 1080",
1256        ] {
1257            assert!(parse_resolution(bad).is_err(), "expected error for {bad:?}");
1258        }
1259    }
1260
1261    #[test]
1262    fn test_validate_scale_accepts_common_factors() {
1263        for ok in [0.5, 1.0, 1.25, 1.5, 1.6666, 1.75, 2.0, 3.0, 4.0] {
1264            assert!(validate_scale(ok).is_ok(), "expected {ok} to validate");
1265        }
1266    }
1267
1268    #[test]
1269    fn test_validate_scale_rejects_out_of_range_and_nonfinite() {
1270        for bad in [0.0, 0.49, 4.01, -1.0, f64::NAN, f64::INFINITY] {
1271            assert!(
1272                validate_scale(bad).is_err(),
1273                "expected {bad} to be rejected"
1274            );
1275        }
1276    }
1277
1278    #[test]
1279    fn test_nearest_supported_scale_snaps_to_closest() {
1280        let supported = [1.0, 1.25, 1.5, 1.75, 2.0];
1281        // Exact hits pass straight through.
1282        assert_eq!(nearest_supported_scale(1.5, &supported), 1.5);
1283        assert_eq!(nearest_supported_scale(2.0, &supported), 2.0);
1284        // 1.66 (166%) isn't advertised → nearest is 1.75.
1285        assert_eq!(nearest_supported_scale(1.66, &supported), 1.75);
1286        // 1.6 is closer to 1.5.
1287        assert_eq!(nearest_supported_scale(1.6, &supported), 1.5);
1288    }
1289
1290    #[test]
1291    fn test_nearest_supported_scale_empty_list_returns_request() {
1292        assert_eq!(nearest_supported_scale(1.5, &[]), 1.5);
1293    }
1294
1295    #[test]
1296    fn test_default_same_structure_as_new() {
1297        let c = MutterCompositor::default();
1298        assert!(c.wayland_display().starts_with("wayland-wd-"));
1299        assert!(c.runtime_dir().to_str().unwrap().contains("wd-session-"));
1300    }
1301}