mod error;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex};
use async_trait::async_trait;
use tokio::process::{Child, Command};
use zbus::zvariant::OwnedValue;
use waydriver::gsettings::{self, GSettingEntry, GSettingsConfig};
use waydriver::{CompositorRuntime, Result};
use crate::error::MutterError;
const DEFAULT_RESOLUTION: &str = "1024x768";
const DEFAULT_SCALE: f64 = 1.0;
const MUTTER_FRACTIONAL_SCALING: &str = "['scale-monitor-framebuffer']";
const MIN_SCALE: f64 = 0.5;
const MAX_SCALE: f64 = 4.0;
const SCALE_SNAP_TOLERANCE: f64 = 0.01;
type DbusProps = HashMap<String, OwnedValue>;
type MonitorMode = (String, i32, i32, f64, f64, Vec<f64>, DbusProps);
type MonitorSpec = (String, String, String, String);
type PhysicalMonitor = (MonitorSpec, Vec<MonitorMode>, DbusProps);
type LogicalMonitor = (i32, i32, f64, u32, bool, Vec<MonitorSpec>, DbusProps);
type CurrentState = (u32, Vec<PhysicalMonitor>, Vec<LogicalMonitor>, DbusProps);
type MonitorAssignment = (String, String, DbusProps);
type LogicalMonitorConfig = (i32, i32, f64, u32, bool, Vec<MonitorAssignment>);
pub struct MutterState {
conn: zbus::Connection,
rd_session_path: String,
rd_session_id: String,
rd_started: Arc<Mutex<bool>>,
runtime_dir: PathBuf,
active_stream_path: Arc<Mutex<Option<String>>>,
}
impl MutterState {
pub fn conn(&self) -> &zbus::Connection {
&self.conn
}
pub fn rd_session_path(&self) -> &str {
&self.rd_session_path
}
pub fn rd_session_id(&self) -> &str {
&self.rd_session_id
}
pub fn runtime_dir(&self) -> &Path {
&self.runtime_dir
}
pub fn rd_started_lock(&self) -> Result<std::sync::MutexGuard<'_, bool>> {
self.rd_started
.lock()
.map_err(|_| waydriver::Error::process("rd_started mutex poisoned"))
}
pub fn active_stream_path_lock(&self) -> Result<std::sync::MutexGuard<'_, Option<String>>> {
self.active_stream_path
.lock()
.map_err(|_| waydriver::Error::process("active_stream_path mutex poisoned"))
}
}
pub struct MutterCompositor {
id: String,
wayland_display: String,
runtime_dir: PathBuf,
mutter_dbus_address: String,
mutter_dbus_pid: Option<u32>,
mutter: Option<Child>,
pipewire: Option<Child>,
wireplumber: Option<Child>,
state: Option<Arc<MutterState>>,
gsettings: GSettingsConfig,
}
static HOST_RUNTIME_ROOT: LazyLock<PathBuf> = LazyLock::new(|| {
let root = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
PathBuf::from(root)
});
pub fn establish_runtime_root() -> &'static std::path::Path {
HOST_RUNTIME_ROOT.as_path()
}
impl MutterCompositor {
pub fn new() -> Self {
let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
let wayland_display = format!("wayland-wd-{}", id);
let runtime_dir = HOST_RUNTIME_ROOT.join(format!("wd-session-{}", id));
Self {
id,
wayland_display,
runtime_dir,
mutter_dbus_address: String::new(),
mutter_dbus_pid: None,
mutter: None,
pipewire: None,
wireplumber: None,
state: None,
gsettings: GSettingsConfig::default(),
}
}
pub fn with_gsettings(mut self, config: GSettingsConfig) -> Self {
self.gsettings = config;
self
}
pub fn state(&self) -> Option<Arc<MutterState>> {
self.state.clone()
}
}
impl Default for MutterCompositor {
fn default() -> Self {
Self::new()
}
}
impl MutterCompositor {
async fn start_inner(
&mut self,
resolution: Option<&str>,
scale: Option<f64>,
) -> std::result::Result<(), MutterError> {
let resolution = resolution.unwrap_or(DEFAULT_RESOLUTION);
parse_resolution(resolution)?;
let scale = scale.unwrap_or(DEFAULT_SCALE);
validate_scale(scale)?;
tracing::info!(
id = self.id,
resolution,
scale,
isolated = self.gsettings.isolated,
"starting mutter compositor"
);
tokio::fs::create_dir_all(&self.runtime_dir).await?;
let runtime_str = self
.runtime_dir
.to_str()
.expect("invariant: runtime_dir built from UTF-8 inputs in new()")
.to_string();
let config_env: Vec<(&str, String)> = if self.gsettings.isolated {
let mut entries = vec![GSettingEntry::new(
"org.gnome.mutter",
"experimental-features",
MUTTER_FRACTIONAL_SCALING,
)];
entries.extend(self.gsettings.initial.iter().cloned());
gsettings::write_keyfile(&self.runtime_dir, &entries)?;
let config_dir = gsettings::config_dir(&self.runtime_dir)
.to_str()
.expect("invariant: config_dir is runtime_dir (UTF-8) + ASCII suffix")
.to_string();
vec![
("XDG_CONFIG_HOME", config_dir),
("GSETTINGS_BACKEND", gsettings::KEYFILE_BACKEND.to_string()),
]
} else {
Vec::new()
};
let dbus_output = Command::new("dbus-launch")
.arg("--sh-syntax")
.output()
.await?;
if !dbus_output.status.success() {
return Err(MutterError::DbusLaunchFailed(
String::from_utf8_lossy(&dbus_output.stderr).into_owned(),
));
}
let dbus_stdout = String::from_utf8_lossy(&dbus_output.stdout);
self.mutter_dbus_address = parse_dbus_address(&dbus_stdout)?;
self.mutter_dbus_pid = Some(parse_dbus_pid(&dbus_stdout)?);
tracing::debug!(id = self.id, mutter_dbus_address = %self.mutter_dbus_address, "private D-Bus for mutter");
let pipewire = Command::new("pipewire")
.env_remove("PIPEWIRE_REMOTE")
.env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
.env("XDG_RUNTIME_DIR", &runtime_str)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|source| MutterError::Spawn {
process: "pipewire",
source,
})?;
self.pipewire = Some(pipewire);
wait_for_pipewire_socket(&runtime_str).await?;
let wireplumber = Command::new("wireplumber")
.env_remove("PIPEWIRE_REMOTE")
.env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
.env("XDG_RUNTIME_DIR", &runtime_str)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|source| MutterError::Spawn {
process: "wireplumber",
source,
})?;
self.wireplumber = Some(wireplumber);
tracing::debug!(id = self.id, "PipeWire + WirePlumber started");
let mutter = Command::new("mutter")
.args([
"--headless",
"--wayland",
"--no-x11",
"--wayland-display",
&self.wayland_display,
"--virtual-monitor",
resolution,
])
.env_remove("PIPEWIRE_REMOTE")
.env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
.env("XDG_RUNTIME_DIR", &runtime_str)
.envs(config_env.iter().map(|(k, v)| (*k, v.as_str())))
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.map_err(|source| MutterError::Spawn {
process: "mutter",
source,
})?;
self.mutter = Some(mutter);
tracing::debug!(id = self.id, wayland_display = %self.wayland_display, "mutter spawned");
wait_for_wayland_socket(&runtime_str, &self.wayland_display).await?;
tracing::debug!(id = self.id, "wayland socket ready");
let mutter_addr: zbus::address::Address = self
.mutter_dbus_address
.as_str()
.try_into()
.map_err(|source: zbus::Error| MutterError::DbusAddressInvalid {
addr: self.mutter_dbus_address.clone(),
source,
})?;
let mutter_conn = zbus::connection::Builder::address(mutter_addr)
.map_err(|source| MutterError::DbusConnect {
stage: "build connection builder",
source,
})?
.build()
.await
.map_err(|source| MutterError::DbusConnect {
stage: "connect",
source,
})?;
let mut rd_reply = None;
for i in 0..50 {
match mutter_conn
.call_method(
Some("org.gnome.Mutter.RemoteDesktop"),
"/org/gnome/Mutter/RemoteDesktop",
Some("org.gnome.Mutter.RemoteDesktop"),
"CreateSession",
&(),
)
.await
{
Ok(reply) => {
rd_reply = Some(reply);
break;
}
Err(e) if i < 49 => {
tracing::debug!(
id = self.id,
attempt = i,
"waiting for RemoteDesktop service: {e}"
);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Err(e) => {
return Err(MutterError::RemoteDesktopCreate(e));
}
}
}
let rd_reply = rd_reply.expect("retry loop sets Some on break or returns Err");
let rd_session_path: zbus::zvariant::OwnedObjectPath = rd_reply
.body()
.deserialize()
.map_err(MutterError::RdSessionPathParse)?;
let rd_session_id_reply = mutter_conn
.call_method(
Some("org.gnome.Mutter.RemoteDesktop"),
rd_session_path.as_str(),
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.gnome.Mutter.RemoteDesktop.Session", "SessionId"),
)
.await
.map_err(MutterError::SessionIdGet)?;
let rd_session_id_body = rd_session_id_reply.body();
let rd_session_id_variant: zbus::zvariant::OwnedValue = rd_session_id_body
.deserialize()
.map_err(MutterError::SessionIdVariantParse)?;
let rd_session_id: String = rd_session_id_variant
.try_into()
.map_err(MutterError::SessionIdNotString)?;
let rd_session_path = rd_session_path.to_string();
tracing::debug!(
id = self.id,
rd_session_path = %rd_session_path,
rd_session_id = %rd_session_id,
"RemoteDesktop session started"
);
if (scale - DEFAULT_SCALE).abs() > f64::EPSILON {
let applied = apply_scale(&mutter_conn, scale, &self.id).await?;
tracing::info!(
id = self.id,
requested = scale,
applied,
"applied logical-monitor scale"
);
}
self.state = Some(Arc::new(MutterState {
conn: mutter_conn,
rd_session_path,
rd_session_id,
rd_started: Arc::new(Mutex::new(false)),
runtime_dir: self.runtime_dir.clone(),
active_stream_path: Arc::new(Mutex::new(None)),
}));
Ok(())
}
}
#[async_trait]
impl CompositorRuntime for MutterCompositor {
async fn start(&mut self, resolution: Option<&str>, scale: Option<f64>) -> Result<()> {
Ok(self.start_inner(resolution, scale).await?)
}
async fn stop(&mut self) -> Result<()> {
tracing::info!(id = self.id, "stopping mutter compositor");
if let Some(state) = &self.state {
let _ = state
.conn()
.call_method(
Some("org.gnome.Mutter.RemoteDesktop"),
state.rd_session_path(),
Some("org.gnome.Mutter.RemoteDesktop.Session"),
"Stop",
&(),
)
.await;
}
self.state = None;
if let Some(mut mutter) = self.mutter.take() {
let _ = mutter.kill().await;
let _ = mutter.wait().await;
}
if let Some(mut wireplumber) = self.wireplumber.take() {
let _ = wireplumber.kill().await;
let _ = wireplumber.wait().await;
}
if let Some(mut pipewire) = self.pipewire.take() {
let _ = pipewire.kill().await;
let _ = pipewire.wait().await;
}
if let Some(pid) = self.mutter_dbus_pid.take() {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
let _ = tokio::fs::remove_dir_all(&self.runtime_dir).await;
tracing::debug!(id = self.id, "mutter compositor stopped");
Ok(())
}
fn id(&self) -> &str {
&self.id
}
fn wayland_display(&self) -> &str {
&self.wayland_display
}
fn runtime_dir(&self) -> &Path {
&self.runtime_dir
}
}
impl Drop for MutterCompositor {
fn drop(&mut self) {
self.state = None;
if let Some(ref mut child) = self.mutter {
let _ = child.start_kill();
}
if let Some(ref mut child) = self.wireplumber {
let _ = child.start_kill();
}
if let Some(ref mut child) = self.pipewire {
let _ = child.start_kill();
}
if let Some(pid) = self.mutter_dbus_pid {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
let _ = std::fs::remove_dir_all(&self.runtime_dir);
}
}
fn parse_dbus_address(output: &str) -> std::result::Result<String, MutterError> {
for line in output.lines() {
if let Some(rest) = line.strip_prefix("DBUS_SESSION_BUS_ADDRESS='") {
if let Some(addr) = rest.strip_suffix("';") {
return Ok(addr.to_string());
}
}
}
Err(MutterError::DbusOutputMissingField {
field: "DBUS_SESSION_BUS_ADDRESS",
})
}
fn parse_dbus_pid(output: &str) -> std::result::Result<u32, MutterError> {
for line in output.lines() {
if let Some(rest) = line.strip_prefix("DBUS_SESSION_BUS_PID=") {
let pid_str = rest.trim_end_matches(';').trim();
return pid_str.parse().map_err(MutterError::DbusPidParse);
}
}
Err(MutterError::DbusOutputMissingField {
field: "DBUS_SESSION_BUS_PID",
})
}
fn parse_resolution(s: &str) -> std::result::Result<(u32, u32), MutterError> {
let invalid = || MutterError::ResolutionInvalid {
value: s.to_string(),
};
let (w, h) = s.split_once('x').ok_or_else(invalid)?;
let parse = |part: &str| -> std::result::Result<u32, MutterError> {
part.parse::<u32>()
.ok()
.filter(|n| *n > 0)
.ok_or_else(invalid)
};
Ok((parse(w)?, parse(h)?))
}
fn validate_scale(scale: f64) -> std::result::Result<(), MutterError> {
if scale.is_finite() && (MIN_SCALE..=MAX_SCALE).contains(&scale) {
Ok(())
} else {
Err(MutterError::ScaleInvalid {
value: scale,
min: MIN_SCALE,
max: MAX_SCALE,
})
}
}
fn nearest_supported_scale(requested: f64, supported: &[f64]) -> f64 {
supported
.iter()
.copied()
.min_by(|a, b| {
let da = (a - requested).abs();
let db = (b - requested).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(requested)
}
async fn apply_scale(
conn: &zbus::Connection,
requested: f64,
id: &str,
) -> std::result::Result<f64, MutterError> {
let state_reply = conn
.call_method(
Some("org.gnome.Mutter.DisplayConfig"),
"/org/gnome/Mutter/DisplayConfig",
Some("org.gnome.Mutter.DisplayConfig"),
"GetCurrentState",
&(),
)
.await
.map_err(|source| MutterError::DisplayConfigState {
stage: "call",
source,
})?;
let state_body = state_reply.body();
let (serial, monitors, _logical, _props): CurrentState =
state_body
.deserialize()
.map_err(|source| MutterError::DisplayConfigState {
stage: "deserialize",
source,
})?;
let (spec, modes, _mprops) = monitors
.into_iter()
.next()
.ok_or(MutterError::DisplayConfigNoMonitor)?;
let connector = spec.0;
let (mode_id, _w, _h, _refresh, _preferred, supported, _modeprops) =
modes
.into_iter()
.next()
.ok_or(MutterError::DisplayConfigNoMonitor)?;
let applied = nearest_supported_scale(requested, &supported);
if (applied - requested).abs() > SCALE_SNAP_TOLERANCE {
tracing::warn!(
id,
requested,
applied,
supported = ?supported,
"requested scale not advertised by mutter; snapped to nearest supported"
);
}
let logical: LogicalMonitorConfig = (
0,
0,
applied,
0,
true,
vec![(connector, mode_id, DbusProps::new())],
);
conn.call_method(
Some("org.gnome.Mutter.DisplayConfig"),
"/org/gnome/Mutter/DisplayConfig",
Some("org.gnome.Mutter.DisplayConfig"),
"ApplyMonitorsConfig",
&(serial, 1u32, vec![logical], DbusProps::new()),
)
.await
.map_err(|source| MutterError::DisplayConfigApply {
scale: applied,
source,
})?;
Ok(applied)
}
async fn wait_for_wayland_socket(
runtime_dir: &str,
display: &str,
) -> std::result::Result<(), MutterError> {
let socket_path = PathBuf::from(runtime_dir).join(display);
for _ in 0..50 {
if socket_path.exists() {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Err(MutterError::WaylandSocketTimeout {
socket: socket_path.display().to_string(),
})
}
async fn wait_for_pipewire_socket(runtime_dir: &str) -> std::result::Result<(), MutterError> {
let socket_path = PathBuf::from(runtime_dir).join("pipewire-0");
for _ in 0..50 {
if socket_path.exists() {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Err(MutterError::PipewireSocketTimeout {
socket: socket_path.display().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "requires a live mutter/pipewire/dbus runtime stack"]
async fn applies_fractional_scale_against_real_mutter() {
let mut compositor = MutterCompositor::new();
compositor
.start(Some("1920x1080"), Some(1.5))
.await
.expect("compositor should start");
let state = compositor.state().expect("state present after start");
let reply = state
.conn()
.call_method(
Some("org.gnome.Mutter.DisplayConfig"),
"/org/gnome/Mutter/DisplayConfig",
Some("org.gnome.Mutter.DisplayConfig"),
"GetCurrentState",
&(),
)
.await
.expect("GetCurrentState should succeed");
let body = reply.body();
let (_serial, _monitors, logical, _props): CurrentState = body
.deserialize()
.expect("GetCurrentState body should deserialize");
let applied = logical.first().expect("at least one logical monitor").2;
compositor.stop().await.expect("compositor should stop");
assert!(
(applied - 1.5).abs() < 0.01,
"expected logical scale ~1.5 (fractional scaling enabled via keyfile), got {applied}"
);
}
#[test]
fn test_parse_dbus_address_valid() {
let output = "DBUS_SESSION_BUS_ADDRESS='unix:abstract=/tmp/dbus-XXX,guid=abc123';\nDBUS_SESSION_BUS_PID=12345;\n";
let addr = parse_dbus_address(output).unwrap();
assert_eq!(addr, "unix:abstract=/tmp/dbus-XXX,guid=abc123");
}
#[test]
fn test_parse_dbus_address_missing() {
let output = "DBUS_SESSION_BUS_PID=12345;\n";
assert!(parse_dbus_address(output).is_err());
}
#[test]
fn test_parse_dbus_pid_valid() {
let output = "DBUS_SESSION_BUS_ADDRESS='unix:abstract=/tmp/dbus-XXX,guid=abc123';\nDBUS_SESSION_BUS_PID=12345;\n";
let pid = parse_dbus_pid(output).unwrap();
assert_eq!(pid, 12345);
}
#[test]
fn test_parse_dbus_pid_missing() {
let output = "DBUS_SESSION_BUS_ADDRESS='unix:abstract=/tmp/dbus-XXX,guid=abc123';\n";
assert!(parse_dbus_pid(output).is_err());
}
#[test]
fn test_parse_dbus_pid_invalid() {
let output = "DBUS_SESSION_BUS_PID=notanumber;\n";
assert!(parse_dbus_pid(output).is_err());
}
#[tokio::test]
async fn test_wait_for_socket_found() {
let dir = tempfile::tempdir().unwrap();
let runtime_dir = dir.path().to_str().unwrap().to_string();
let display = "wayland-test-99";
std::fs::File::create(dir.path().join(display)).unwrap();
wait_for_wayland_socket(&runtime_dir, display)
.await
.unwrap();
}
#[tokio::test]
async fn test_wait_for_pipewire_socket_found() {
let dir = tempfile::tempdir().unwrap();
let runtime_dir = dir.path().to_str().unwrap().to_string();
std::fs::File::create(dir.path().join("pipewire-0")).unwrap();
wait_for_pipewire_socket(&runtime_dir).await.unwrap();
}
#[tokio::test]
async fn test_wait_for_pipewire_socket_timeout() {
let dir = tempfile::tempdir().unwrap();
let runtime_dir = dir.path().to_str().unwrap().to_string();
let err = wait_for_pipewire_socket(&runtime_dir).await.unwrap_err();
assert!(
matches!(err, MutterError::PipewireSocketTimeout { .. }),
"expected PipewireSocketTimeout, got: {err}"
);
let public: waydriver::Error = err.into();
assert!(
matches!(public, waydriver::Error::Timeout(_)),
"expected waydriver::Error::Timeout, got: {public}"
);
}
#[tokio::test]
async fn test_wait_for_socket_timeout() {
let dir = tempfile::tempdir().unwrap();
let runtime_dir = dir.path().to_str().unwrap().to_string();
let display = "wayland-nonexistent-0";
let err = wait_for_wayland_socket(&runtime_dir, display)
.await
.unwrap_err();
assert!(
matches!(err, MutterError::WaylandSocketTimeout { .. }),
"expected WaylandSocketTimeout, got: {err}"
);
let public: waydriver::Error = err.into();
assert!(
matches!(public, waydriver::Error::Timeout(_)),
"expected waydriver::Error::Timeout, got: {public}"
);
}
#[test]
fn test_new_generates_unique_ids() {
let a = MutterCompositor::new();
let b = MutterCompositor::new();
assert_ne!(a.id(), b.id());
}
#[test]
fn test_new_wayland_display_contains_id() {
let c = MutterCompositor::new();
assert!(
c.wayland_display().contains(c.id()),
"display '{}' should contain id '{}'",
c.wayland_display(),
c.id()
);
}
#[test]
fn test_new_runtime_dir_contains_id() {
let c = MutterCompositor::new();
let dir_str = c.runtime_dir().to_str().unwrap();
assert!(
dir_str.contains(c.id()),
"runtime_dir '{}' should contain id '{}'",
dir_str,
c.id()
);
}
#[test]
fn test_session_runtime_dirs_are_siblings_not_nested() {
let a = MutterCompositor::new();
let dir_a = a.runtime_dir().to_path_buf();
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", &dir_a);
}
let b = MutterCompositor::new();
let dir_b = b.runtime_dir().to_path_buf();
assert_eq!(
dir_a.parent(),
dir_b.parent(),
"session dirs must share a parent (siblings), got a={dir_a:?} b={dir_b:?}"
);
assert!(
!dir_b.starts_with(&dir_a),
"session B nested inside session A: {dir_b:?}"
);
}
#[test]
fn test_new_wayland_display_prefix() {
let c = MutterCompositor::new();
assert!(c.wayland_display().starts_with("wayland-wd-"));
}
#[test]
fn test_new_runtime_dir_contains_session_prefix() {
let c = MutterCompositor::new();
let dir_str = c.runtime_dir().to_str().unwrap();
assert!(dir_str.contains("wd-session-"));
}
#[test]
fn test_state_returns_none_before_start() {
let c = MutterCompositor::new();
assert!(c.state().is_none());
}
#[test]
fn test_parse_resolution_accepts_hd() {
assert_eq!(parse_resolution("1920x1080").unwrap(), (1920, 1080));
assert_eq!(parse_resolution("1024x768").unwrap(), (1024, 768));
}
#[test]
fn test_parse_resolution_rejects_garbage() {
for bad in [
"",
"1920",
"1920x",
"x1080",
"0x0",
"1920x0",
"0x1080",
"1920x1080x1",
"abcxdef",
"-1x1080",
"1920 x 1080",
] {
assert!(parse_resolution(bad).is_err(), "expected error for {bad:?}");
}
}
#[test]
fn test_validate_scale_accepts_common_factors() {
for ok in [0.5, 1.0, 1.25, 1.5, 1.6666, 1.75, 2.0, 3.0, 4.0] {
assert!(validate_scale(ok).is_ok(), "expected {ok} to validate");
}
}
#[test]
fn test_validate_scale_rejects_out_of_range_and_nonfinite() {
for bad in [0.0, 0.49, 4.01, -1.0, f64::NAN, f64::INFINITY] {
assert!(
validate_scale(bad).is_err(),
"expected {bad} to be rejected"
);
}
}
#[test]
fn test_nearest_supported_scale_snaps_to_closest() {
let supported = [1.0, 1.25, 1.5, 1.75, 2.0];
assert_eq!(nearest_supported_scale(1.5, &supported), 1.5);
assert_eq!(nearest_supported_scale(2.0, &supported), 2.0);
assert_eq!(nearest_supported_scale(1.66, &supported), 1.75);
assert_eq!(nearest_supported_scale(1.6, &supported), 1.5);
}
#[test]
fn test_nearest_supported_scale_empty_list_returns_request() {
assert_eq!(nearest_supported_scale(1.5, &[]), 1.5);
}
#[test]
fn test_default_same_structure_as_new() {
let c = MutterCompositor::default();
assert!(c.wayland_display().starts_with("wayland-wd-"));
assert!(c.runtime_dir().to_str().unwrap().contains("wd-session-"));
}
}