From a6ec721437a4663ba99c9a1636bee3269d4fde6a Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 05:08:18 +0100 Subject: [PATCH] feat: gate semantic effects by client capability --- Cargo.lock | 1 + README.md | 14 ++ compositor/Cargo.toml | 1 + compositor/src/backends/mod.rs | 36 +++- compositor/src/effects.rs | 224 +++++++++++++++++++++++++ compositor/src/main.rs | 27 ++- compositor/src/private_client.rs | 77 +++++++++ compositor/src/state.rs | 2 + tools/ferese-effects-probe/src/main.rs | 10 ++ 9 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 compositor/src/effects.rs create mode 100644 compositor/src/private_client.rs diff --git a/Cargo.lock b/Cargo.lock index da6c1a9..0acf0c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -422,6 +422,7 @@ dependencies = [ "ferese-core", "ferese-ipc", "ferese-layout", + "ferese-protocols", "libc", "serde", "serde_json", diff --git a/README.md b/README.md index f19537d..c0d5cb8 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,20 @@ pixels, average and longest render time, and DRM missed-deadline totals. The native-client soak runner and its nested/DRM procedure are documented in [docs/native-soak-testing.md](docs/native-soak-testing.md). +The M7 semantic-effects probe must use a compositor-created private connection. +Build it, then grant only the `effects` capability to that launched process: + +```bash +cargo build -p ferese-effects-probe +cargo run -p ferese -- --grant-effects -- target/debug/ferese-effects-probe +``` + +To verify that the private global is absent from the public Wayland socket: + +```bash +cargo run -p ferese -- target/debug/ferese-effects-probe --expect-hidden +``` + Ferese reads `$XDG_CONFIG_HOME/ferese/config.toml`, or `~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling columns use half the viewport by default. To make every newly opened column use diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 2f7145d..3ee3d60 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -11,6 +11,7 @@ ferese-animation = { path = "../crates/ferese-animation" } ferese-core = { path = "../crates/ferese-core" } ferese-ipc = { path = "../crates/ferese-ipc" } ferese-layout = { path = "../crates/ferese-layout" } +ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } libc.workspace = true smithay.workspace = true serde.workspace = true diff --git a/compositor/src/backends/mod.rs b/compositor/src/backends/mod.rs index 8a165ef..8330e20 100644 --- a/compositor/src/backends/mod.rs +++ b/compositor/src/backends/mod.rs @@ -4,7 +4,7 @@ use std::ffi::OsString; use smithay::reexports::calloop::EventLoop; -use crate::Ferese; +use crate::{Ferese, private_client::ClientCapabilities}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BackendKind { @@ -16,6 +16,7 @@ pub enum BackendKind { pub struct LaunchConfig { pub backend: BackendKind, pub client: Vec, + pub client_capabilities: ClientCapabilities, } impl LaunchConfig { @@ -33,6 +34,7 @@ impl LaunchConfig { let mut args = args.into_iter().peekable(); let mut requested_backend = None; let mut client = Vec::new(); + let mut client_capabilities = ClientCapabilities::default(); while let Some(argument) = args.next() { if argument == "--" { @@ -46,6 +48,10 @@ impl LaunchConfig { requested_backend = Some(parse_backend(&value)?); continue; } + if argument == "--grant-effects" { + client_capabilities.insert(ClientCapabilities::EFFECTS); + continue; + } if let Some(value) = argument .to_str() .and_then(|argument| argument.strip_prefix("--backend=")) @@ -65,7 +71,11 @@ impl LaunchConfig { BackendKind::Drm }); - Ok(Self { backend, client }) + Ok(Self { + backend, + client, + client_capabilities, + }) } } @@ -102,6 +112,7 @@ mod tests { assert_eq!(config.backend, BackendKind::Nested); assert_eq!(config.client, [OsString::from("foot")]); + assert!(config.client_capabilities.is_empty()); } #[test] @@ -110,6 +121,7 @@ mod tests { assert_eq!(config.backend, BackendKind::Drm); assert!(config.client.is_empty()); + assert!(config.client_capabilities.is_empty()); } #[test] @@ -130,6 +142,26 @@ mod tests { config.client, [OsString::from("foot"), OsString::from("--title=Ferese")] ); + assert!(config.client_capabilities.is_empty()); + } + + #[test] + fn explicitly_grants_effects_to_the_launched_private_client() { + let config = LaunchConfig::parse( + [ + OsString::from("--grant-effects"), + OsString::from("ferese-effects-probe"), + ], + true, + ) + .unwrap(); + + assert_eq!(config.client, [OsString::from("ferese-effects-probe")]); + assert!( + config + .client_capabilities + .contains(ClientCapabilities::EFFECTS) + ); } #[test] diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs new file mode 100644 index 0000000..39a5dc6 --- /dev/null +++ b/compositor/src/effects.rs @@ -0,0 +1,224 @@ +use std::sync::{ + Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +use ferese_protocols::effects::v1::server::{ + ferese_effects_manager_v1::{self, FereseEffectsManagerV1}, + ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, +}; +use smithay::{ + reexports::wayland_server::{ + Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, WEnum, Weak, + backend::ClientId, protocol::wl_surface::WlSurface, + }, + wayland::compositor::with_states, +}; + +use crate::{Ferese, private_client::ClientCapabilities, state::ClientState}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SemanticRole { + Panel, + PanelElevated, + Popover, + Menu, + Notification, + Hud, + Modal, +} + +#[derive(Debug)] +struct SurfaceEffectsState { + attached: AtomicBool, + generation: AtomicU64, + role: Mutex>, +} + +impl SurfaceEffectsState { + fn new() -> Self { + Self { + attached: AtomicBool::new(false), + generation: AtomicU64::new(0), + role: Mutex::new(None), + } + } + + fn set_role(&self, role: Option) { + let mut current = self.role.lock().unwrap(); + if *current != role { + *current = role; + self.generation.fetch_add(1, Ordering::Release); + } + } +} + +#[derive(Debug)] +pub(crate) struct SurfaceEffectsUserData(Mutex>); + +impl SurfaceEffectsUserData { + fn new(surface: WlSurface) -> Self { + Self(Mutex::new(surface.downgrade())) + } + + fn surface(&self) -> Option { + self.0.lock().unwrap().upgrade().ok() + } +} + +pub(crate) fn init_global(display: &DisplayHandle) { + display.create_global::(1, ()); +} + +// Consumed by the material renderer in the next M7 slice. +#[allow(dead_code)] +pub(crate) fn surface_role(surface: &WlSurface) -> Option<(SemanticRole, u64)> { + with_states(surface, |states| { + let effects = states.data_map.get::()?; + let role = *effects.role.lock().unwrap(); + role.map(|role| (role, effects.generation.load(Ordering::Acquire))) + }) +} + +impl GlobalDispatch for Ferese { + fn bind( + _state: &mut Self, + _display: &DisplayHandle, + _client: &Client, + resource: New, + _global_data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } + + fn can_view(client: Client, _global_data: &()) -> bool { + client + .get_data::() + .is_some_and(|state| state.capabilities.contains(ClientCapabilities::EFFECTS)) + } +} + +impl Dispatch for Ferese { + fn request( + _state: &mut Self, + _client: &Client, + manager: &FereseEffectsManagerV1, + request: ferese_effects_manager_v1::Request, + _data: &(), + _display: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + ferese_effects_manager_v1::Request::GetSurfaceEffects { id, surface } => { + let already_attached = with_states(&surface, |states| { + states + .data_map + .insert_if_missing_threadsafe(SurfaceEffectsState::new); + states + .data_map + .get::() + .unwrap() + .attached + .swap(true, Ordering::AcqRel) + }); + + if already_attached { + manager.post_error( + ferese_effects_manager_v1::Error::AlreadyConstructed, + "wl_surface already has a Ferese effects object", + ); + } else { + data_init.init(id, SurfaceEffectsUserData::new(surface)); + } + } + ferese_effects_manager_v1::Request::Destroy => {} + _ => unreachable!(), + } + } +} + +impl Dispatch for Ferese { + fn request( + _state: &mut Self, + _client: &Client, + _resource: &FereseSurfaceEffectsV1, + request: ferese_surface_effects_v1::Request, + data: &SurfaceEffectsUserData, + _display: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let Some(surface) = data.surface() else { + return; + }; + + match request { + ferese_surface_effects_v1::Request::SetRole { role } => { + let role = match role { + WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel, + WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => { + SemanticRole::PanelElevated + } + WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover, + WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu, + WEnum::Value(ferese_surface_effects_v1::Role::Notification) => { + SemanticRole::Notification + } + WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud, + WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal, + WEnum::Unknown(_) | WEnum::Value(_) => return, + }; + set_surface_role(&surface, Some(role)); + } + ferese_surface_effects_v1::Request::ClearRole => set_surface_role(&surface, None), + ferese_surface_effects_v1::Request::Destroy => detach(&surface), + _ => unreachable!(), + } + } + + fn destroyed( + _state: &mut Self, + _client: ClientId, + _resource: &FereseSurfaceEffectsV1, + data: &SurfaceEffectsUserData, + ) { + if let Some(surface) = data.surface() { + detach(&surface); + } + } +} + +fn set_surface_role(surface: &WlSurface, role: Option) { + with_states(surface, |states| { + if let Some(effects) = states.data_map.get::() { + effects.set_role(role); + } + }); +} + +fn detach(surface: &WlSurface) { + with_states(surface, |states| { + if let Some(effects) = states.data_map.get::() { + effects.set_role(None); + effects.attached.store(false, Ordering::Release); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_changes_advance_generation_only_when_material_changes() { + let effects = SurfaceEffectsState::new(); + effects.set_role(Some(SemanticRole::Panel)); + assert_eq!(effects.generation.load(Ordering::Acquire), 1); + + effects.set_role(Some(SemanticRole::Panel)); + assert_eq!(effects.generation.load(Ordering::Acquire), 1); + + effects.set_role(Some(SemanticRole::Popover)); + assert_eq!(effects.generation.load(Ordering::Acquire), 2); + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 2e72f0b..6375aad 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -3,11 +3,13 @@ mod backends; mod config; mod cursor; +mod effects; mod grabs; mod handlers; mod input; mod ipc; mod metrics; +mod private_client; mod stacking; mod state; mod window_rules; @@ -66,7 +68,7 @@ fn main() -> Result<(), Box> { unsafe { std::env::set_var("WAYLAND_DISPLAY", &state.socket_name) }; info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); - spawn_client(launch.client); + spawn_client(&mut state, launch.client, launch.client_capabilities); event_loop.run(None, &mut state, |_| {})?; Ok(()) } @@ -77,15 +79,34 @@ fn init_logging() { tracing_subscriber::fmt().with_env_filter(filter).init(); } -fn spawn_client(mut args: Vec) { +fn spawn_client( + state: &mut Ferese, + mut args: Vec, + capabilities: private_client::ClientCapabilities, +) { if args.is_empty() { info!("no client requested; pass one after `--`, for example `-- foot`"); return; } let program = args.remove(0); - match Command::new(&program).args(args).spawn() { + let mut command = Command::new(&program); + command.args(args); + let private_connection = if capabilities.is_empty() { + None + } else { + match private_client::prepare_command(state, &mut command, capabilities) { + Ok(connection) => Some(connection), + Err(error) => { + warn!(program = ?program, %error, "failed to create private Wayland connection"); + return; + } + } + }; + + match command.spawn() { Ok(child) => info!(program = ?program, pid = child.id(), "spawned Wayland client"), Err(error) => warn!(program = ?program, %error, "failed to spawn Wayland client"), } + drop(private_connection); } diff --git a/compositor/src/private_client.rs b/compositor/src/private_client.rs new file mode 100644 index 0000000..ae76853 --- /dev/null +++ b/compositor/src/private_client.rs @@ -0,0 +1,77 @@ +use std::{ + io, + os::{ + fd::AsRawFd, + unix::{net::UnixStream, process::CommandExt}, + }, + process::Command, + sync::Arc, +}; + +use crate::{Ferese, state::ClientState}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ClientCapabilities(u8); + +impl ClientCapabilities { + pub(crate) const EFFECTS: Self = Self(1 << 0); + + pub(crate) fn contains(self, capability: Self) -> bool { + self.0 & capability.0 == capability.0 + } + + pub(crate) fn insert(&mut self, capability: Self) { + self.0 |= capability.0; + } + + pub(crate) fn is_empty(self) -> bool { + self.0 == 0 + } +} + +pub(crate) fn prepare_command( + state: &mut Ferese, + command: &mut Command, + capabilities: ClientCapabilities, +) -> io::Result { + let (server, client) = UnixStream::pair()?; + state.display_handle.insert_client( + server, + Arc::new(ClientState { + capabilities, + ..ClientState::default() + }), + )?; + + let client_fd = client.as_raw_fd(); + command.env_remove("WAYLAND_DISPLAY"); + command.env("WAYLAND_SOCKET", client_fd.to_string()); + // SAFETY: fcntl(F_SETFD) is async-signal-safe. The closure only clears + // CLOEXEC on the socket already owned by this Command's parent process. + unsafe { + command.pre_exec(move || { + if libc::fcntl(client_fd, libc::F_SETFD, 0) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + + Ok(client) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capabilities_are_explicit_and_independent() { + let mut capabilities = ClientCapabilities::default(); + assert!(capabilities.is_empty()); + assert!(!capabilities.contains(ClientCapabilities::EFFECTS)); + + capabilities.insert(ClientCapabilities::EFFECTS); + assert!(!capabilities.is_empty()); + assert!(capabilities.contains(ClientCapabilities::EFFECTS)); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 4b5d586..8f5ac8b 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -355,6 +355,7 @@ impl Ferese { ) -> Result> { let display_handle = display.handle(); crate::handlers::screencopy::init_global(&display_handle); + crate::effects::init_global(&display_handle); let alpha_modifier_state = AlphaModifierState::new::(&display_handle); let compositor_state = CompositorState::new::(&display_handle); let cursor_shape_state = CursorShapeManagerState::new::(&display_handle); @@ -1856,6 +1857,7 @@ fn client_size(window: &Window) -> Option { #[derive(Default)] pub struct ClientState { pub compositor_state: CompositorClientState, + pub(crate) capabilities: crate::private_client::ClientCapabilities, } impl ClientData for ClientState { diff --git a/tools/ferese-effects-probe/src/main.rs b/tools/ferese-effects-probe/src/main.rs index 17592b0..c89b8bc 100644 --- a/tools/ferese-effects-probe/src/main.rs +++ b/tools/ferese-effects-probe/src/main.rs @@ -19,12 +19,22 @@ const WIDTH: u32 = 640; const HEIGHT: u32 = 64; fn main() -> Result<(), Box> { + let expect_hidden = std::env::args_os().any(|argument| argument == "--expect-hidden"); let connection = Connection::connect_to_env()?; let mut queue = connection.new_event_queue(); let qh = queue.handle(); connection.display().get_registry(&qh, ()); let mut state = ProbeState::default(); + if expect_hidden { + queue.roundtrip(&mut state)?; + if state.effects_manager.is_some() { + return Err("public client could see the private effects global".into()); + } + println!("PASS public client cannot see ferese_effects_manager_v1"); + return Ok(()); + } + while state.effects.is_none() || !state.configured { queue.blocking_dispatch(&mut state)?; state.try_initialize(&qh)?;