feat: gate semantic effects by client capability

This commit is contained in:
Samuel Onoja committed 2026-09-25 05:08:18 +01:00
1 parent 6d19932a64
commit a6ec721437
9 files changed
+387 -5

No files matched your search

Generated
+1
View File
@@ -422,6 +422,7 @@ dependencies = [
"ferese-core", "ferese-core",
"ferese-ipc", "ferese-ipc",
"ferese-layout", "ferese-layout",
"ferese-protocols",
"libc", "libc",
"serde", "serde",
"serde_json", "serde_json",
+14
View File
@@ -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 The native-client soak runner and its nested/DRM procedure are documented in
[docs/native-soak-testing.md](docs/native-soak-testing.md). [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 Ferese reads `$XDG_CONFIG_HOME/ferese/config.toml`, or
`~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling `~/.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 columns use half the viewport by default. To make every newly opened column use
+1
View File
@@ -11,6 +11,7 @@ ferese-animation = { path = "../crates/ferese-animation" }
ferese-core = { path = "../crates/ferese-core" } ferese-core = { path = "../crates/ferese-core" }
ferese-ipc = { path = "../crates/ferese-ipc" } ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" } ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
libc.workspace = true libc.workspace = true
smithay.workspace = true smithay.workspace = true
serde.workspace = true serde.workspace = true
+34 -2
View File
@@ -4,7 +4,7 @@ use std::ffi::OsString;
use smithay::reexports::calloop::EventLoop; use smithay::reexports::calloop::EventLoop;
use crate::Ferese; use crate::{Ferese, private_client::ClientCapabilities};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackendKind { pub enum BackendKind {
@@ -16,6 +16,7 @@ pub enum BackendKind {
pub struct LaunchConfig { pub struct LaunchConfig {
pub backend: BackendKind, pub backend: BackendKind,
pub client: Vec<OsString>, pub client: Vec<OsString>,
pub client_capabilities: ClientCapabilities,
} }
impl LaunchConfig { impl LaunchConfig {
@@ -33,6 +34,7 @@ impl LaunchConfig {
let mut args = args.into_iter().peekable(); let mut args = args.into_iter().peekable();
let mut requested_backend = None; let mut requested_backend = None;
let mut client = Vec::new(); let mut client = Vec::new();
let mut client_capabilities = ClientCapabilities::default();
while let Some(argument) = args.next() { while let Some(argument) = args.next() {
if argument == "--" { if argument == "--" {
@@ -46,6 +48,10 @@ impl LaunchConfig {
requested_backend = Some(parse_backend(&value)?); requested_backend = Some(parse_backend(&value)?);
continue; continue;
} }
if argument == "--grant-effects" {
client_capabilities.insert(ClientCapabilities::EFFECTS);
continue;
}
if let Some(value) = argument if let Some(value) = argument
.to_str() .to_str()
.and_then(|argument| argument.strip_prefix("--backend=")) .and_then(|argument| argument.strip_prefix("--backend="))
@@ -65,7 +71,11 @@ impl LaunchConfig {
BackendKind::Drm 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.backend, BackendKind::Nested);
assert_eq!(config.client, [OsString::from("foot")]); assert_eq!(config.client, [OsString::from("foot")]);
assert!(config.client_capabilities.is_empty());
} }
#[test] #[test]
@@ -110,6 +121,7 @@ mod tests {
assert_eq!(config.backend, BackendKind::Drm); assert_eq!(config.backend, BackendKind::Drm);
assert!(config.client.is_empty()); assert!(config.client.is_empty());
assert!(config.client_capabilities.is_empty());
} }
#[test] #[test]
@@ -130,6 +142,26 @@ mod tests {
config.client, config.client,
[OsString::from("foot"), OsString::from("--title=Ferese")] [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] #[test]
+224
View File
@@ -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<Option<SemanticRole>>,
}
impl SurfaceEffectsState {
fn new() -> Self {
Self {
attached: AtomicBool::new(false),
generation: AtomicU64::new(0),
role: Mutex::new(None),
}
}
fn set_role(&self, role: Option<SemanticRole>) {
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<Weak<WlSurface>>);
impl SurfaceEffectsUserData {
fn new(surface: WlSurface) -> Self {
Self(Mutex::new(surface.downgrade()))
}
fn surface(&self) -> Option<WlSurface> {
self.0.lock().unwrap().upgrade().ok()
}
}
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(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::<SurfaceEffectsState>()?;
let role = *effects.role.lock().unwrap();
role.map(|role| (role, effects.generation.load(Ordering::Acquire)))
})
}
impl GlobalDispatch<FereseEffectsManagerV1, ()> for Ferese {
fn bind(
_state: &mut Self,
_display: &DisplayHandle,
_client: &Client,
resource: New<FereseEffectsManagerV1>,
_global_data: &(),
data_init: &mut DataInit<'_, Self>,
) {
data_init.init(resource, ());
}
fn can_view(client: Client, _global_data: &()) -> bool {
client
.get_data::<ClientState>()
.is_some_and(|state| state.capabilities.contains(ClientCapabilities::EFFECTS))
}
}
impl Dispatch<FereseEffectsManagerV1, ()> 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::<SurfaceEffectsState>()
.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<FereseSurfaceEffectsV1, SurfaceEffectsUserData> 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<SemanticRole>) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
effects.set_role(role);
}
});
}
fn detach(surface: &WlSurface) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
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);
}
}
+24 -3
View File
@@ -3,11 +3,13 @@
mod backends; mod backends;
mod config; mod config;
mod cursor; mod cursor;
mod effects;
mod grabs; mod grabs;
mod handlers; mod handlers;
mod input; mod input;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod private_client;
mod stacking; mod stacking;
mod state; mod state;
mod window_rules; mod window_rules;
@@ -66,7 +68,7 @@ fn main() -> Result<(), Box<dyn Error>> {
unsafe { std::env::set_var("WAYLAND_DISPLAY", &state.socket_name) }; unsafe { std::env::set_var("WAYLAND_DISPLAY", &state.socket_name) };
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); 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, |_| {})?; event_loop.run(None, &mut state, |_| {})?;
Ok(()) Ok(())
} }
@@ -77,15 +79,34 @@ fn init_logging() {
tracing_subscriber::fmt().with_env_filter(filter).init(); tracing_subscriber::fmt().with_env_filter(filter).init();
} }
fn spawn_client(mut args: Vec<std::ffi::OsString>) { fn spawn_client(
state: &mut Ferese,
mut args: Vec<std::ffi::OsString>,
capabilities: private_client::ClientCapabilities,
) {
if args.is_empty() { if args.is_empty() {
info!("no client requested; pass one after `--`, for example `-- foot`"); info!("no client requested; pass one after `--`, for example `-- foot`");
return; return;
} }
let program = args.remove(0); 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"), Ok(child) => info!(program = ?program, pid = child.id(), "spawned Wayland client"),
Err(error) => warn!(program = ?program, %error, "failed to spawn Wayland client"), Err(error) => warn!(program = ?program, %error, "failed to spawn Wayland client"),
} }
drop(private_connection);
} }
+77
View File
@@ -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<UnixStream> {
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));
}
}
+2
View File
@@ -355,6 +355,7 @@ impl Ferese {
) -> Result<Self, Box<dyn Error>> { ) -> Result<Self, Box<dyn Error>> {
let display_handle = display.handle(); let display_handle = display.handle();
crate::handlers::screencopy::init_global(&display_handle); crate::handlers::screencopy::init_global(&display_handle);
crate::effects::init_global(&display_handle);
let alpha_modifier_state = AlphaModifierState::new::<Self>(&display_handle); let alpha_modifier_state = AlphaModifierState::new::<Self>(&display_handle);
let compositor_state = CompositorState::new::<Self>(&display_handle); let compositor_state = CompositorState::new::<Self>(&display_handle);
let cursor_shape_state = CursorShapeManagerState::new::<Self>(&display_handle); let cursor_shape_state = CursorShapeManagerState::new::<Self>(&display_handle);
@@ -1856,6 +1857,7 @@ fn client_size(window: &Window) -> Option<ClientSize> {
#[derive(Default)] #[derive(Default)]
pub struct ClientState { pub struct ClientState {
pub compositor_state: CompositorClientState, pub compositor_state: CompositorClientState,
pub(crate) capabilities: crate::private_client::ClientCapabilities,
} }
impl ClientData for ClientState { impl ClientData for ClientState {
+10
View File
@@ -19,12 +19,22 @@ const WIDTH: u32 = 640;
const HEIGHT: u32 = 64; const HEIGHT: u32 = 64;
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let expect_hidden = std::env::args_os().any(|argument| argument == "--expect-hidden");
let connection = Connection::connect_to_env()?; let connection = Connection::connect_to_env()?;
let mut queue = connection.new_event_queue(); let mut queue = connection.new_event_queue();
let qh = queue.handle(); let qh = queue.handle();
connection.display().get_registry(&qh, ()); connection.display().get_registry(&qh, ());
let mut state = ProbeState::default(); 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 { while state.effects.is_none() || !state.configured {
queue.blocking_dispatch(&mut state)?; queue.blocking_dispatch(&mut state)?;
state.try_initialize(&qh)?; state.try_initialize(&qh)?;