general improvement and bug fixes, cursor scaling, DMA-BUF routing and nested backend recovery

ignore performance-baseline.md

more improvement and code quality fixes
This commit is contained in:
Samuel Onoja committed 2026-10-04 18:33:05 +01:00
1 parent 405c74af9f
commit 73f21e20ce
23 files changed
+2248 -418

No files matched your search

@@ -44,3 +44,4 @@ jobs:
run: | run: |
cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture
+29 -1
View File
@@ -107,6 +107,16 @@ impl DirectBackendState {
self.active && !self.topology.reconciling && !self.lid.pending() self.active && !self.topology.reconciling && !self.lid.pending()
} }
pub(crate) fn process_dmabuf_imports(&mut self, imports: &mut crate::dmabuf_imports::ImportManager) {
if !self.active || self.topology.reconciling {
return;
}
for device in self.devices.values_mut().filter(|device| device.drm.is_active()) {
imports.process(device.dmabuf_global, &mut device.renderer, Some(device.render_node));
}
}
pub(crate) fn scene_output(&self, state: &Ferese, output: &Output) -> Output { pub(crate) fn scene_output(&self, state: &Ferese, output: &Output) -> Output {
let source = self let source = self
.devices .devices
@@ -950,6 +960,7 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
let dmabuf_global = state let dmabuf_global = state
.dmabuf_state .dmabuf_state
.create_global_with_default_feedback::<Ferese>(&display_handle, &feedback); .create_global_with_default_feedback::<Ferese>(&display_handle, &feedback);
state.dmabuf_imports.register(dmabuf_global);
state state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -1088,6 +1099,23 @@ fn cursor_affected_outputs(state: &Ferese) -> Vec<Output> {
.collect() .collect()
} }
pub(crate) fn cursor_animation_visible(state: &Ferese) -> bool {
if let Some(backend) = state.direct_backend.as_ref() {
backend.can_render()
&& backend.devices.values().any(|device| {
device
.outputs
.values()
.any(|output| output.power.can_render() && redraw::cursor_on_output(state, &output.output))
})
} else {
state
.space
.outputs()
.any(|output| redraw::cursor_on_output(state, output))
}
}
#[track_caller] #[track_caller]
pub(crate) fn render_surface( pub(crate) fn render_surface(
state: &mut Ferese, state: &mut Ferese,
@@ -1272,7 +1300,6 @@ fn render_output(
let frame = state.sample_frame(&scene_output, horizon); let frame = state.sample_frame(&scene_output, horizon);
let rendered = { let rendered = {
(|| -> Result<bool, Box<dyn Error>> { (|| -> Result<bool, Box<dyn Error>> {
state.process_dmabuf_imports(&mut device.renderer, Some(device.render_node));
let elements = if output.mirror_source.is_some() { let elements = if output.mirror_source.is_some() {
mirror::elements(state, &mut device.renderer, &scene_output, &frame, &mut output)? mirror::elements(state, &mut device.renderer, &scene_output, &frame, &mut output)?
} else { } else {
@@ -1562,6 +1589,7 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
}; };
state.loop_handle.remove(device.notifier); state.loop_handle.remove(device.notifier);
state.dmabuf_imports.remove(device.dmabuf_global);
state state
.dmabuf_state .dmabuf_state
.disable_global::<Ferese>(&state.display_handle, &device.dmabuf_global); .disable_global::<Ferese>(&state.display_handle, &device.dmabuf_global);
+1 -1
View File
@@ -67,7 +67,7 @@ pub(super) fn capture_mirror(
} }
} }
fn capture_request<T, E: Display>( pub(crate) fn capture_request<T, E: Display>(
state: &mut Ferese, state: &mut Ferese,
output: &Output, output: &Output,
include_cursor: bool, include_cursor: bool,
+6 -17
View File
@@ -123,22 +123,11 @@ pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::
.to_f64() .to_f64()
} }
CursorImageStatus::Named(icon) => { CursorImageStatus::Named(_) => {
let Some(cursor) = state.named_cursors.get(icon) else { let Some(cursor) = state.named_cursor_frame() else {
return false; return false;
}; };
let scale = output.current_scale().fractional_scale(); cursor.logical_rect(location)
// Named buffers use buffer scale 1: Smithay scales their size
// with the output, while the hotspot is subtracted physically.
let size = cursor.size;
Rectangle::<f64, Logical>::new(
(
location.x - f64::from(cursor.hotspot.x) / scale,
location.y - f64::from(cursor.hotspot.y) / scale,
)
.into(),
(f64::from(size.w), f64::from(size.h)).into(),
)
} }
CursorImageStatus::Hidden => return false, CursorImageStatus::Hidden => return false,
@@ -353,9 +342,9 @@ mod tests {
); );
assert!(cursor_on_output(&state, &outputs[0])); assert!(cursor_on_output(&state, &outputs[0]));
assert!(cursor_on_output(&state, &outputs[1]), "cursor image straddles the seam"); assert!(cursor_on_output(&state, &outputs[1]), "cursor image straddles the seam");
if let CursorImageStatus::Named(icon) = &state.cursor_status { if let Some(cursor) = state.named_cursor_frame() {
let cursor = state.named_cursors.get(icon).unwrap(); let x = 1920.0 - f64::from(cursor.size.w) / f64::from(cursor.buffer_scale) * 0.75
let x = 1920.0 - f64::from(cursor.size.w) * 0.75 + f64::from(cursor.hotspot.x) / 2.0; + f64::from(cursor.hotspot.x) / f64::from(cursor.buffer_scale);
pointer.motion( pointer.motion(
&mut state, &mut state,
None, None,
+413 -71
View File
@@ -1,49 +1,328 @@
use std::fs; use std::fs::File;
use std::sync::OnceLock; use std::io::Read;
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::{Duration, Instant};
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::MemoryRenderBuffer; use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
use smithay::input::pointer::CursorIcon; use smithay::input::pointer::{CursorIcon, CursorImageStatus};
use smithay::utils::{Buffer, Point, Size, Transform}; use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{LoopSignal, RegistrationToken};
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transform};
use xcursor::CursorTheme; use xcursor::CursorTheme;
use xcursor::parser::Image;
const DEFAULT_CURSOR_SIZE: u32 = 24; const DEFAULT_CURSOR_SIZE: u32 = 24;
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
const MAX_FRAMES: usize = 256;
const MAX_PIXELS: usize = 2 * 1024 * 1024;
const MAX_CACHE_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct NamedCursor { pub(crate) struct CursorFrame {
pub buffer: MemoryRenderBuffer, pub buffer: MemoryRenderBuffer,
pub hotspot: Point<i32, Buffer>, pub hotspot: Point<i32, Buffer>,
pub size: Size<i32, Buffer>, pub size: Size<i32, Buffer>,
pub buffer_scale: i32,
delay: Duration,
} }
pub(crate) fn cursor_theme() -> CursorTheme { impl CursorFrame {
let name = std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into()); pub(crate) fn logical_rect(&self, pointer: Point<f64, Logical>) -> Rectangle<f64, Logical> {
Rectangle::new(
pointer
- Point::from((
f64::from(self.hotspot.x) / f64::from(self.buffer_scale),
f64::from(self.hotspot.y) / f64::from(self.buffer_scale),
)),
(
f64::from(self.size.w) / f64::from(self.buffer_scale),
f64::from(self.size.h) / f64::from(self.buffer_scale),
)
.into(),
)
}
CursorTheme::load(&name) pub(crate) fn physical_location(&self, pointer: Point<f64, Logical>, scale: f64) -> Point<f64, Physical> {
} self.logical_rect(pointer).loc.to_physical(scale)
pub(crate) fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon) -> NamedCursor {
let requested_size = cursor_size();
let image = cursor_names(icon)
.iter()
.find_map(|name| load_image(theme, name, requested_size));
match image {
Some(image) => cursor_from_image(image),
None => fallback_cursor(),
} }
} }
fn cursor_size() -> u32 { #[derive(Debug, Clone)]
static SIZE: OnceLock<u32> = OnceLock::new(); pub(crate) struct NamedCursor {
frames: Arc<[CursorFrame]>,
bytes: usize,
requested_scale: i32,
}
*SIZE.get_or_init(|| { impl NamedCursor {
std::env::var("XCURSOR_SIZE") fn sample(&self, elapsed: Duration) -> (usize, Option<Duration>) {
if self.frames.len() <= 1 {
return (0, None);
}
let period: Duration = self.frames.iter().map(|frame| frame.delay).sum();
let mut offset = elapsed.as_nanos() % period.as_nanos();
for (index, frame) in self.frames.iter().enumerate() {
if offset < frame.delay.as_nanos() {
return (index, Some(frame.delay - Duration::from_nanos(offset as u64)));
}
offset -= frame.delay.as_nanos();
}
unreachable!("cursor phase is within the bounded, nonzero period")
}
}
#[derive(Default)]
struct Requests {
pending: Option<(CursorIcon, i32)>,
in_flight: Option<(CursorIcon, i32)>,
closed: bool,
}
struct Loaded {
icon: CursorIcon,
scale: i32,
cursor: Option<NamedCursor>,
}
pub(crate) struct Loader {
shared: Arc<(Mutex<Requests>, Condvar)>,
results: mpsc::Receiver<Loaded>,
}
impl Loader {
pub(crate) fn new(signal: LoopSignal) -> std::io::Result<Self> {
Self::with_loader(signal, {
// Theme discovery and inherited-theme reads belong to the worker too.
let mut theme = None;
move |(icon, scale)| {
let theme = theme.get_or_insert_with(|| {
CursorTheme::load(&std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into()))
});
load_named_cursor(theme, icon, scale)
}
})
}
fn with_loader(
signal: LoopSignal,
mut load: impl FnMut((CursorIcon, i32)) -> Option<NamedCursor> + Send + 'static,
) -> std::io::Result<Self> {
let shared = Arc::new((
Mutex::new(Requests {
pending: Some((CursorIcon::Default, 1)),
..Requests::default()
}),
Condvar::new(),
));
let worker = shared.clone();
let (sender, results) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("ferese-cursors".into())
.spawn(move || {
loop {
let (requests, ready) = &*worker;
let mut requests = ready
.wait_while(requests.lock().unwrap(), |requests| {
!requests.closed && requests.pending.is_none()
})
.unwrap();
if requests.closed {
break;
}
let request = requests.pending.take().unwrap();
requests.in_flight = Some(request);
drop(requests);
let cursor = load(request);
let (icon, scale) = request;
if sender.send(Loaded { icon, scale, cursor }).is_err() {
break;
}
signal.wakeup();
worker.0.lock().unwrap().in_flight = None;
}
})?;
Ok(Self { shared, results })
}
pub(crate) fn request(&self, icon: CursorIcon, scale: i32) {
let request = (icon, scale);
let mut requests = self.shared.0.lock().unwrap();
if requests.in_flight == Some(request) || requests.pending == Some(request) {
return;
}
requests.pending = Some(request);
self.shared.1.notify_one();
}
}
impl Drop for Loader {
fn drop(&mut self) {
let mut requests = self.shared.0.lock().unwrap();
requests.closed = true;
requests.pending = None;
self.shared.1.notify_one();
}
}
pub(crate) struct Animation {
icon: Option<CursorIcon>,
epoch: Instant,
frame: usize,
timer: Option<RegistrationToken>,
pub(crate) reset: bool,
pub(crate) buffer_scale: i32,
}
impl Default for Animation {
fn default() -> Self {
Self {
icon: None,
epoch: Instant::now(),
frame: 0,
timer: None,
reset: false,
buffer_scale: 1,
}
}
}
impl crate::Ferese {
pub(crate) fn named_cursor_frame(&self) -> Option<&CursorFrame> {
let CursorImageStatus::Named(icon) = self.cursor_status else {
return None;
};
let cursor = self
.named_cursors
.get(&icon)
.or_else(|| self.named_cursors.get(&CursorIcon::Default))?;
let frame = if self.cursor_animation.icon == Some(icon) {
self.cursor_animation.frame
} else {
0
};
cursor.frames.get(frame).or_else(|| cursor.frames.first())
}
pub(crate) fn update_named_cursor(&mut self, now: Instant) {
self.cursor_animation.buffer_scale = if let Some(backend) = self.direct_backend.as_ref() {
backend
.physical_outputs()
.map(|output| output.current_scale().fractional_scale().ceil() as i32)
.max()
} else {
self.space
.outputs()
.map(|output| output.current_scale().fractional_scale().ceil() as i32)
.max()
}
.unwrap_or(1)
.clamp(1, 8);
while let Ok(Loaded { icon, scale, cursor }) = self.cursor_loader.results.try_recv() {
let mut cursor = cursor.unwrap_or_else(fallback_cursor);
cursor.requested_scale = scale;
let active = match self.cursor_status {
CursorImageStatus::Named(icon) => Some(icon),
_ => None,
};
let affects_current = active == Some(icon)
|| (icon == CursorIcon::Default
&& active.is_some_and(|active| !self.named_cursors.contains_key(&active)));
let mut bytes: usize = self.named_cursors.values().map(|cursor| cursor.bytes).sum();
while bytes + cursor.bytes > MAX_CACHE_BYTES {
let Some(evict) = self
.named_cursors
.keys()
.copied()
.find(|key| *key != CursorIcon::Default && *key != icon && Some(*key) != active)
else {
break;
};
bytes -= self.named_cursors.remove(&evict).unwrap().bytes;
}
self.named_cursors.insert(icon, cursor);
if affects_current {
self.cursor_animation.reset = true;
self.cursor_redraw_pending |= crate::backends::direct::cursor_animation_visible(self);
}
}
let icon = match self.cursor_status {
CursorImageStatus::Named(icon) => Some(icon),
_ => None,
};
if let Some(icon) = icon
&& self
.named_cursors
.get(&icon)
.is_none_or(|cursor| cursor.requested_scale != self.cursor_animation.buffer_scale)
{
self.cursor_loader.request(icon, self.cursor_animation.buffer_scale);
}
let animated = icon
.and_then(|icon| {
self.named_cursors
.get(&icon)
.or_else(|| self.named_cursors.get(&CursorIcon::Default))
})
.is_some_and(|cursor| cursor.frames.len() > 1);
let visible = animated && crate::backends::direct::cursor_animation_visible(self);
if !visible || self.cursor_animation.icon != icon || self.cursor_animation.reset {
if let Some(token) = self.cursor_animation.timer.take() {
self.loop_handle.remove(token);
}
self.cursor_animation.icon = icon.filter(|_| visible);
self.cursor_animation.epoch = now;
self.cursor_animation.frame = 0;
self.cursor_animation.reset = false;
}
if !visible {
return;
}
let icon = icon.unwrap();
let cursor = self
.named_cursors
.get(&icon)
.or_else(|| self.named_cursors.get(&CursorIcon::Default))
.unwrap();
let (frame, remaining) = cursor.sample(now.saturating_duration_since(self.cursor_animation.epoch));
if self.cursor_animation.frame != frame {
self.cursor_animation.frame = frame;
self.cursor_redraw_pending = true;
}
if let Some(remaining) = remaining
&& self.cursor_animation.timer.is_none()
{
match self
.loop_handle
.insert_source(Timer::from_deadline(now + remaining), |_, _, state| {
state.cursor_animation.timer = None;
state.cursor_redraw_pending = true;
TimeoutAction::Drop
}) {
Ok(token) => self.cursor_animation.timer = Some(token),
Err(error) => tracing::warn!(%error, "could not schedule animated cursor"),
}
}
}
}
fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon, scale: i32) -> Option<NamedCursor> {
let requested = std::env::var("XCURSOR_SIZE")
.ok() .ok()
.and_then(|value| value.parse().ok()) .and_then(|size| size.parse::<u32>().ok())
.filter(|size| *size > 0) .filter(|size| *size > 0 && *size <= 1024)
.unwrap_or(DEFAULT_CURSOR_SIZE) .unwrap_or(DEFAULT_CURSOR_SIZE);
cursor_names(icon).iter().find_map(|name| {
let path = theme.load_icon(name)?;
let file = File::open(path).ok()?;
let metadata = file.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES as u64 {
return None;
}
let mut bytes = Vec::new();
file.take((MAX_FILE_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
parse_cursor(&bytes, requested, scale)
}) })
} }
@@ -56,37 +335,113 @@ fn cursor_names(icon: CursorIcon) -> [&'static str; 3] {
} }
} }
fn load_image(theme: &CursorTheme, name: &str, requested_size: u32) -> Option<Image> { // Inspect every image before allocating buffers, then decode only the selected
let path = theme.load_icon(name)?; // nominal size. TOC aliases count toward the budget just like separate frames.
let bytes = fs::read(path).ok()?; fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor> {
fn word(bytes: &[u8], offset: usize) -> Option<u32> {
xcursor::parser::parse_xcursor(&bytes)?.into_iter().min_by_key(|image| { Some(u32::from_le_bytes(
( bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
image.size.abs_diff(requested_size), ))
image.width.abs_diff(requested_size), }
if bytes.len() > MAX_FILE_BYTES || bytes.get(..4)? != b"Xcur" {
return None;
}
let header = word(bytes, 4)? as usize;
let count = word(bytes, 12)? as usize;
if header < 16 || count > MAX_FRAMES || header.checked_add(count.checked_mul(12)?)? > bytes.len() {
return None;
}
let mut images = Vec::new();
let mut total_pixels = 0usize;
for i in 0..count {
let toc = header + i * 12;
if word(bytes, toc)? != 0xfffd0002 {
continue;
}
let nominal = word(bytes, toc + 4)?;
let pos = word(bytes, toc + 8)? as usize;
if word(bytes, pos)? != 36
|| word(bytes, pos.checked_add(4)?)? != 0xfffd0002
|| word(bytes, pos.checked_add(8)?)? != nominal
|| word(bytes, pos.checked_add(12)?)? != 1
{
return None;
}
let width = word(bytes, pos + 16)?;
let height = word(bytes, pos + 20)?;
let xhot = word(bytes, pos + 24)?;
let yhot = word(bytes, pos + 28)?;
if nominal == 0 || width == 0 || height == 0 || width > 1024 || height > 1024 || xhot > width || yhot > height {
return None;
}
let pixels = (width as usize).checked_mul(height as usize)?;
total_pixels = total_pixels.checked_add(pixels)?;
if total_pixels > MAX_PIXELS {
return None;
}
let start = pos.checked_add(36)?;
let end = start.checked_add(pixels.checked_mul(4)?)?;
let rgba = bytes.get(start..end)?;
images.push((nominal, width, height, xhot, yhot, word(bytes, pos + 32)?, rgba));
}
let base = images
.iter()
.map(|image| image.0)
.min_by_key(|size| (size.abs_diff(requested), *size))?;
let scale = scale.clamp(1, 8);
let desired = base.checked_mul(scale as u32)?;
let nominal = images
.iter()
.map(|image| image.0)
.min_by_key(|size| (size.abs_diff(desired), *size))?;
let mut frames = Vec::new();
let mut asset_bytes = 0usize;
for (_, width, height, xhot, yhot, delay, rgba) in images.into_iter().filter(|image| image.0 == nominal) {
let ratio = f64::from(desired) / f64::from(nominal);
let w = (f64::from(width) * ratio).round().max(1.0) as u32;
let h = (f64::from(height) * ratio).round().max(1.0) as u32;
if w > 1024 || h > 1024 {
return None;
}
let size: Size<i32, Buffer> = (w as i32, h as i32).into();
asset_bytes = asset_bytes.checked_add((w as usize).checked_mul(h as usize)?.checked_mul(4)?)?;
if asset_bytes > MAX_PIXELS * 4 {
return None;
}
let resized;
let pixels = if w != width || h != height {
let original = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
resized = image::imageops::resize(&original, w, h, image::imageops::FilterType::Triangle);
resized.as_raw().as_slice()
} else {
rgba
};
frames.push(CursorFrame {
buffer: MemoryRenderBuffer::from_slice(pixels, Fourcc::Abgr8888, size, scale, Transform::Normal, None),
hotspot: (
(f64::from(xhot) * ratio).round() as i32,
(f64::from(yhot) * ratio).round() as i32,
) )
.into(),
size,
buffer_scale: scale,
// Zero-delay themes need a finite cadence rather than a busy loop.
delay: Duration::from_millis(if delay == 0 { 16 } else { u64::from(delay) }),
});
}
Some(NamedCursor {
frames: frames.into(),
bytes: asset_bytes,
requested_scale: scale,
}) })
} }
fn cursor_from_image(image: Image) -> NamedCursor { pub(crate) fn fallback_cursor() -> NamedCursor {
let size = Size::<i32, Buffer>::from((image.width as i32, image.height as i32));
let buffer = MemoryRenderBuffer::from_slice(&image.pixels_rgba, Fourcc::Abgr8888, size, 1, Transform::Normal, None);
NamedCursor {
buffer,
hotspot: Point::from((image.xhot as i32, image.yhot as i32)),
size,
}
}
fn fallback_cursor() -> NamedCursor {
const WIDTH: usize = 12; const WIDTH: usize = 12;
const HEIGHT: usize = 18; const HEIGHT: usize = 18;
let mut pixels = vec![0_u8; WIDTH * HEIGHT * 4]; let mut pixels = vec![0_u8; WIDTH * HEIGHT * 4];
for y in 0..HEIGHT { for y in 0..HEIGHT {
let body_width = (y / 2 + 1).min(8); let body_width = (y / 2 + 1).min(8);
for x in 0..body_width { for x in 0..body_width {
let edge = x == 0 || x + 1 == body_width || y == 0; let edge = x == 0 || x + 1 == body_width || y == 0;
let offset = (y * WIDTH + x) * 4; let offset = (y * WIDTH + x) * 4;
@@ -94,39 +449,26 @@ fn fallback_cursor() -> NamedCursor {
pixels[offset..offset + 4].copy_from_slice(&[color, color, color, 255]); pixels[offset..offset + 4].copy_from_slice(&[color, color, color, 255]);
} }
} }
NamedCursor { NamedCursor {
frames: vec![CursorFrame {
buffer: MemoryRenderBuffer::from_slice( buffer: MemoryRenderBuffer::from_slice(
&pixels, &pixels,
Fourcc::Abgr8888, Fourcc::Abgr8888,
Size::<i32, Buffer>::from((WIDTH as i32, HEIGHT as i32)), (WIDTH as i32, HEIGHT as i32),
1, 1,
Transform::Normal, Transform::Normal,
None, None,
), ),
hotspot: Point::default(), hotspot: Point::default(),
size: (WIDTH as i32, HEIGHT as i32).into(), size: (WIDTH as i32, HEIGHT as i32).into(),
buffer_scale: 1,
delay: Duration::from_millis(16),
}]
.into(),
bytes: pixels.len(),
requested_scale: 1,
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests;
use smithay::input::pointer::CursorIcon;
use super::cursor_names;
#[test]
fn supplies_legacy_fallback_names_for_common_cursors() {
assert_eq!(cursor_names(CursorIcon::Default), ["default", "left_ptr", "arrow"]);
assert_eq!(cursor_names(CursorIcon::Pointer), ["pointer", "hand2", "left_ptr"]);
assert_eq!(cursor_names(CursorIcon::Text), ["text", "xterm", "left_ptr"]);
}
#[test]
fn falls_back_to_default_for_other_named_cursors() {
assert_eq!(
cursor_names(CursorIcon::Crosshair),
["crosshair", "default", "left_ptr"]
);
}
}
+217
View File
@@ -0,0 +1,217 @@
use super::*;
use smithay::input::SeatHandler;
use smithay::input::pointer::MotionEvent;
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::utils::SERIAL_COUNTER;
fn file(frames: &[(u32, u32, u32, u32)]) -> Vec<u8> {
let mut bytes = b"Xcur".to_vec();
bytes.extend([16u32, 1, frames.len() as u32].into_iter().flat_map(u32::to_le_bytes));
let mut pos = 16 + frames.len() * 12;
for &(size, width, _, _) in frames {
bytes.extend([0xfffd0002, size, pos as u32].into_iter().flat_map(u32::to_le_bytes));
pos += 36 + width as usize * width as usize * 4;
}
for &(size, width, hotspot, delay) in frames {
bytes.extend(
[36, 0xfffd0002, size, 1, width, width, hotspot, hotspot, delay]
.into_iter()
.flat_map(u32::to_le_bytes),
);
bytes.extend([255, 255, 255, 255].repeat(width as usize * width as usize));
}
bytes
}
#[test]
fn hotspot_and_footprint_use_the_images_scale() {
let bytes = file(&[(24, 24, 12, 50), (48, 48, 24, 50)]);
for scale in [1.0f64, 1.25, 2.0] {
let asset = parse_cursor(&bytes, 24, scale.ceil() as i32).unwrap();
let frame = &asset.frames[0];
let pointer: Point<f64, Logical> = (100.0, 120.0).into();
let location = frame.physical_location(pointer, scale);
let hotspot = f64::from(frame.hotspot.x) * scale / f64::from(frame.buffer_scale);
assert_eq!(location.x + hotspot, pointer.x * scale);
assert_eq!(location.y + hotspot, pointer.y * scale);
assert_eq!(frame.logical_rect(pointer).size, (24.0, 24.0).into());
if scale > 1.0 {
assert_eq!(frame.size.w, 48);
}
}
}
#[test]
fn unavailable_hidpi_size_preserves_logical_size_and_hotspot() {
let asset = parse_cursor(&file(&[(32, 32, 16, 50)]), 24, 2).unwrap();
let frame = &asset.frames[0];
assert_eq!(frame.buffer_scale, 2);
assert_eq!(frame.size.w, 64);
assert_eq!(frame.hotspot.x, 32);
assert_eq!(frame.logical_rect((100.0, 100.0).into()).size, (32.0, 32.0).into());
}
#[test]
fn selects_a_nominal_sequence_and_preserves_frame_delays() {
let asset = parse_cursor(&file(&[(48, 48, 24, 70), (24, 24, 12, 30), (24, 24, 12, 80)]), 24, 1).unwrap();
assert_eq!(asset.frames.len(), 2);
for (time, index, remaining) in [
(0, 0, 30),
(29, 0, 1),
(30, 1, 80),
(109, 1, 1),
(110, 0, 30),
(400, 1, 40),
] {
assert_eq!(
asset.sample(Duration::from_millis(time)),
(index, Some(Duration::from_millis(remaining)))
);
}
let still = parse_cursor(&file(&[(24, 24, 12, 0)]), 24, 1).unwrap();
assert_eq!(still.sample(Duration::from_secs(300)), (0, None));
let zero = parse_cursor(&file(&[(24, 24, 12, 0), (24, 24, 12, 0)]), 24, 1).unwrap();
assert_eq!(zero.sample(Duration::from_millis(16)).0, 1);
}
#[test]
fn rejects_oversized_and_malformed_assets_before_buffer_allocation() {
assert!(parse_cursor(&vec![0; MAX_FILE_BYTES + 1], 24, 1).is_none());
let mut bytes = file(&[(24, 24, 12, 30)]);
bytes[12..16].copy_from_slice(&((MAX_FRAMES + 1) as u32).to_le_bytes());
assert!(parse_cursor(&bytes, 24, 1).is_none());
let mut bytes = file(&[(24, 24, 12, 30)]);
bytes[16 + 8..16 + 12].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(parse_cursor(&bytes, 24, 1).is_none());
let mut bytes = file(&[(24, 24, 12, 30)]);
bytes[28 + 16..28 + 20].copy_from_slice(&2048u32.to_le_bytes());
assert!(parse_cursor(&bytes, 24, 1).is_none());
// Repeated TOC references must not bypass the aggregate pixel limit.
let mut bytes = file(&[(1024, 1024, 0, 30)]);
let frame = bytes.split_off(28);
bytes.truncate(16);
bytes[12..16].copy_from_slice(&3u32.to_le_bytes());
for _ in 0..3 {
bytes.extend([0xfffd0002, 1024, 52].into_iter().flat_map(u32::to_le_bytes));
}
bytes.extend(frame);
assert!(parse_cursor(&bytes, 24, 1).is_none());
assert!(parse_cursor(&file(&[(24, 24, 12, 30)])[..40], 24, 1).is_none());
}
#[test]
fn delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch() {
if !crate::startup_tests::private_runtime(
"cursor::tests::delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let (started, calls) = mpsc::channel();
let (release, held) = mpsc::channel();
state.cursor_loader = Loader::with_loader(events.get_signal(), move |request| {
started.send(request).unwrap();
if request.0 == CursorIcon::Default {
held.recv_timeout(Duration::from_secs(2)).unwrap();
}
None
})
.unwrap();
assert_eq!(
calls.recv_timeout(Duration::from_secs(2)).unwrap().0,
CursorIcon::Default
);
let seat = state.seat.clone();
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Pointer));
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (10.0, 20.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 1,
},
);
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
crate::after_dispatch_with_redraw(&mut state, |_, _| {});
assert_eq!(pointer.current_location(), (10.0, 20.0).into());
assert!(
state.named_cursor_frame().is_some(),
"cached fallback must remain visible"
);
assert!(calls.try_recv().is_err(), "decoder is still deliberately blocked");
release.send(()).unwrap();
assert_eq!(calls.recv_timeout(Duration::from_secs(2)).unwrap().0, CursorIcon::Text);
}
#[test]
fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
if !crate::startup_tests::private_runtime(
"cursor::tests::cursor_animation_advances_and_cancels_on_hide_and_shape_change",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
// No background result can replace the synthetic fixture.
state.cursor_loader = Loader::with_loader(events.get_signal(), |_| None).unwrap();
let output = Output::new(
"cursor-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (800, 600).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&output, (0, 0));
let asset = parse_cursor(&file(&[(24, 24, 12, 30), (24, 24, 12, 80)]), 24, 1).unwrap();
state.named_cursors.insert(CursorIcon::Wait, asset);
state.cursor_status = CursorImageStatus::Named(CursorIcon::Wait);
let now = Instant::now();
state.update_named_cursor(now);
assert_eq!(state.cursor_animation.frame, 0);
assert!(state.cursor_animation.timer.is_some());
// Invoke the same deadline callback by dispatching the real timer.
while Instant::now() < now + Duration::from_millis(35) {
events.dispatch(Duration::from_millis(5), &mut state).unwrap();
}
state.update_named_cursor(now + Duration::from_millis(30));
assert_eq!(state.cursor_animation.frame, 1);
assert!(state.cursor_animation.timer.is_some());
let seat = state.seat.clone();
state.cursor_image(&seat, CursorImageStatus::Hidden);
state.update_named_cursor(now + Duration::from_millis(31));
assert!(state.cursor_animation.timer.is_none());
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Wait));
state.update_named_cursor(now + Duration::from_millis(32));
assert!(state.cursor_animation.timer.is_some());
state.named_cursors.insert(CursorIcon::Text, fallback_cursor());
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
state.update_named_cursor(now + Duration::from_millis(33));
assert!(state.cursor_animation.timer.is_none());
}
#[test]
fn supplies_legacy_fallback_names_for_common_cursors() {
assert_eq!(cursor_names(CursorIcon::Default), ["default", "left_ptr", "arrow"]);
assert_eq!(cursor_names(CursorIcon::Pointer), ["pointer", "hand2", "left_ptr"]);
assert_eq!(cursor_names(CursorIcon::Text), ["text", "xterm", "left_ptr"]);
assert_eq!(
cursor_names(CursorIcon::Crosshair),
["crosshair", "default", "left_ptr"]
);
}
+293
View File
@@ -0,0 +1,293 @@
use std::collections::{HashSet, VecDeque};
use std::time::{Duration, Instant};
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::drm::DrmNode;
use smithay::backend::renderer::ImportDma;
use smithay::reexports::calloop::RegistrationToken;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::wayland_server::backend::ClientId;
use smithay::wayland::dmabuf::{DmabufGlobal, ImportNotifier};
use crate::Ferese;
const MAX_PENDING: usize = 64;
const MAX_PER_CLIENT: usize = 16;
const MAX_WAIT: Duration = Duration::from_secs(5);
struct Pending<K, C, T> {
owner: K,
client: C,
deadline: Instant,
value: T,
}
struct Queue<K, C, T> {
pending: VecDeque<Pending<K, C, T>>,
}
impl<K, C, T> Default for Queue<K, C, T> {
fn default() -> Self {
Self {
pending: VecDeque::new(),
}
}
}
impl<K: Eq, C: Eq, T> Queue<K, C, T> {
fn enqueue(&mut self, owner: K, client: C, value: T, now: Instant) -> Result<(), T> {
if self.pending.len() >= MAX_PENDING
|| self.pending.iter().filter(|entry| entry.client == client).count() >= MAX_PER_CLIENT
{
return Err(value);
}
self.pending.push_back(Pending {
owner,
client,
value,
deadline: now + MAX_WAIT,
});
Ok(())
}
fn process(&mut self, owner: &K, mut complete: impl FnMut(T)) {
self.remove_if(|entry| &entry.owner == owner, |entry| complete(entry.value));
}
fn remove_if(
&mut self,
mut remove: impl FnMut(&Pending<K, C, T>) -> bool,
mut complete: impl FnMut(Pending<K, C, T>),
) {
for _ in 0..self.pending.len() {
let entry = self.pending.pop_front().unwrap();
if remove(&entry) {
complete(entry);
} else {
self.pending.push_back(entry);
}
}
}
fn deadline(&self) -> Option<Instant> {
self.pending.front().map(|entry| entry.deadline)
}
}
struct Request {
buffer: Dmabuf,
notifier: ImportNotifier,
}
#[derive(Default)]
pub(crate) struct ImportManager {
globals: HashSet<DmabufGlobal>,
queue: Queue<DmabufGlobal, ClientId, Request>,
timer: Option<RegistrationToken>,
}
impl Drop for ImportManager {
fn drop(&mut self) {
self.queue.remove_if(|_| true, |entry| entry.value.notifier.failed());
}
}
impl ImportManager {
pub(crate) fn register(&mut self, global: DmabufGlobal) {
self.globals.insert(global);
}
pub(crate) fn remove(&mut self, global: DmabufGlobal) {
self.globals.remove(&global);
self.queue.process(&global, |request| request.notifier.failed());
}
pub(crate) fn enqueue(&mut self, global: DmabufGlobal, buffer: Dmabuf, notifier: ImportNotifier, now: Instant) {
let Some(client) = notifier.client() else {
notifier.failed();
return;
};
if !self.globals.contains(&global) {
notifier.failed();
return;
}
if let Err(request) = self
.queue
.enqueue(global, client.id(), Request { buffer, notifier }, now)
{
request.notifier.failed();
}
}
pub(crate) fn process<R: ImportDma>(&mut self, global: DmabufGlobal, renderer: &mut R, node: Option<DrmNode>) {
self.process_with(global, node, |buffer| renderer.import_dmabuf(buffer, None).is_ok());
}
pub(crate) fn process_with(
&mut self,
global: DmabufGlobal,
node: Option<DrmNode>,
mut import: impl FnMut(&Dmabuf) -> bool,
) {
self.queue.process(&global, |request| {
if request.notifier.client().is_none() {
request.notifier.failed();
return;
}
if !import(&request.buffer) {
request.notifier.failed();
return;
}
if let Some(node) = node {
request.buffer.set_node(node);
}
if let Err(error) = request.notifier.successful::<Ferese>() {
tracing::debug!(?error, "dma-buf client disappeared before import completed");
}
});
}
pub(crate) fn expire(&mut self, now: Instant) {
self.queue.remove_if(
|entry| entry.deadline <= now || entry.value.notifier.client().is_none(),
|entry| entry.value.notifier.failed(),
);
}
}
impl Ferese {
pub(crate) fn process_pending_dmabuf_imports(&mut self) {
self.dmabuf_imports.expire(Instant::now());
if self.dmabuf_imports.queue.deadline().is_some() {
if let Some(backend) = self.direct_backend.as_mut() {
backend.process_dmabuf_imports(&mut self.dmabuf_imports);
} else if let Some(backend) = self.nested_backend.as_ref() {
let mut backend = backend.borrow_mut();
// Nested Ferese publishes exactly one DMA-BUF global.
if let Some(global) = self.dmabuf_imports.globals.iter().next().copied() {
self.dmabuf_imports.process(global, backend.renderer(), None);
}
}
}
if let Some(deadline) = self.dmabuf_imports.queue.deadline() {
if self.dmabuf_imports.timer.is_none() {
match self
.loop_handle
.insert_source(Timer::from_deadline(deadline), |_, _, state| {
state.dmabuf_imports.expire(Instant::now());
if let Some(deadline) = state.dmabuf_imports.queue.deadline() {
TimeoutAction::ToInstant(deadline)
} else {
state.dmabuf_imports.timer = None;
TimeoutAction::Drop
}
}) {
Ok(token) => self.dmabuf_imports.timer = Some(token),
Err(error) => {
tracing::warn!(%error, "could not schedule DMA-BUF import deadline");
self.dmabuf_imports
.queue
.remove_if(|_| true, |entry| entry.value.notifier.failed());
}
}
}
} else if let Some(token) = self.dmabuf_imports.timer.take() {
self.loop_handle.remove(token);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::AsRawFd;
#[test]
fn renderer_order_cannot_consume_another_globals_requests() {
for order in [[1, 2], [2, 1]] {
let now = Instant::now();
let mut queue = Queue::default();
queue.enqueue(2, 1, "B-only", now).unwrap();
let mut results = Vec::new();
for owner in order {
queue.process(&owner, |buffer| {
assert_eq!(owner, 2);
assert_eq!(buffer, "B-only");
results.push(true);
});
}
assert_eq!(results, [true]);
assert!(queue.pending.is_empty());
}
}
#[test]
fn unavailable_imports_are_bounded_and_recover_before_the_deadline() {
let now = Instant::now();
let mut queue = Queue::default();
for index in 0..MAX_PENDING {
queue.enqueue(1, index / MAX_PER_CLIENT, index, now).unwrap();
}
assert_eq!(queue.enqueue(1, 99, 100, now), Err(100));
let mut completed = Vec::new();
queue.remove_if(
|entry| entry.deadline <= now + MAX_WAIT / 2,
|_| panic!("expired early"),
);
queue.process(&1, |value| completed.push(value));
assert_eq!(completed, (0..MAX_PENDING).collect::<Vec<_>>());
assert!(queue.deadline().is_none());
}
#[test]
fn per_client_admission_leaves_room_for_other_clients() {
let now = Instant::now();
let mut queue = Queue::default();
for index in 0..MAX_PER_CLIENT {
queue.enqueue(1, 1, index, now).unwrap();
}
assert_eq!(queue.enqueue(2, 1, 100, now), Err(100));
queue.enqueue(2, 2, 101, now).unwrap();
}
#[test]
fn expiry_and_device_removal_release_resources_once_without_touching_other_owners() {
if !crate::startup_tests::private_runtime(
"dmabuf_imports::tests::expiry_and_device_removal_release_resources_once_without_touching_other_owners",
) {
return;
}
let now = Instant::now();
let mut queue = Queue::default();
let removed = tempfile::tempfile().unwrap();
let expired = tempfile::tempfile().unwrap();
let fd_removed = removed.as_raw_fd();
let fd_expired = expired.as_raw_fd();
queue.enqueue(1, 1, removed, now).unwrap();
queue.enqueue(2, 1, expired, now + Duration::from_secs(1)).unwrap();
let mut failures = 0;
queue.process(&1, |_| failures += 1);
// SAFETY: F_GETFD observes descriptor validity without modifying it.
assert_eq!(unsafe { libc::fcntl(fd_removed, libc::F_GETFD) }, -1);
assert_ne!(unsafe { libc::fcntl(fd_expired, libc::F_GETFD) }, -1);
queue.remove_if(|entry| entry.deadline <= now + MAX_WAIT, |_| panic!("expired early"));
queue.remove_if(
|entry| entry.deadline <= now + MAX_WAIT + Duration::from_secs(1),
|_| failures += 1,
);
assert_eq!(unsafe { libc::fcntl(fd_expired, libc::F_GETFD) }, -1);
assert_eq!(failures, 2);
assert!(queue.deadline().is_none());
}
}
+3 -3
View File
@@ -112,9 +112,9 @@ impl DmabufHandler for Ferese {
&mut self.dmabuf_state &mut self.dmabuf_state
} }
fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) { fn dmabuf_imported(&mut self, global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
self.queue_dmabuf_import(dmabuf, notifier); self.dmabuf_imports
crate::backends::direct::render_all(self); .enqueue(*global, dmabuf, notifier, std::time::Instant::now());
} }
} }
+10 -4
View File
@@ -46,11 +46,17 @@ impl SeatHandler for Ferese {
} }
fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) { fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
if let CursorImageStatus::Named(icon) = &image { if let CursorImageStatus::Named(icon) = &image
self.named_cursors && !self.named_cursors.contains_key(icon)
.entry(*icon) {
.or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon)); self.cursor_loader.request(*icon, self.cursor_animation.buffer_scale);
} }
self.cursor_animation.reset |= match (&self.cursor_status, &image) {
(CursorImageStatus::Named(previous), CursorImageStatus::Named(next)) => previous != next,
(CursorImageStatus::Hidden, CursorImageStatus::Hidden) => false,
(CursorImageStatus::Surface(previous), CursorImageStatus::Surface(next)) => previous != next,
_ => true,
};
self.cursor_status = image; self.cursor_status = image;
self.update_capture_cursor_privacy(); self.update_capture_cursor_privacy();
// Pointer focus transitions invoke this with the pointer lock held. // Pointer focus transitions invoke this with the pointer lock held.
+3
View File
@@ -4,6 +4,7 @@ mod cursor;
mod daemon; mod daemon;
mod dimming; mod dimming;
mod display_presentation; mod display_presentation;
mod dmabuf_imports;
mod effects; mod effects;
mod floating; mod floating;
mod frame_scheduler; mod frame_scheduler;
@@ -161,6 +162,8 @@ fn after_dispatch(state: &mut Ferese) {
} }
fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) { fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) {
state.process_pending_dmabuf_imports();
state.update_named_cursor(std::time::Instant::now());
state.poll_theme(); state.poll_theme();
// The decoder wakes the loop after publishing its result, including // The decoder wakes the loop after publishing its result, including
// when no output has a pending frame. // when no output has a pending frame.
+4 -5
View File
@@ -487,15 +487,14 @@ pub(super) fn cursor_elements(
}) })
.collect() .collect()
} }
CursorImageStatus::Named(icon) => { CursorImageStatus::Named(_) => {
let Some(cursor) = state.named_cursors.get(icon) else { let Some(cursor) = state.named_cursor_frame() else {
return Vec::new(); return Vec::new();
}; };
let hotspot = Point::<i32, Physical>::from((cursor.hotspot.x, cursor.hotspot.y)); let physical_location = cursor.physical_location(pointer_location, scale);
let physical_location = pointer_location.to_physical_precise_round(scale) - hotspot;
let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( let Ok(element) = MemoryRenderBufferRenderElement::from_buffer(
renderer, renderer,
physical_location.to_f64(), physical_location,
&cursor.buffer, &cursor.buffer,
None, None,
None, None,
+1 -1
View File
@@ -27,7 +27,7 @@ pub(crate) fn private_runtime(test: &str) -> bool {
let log_path = directory.path().join("test.log"); let log_path = directory.path().join("test.log");
let log = std::fs::File::create(&log_path).unwrap(); let log = std::fs::File::create(&log_path).unwrap();
let mut child = Command::new(std::env::current_exe().unwrap()) let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", test, "--nocapture"]) .args(["--exact", test, "--include-ignored", "--nocapture"])
.env(CHILD, test) .env(CHILD, test)
.env("XDG_RUNTIME_DIR", directory.path()) .env("XDG_RUNTIME_DIR", directory.path())
.env("XDG_CONFIG_HOME", directory.path()) .env("XDG_CONFIG_HOME", directory.path())
+15 -37
View File
@@ -33,10 +33,8 @@ use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, ViewportFocusStrategy, WindowId, Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, ViewportFocusStrategy, WindowId,
}; };
use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1; use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1;
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::drm::{DrmEventTime, DrmNode}; use smithay::backend::drm::{DrmEventTime, DrmNode};
use smithay::backend::input::Keycode; use smithay::backend::input::Keycode;
use smithay::backend::renderer::ImportDma;
use smithay::desktop::space::SpaceElement; use smithay::desktop::space::SpaceElement;
use smithay::desktop::utils::send_frames_surface_tree; use smithay::desktop::utils::send_frames_surface_tree;
use smithay::desktop::{LayerSurface, PopupKind, PopupManager, Space, Window, WindowSurfaceType, layer_map_for_output}; use smithay::desktop::{LayerSurface, PopupKind, PopupManager, Space, Window, WindowSurfaceType, layer_map_for_output};
@@ -55,7 +53,7 @@ use smithay::utils::{Logical, Point, Rectangle, Size};
use smithay::wayland::alpha_modifier::AlphaModifierState; use smithay::wayland::alpha_modifier::AlphaModifierState;
use smithay::wayland::compositor::{CompositorClientState, CompositorState, with_states}; use smithay::wayland::compositor::{CompositorClientState, CompositorState, with_states};
use smithay::wayland::cursor_shape::CursorShapeManagerState; use smithay::wayland::cursor_shape::CursorShapeManagerState;
use smithay::wayland::dmabuf::{DmabufState, ImportNotifier}; use smithay::wayland::dmabuf::DmabufState;
use smithay::wayland::fractional_scale::FractionalScaleManagerState; use smithay::wayland::fractional_scale::FractionalScaleManagerState;
use smithay::wayland::idle_inhibit::IdleInhibitManagerState; use smithay::wayland::idle_inhibit::IdleInhibitManagerState;
use smithay::wayland::idle_notify::IdleNotifierState; use smithay::wayland::idle_notify::IdleNotifierState;
@@ -84,7 +82,7 @@ use window_registry::WindowRegistry;
use crate::backends::direct::DirectBackendState; use crate::backends::direct::DirectBackendState;
use crate::config::{Binding, BindingSet, DaemonConfig, InputSettings, OutputProfile, ThemeSettings}; use crate::config::{Binding, BindingSet, DaemonConfig, InputSettings, OutputProfile, ThemeSettings};
use crate::cursor::{NamedCursor, cursor_theme, load_named_cursor}; use crate::cursor::NamedCursor;
use crate::dimming::DimAnimation; use crate::dimming::DimAnimation;
use crate::gestures::{Swipe, SwipeDirection}; use crate::gestures::{Swipe, SwipeDirection};
use crate::handlers::screencopy::PendingScreencopy; use crate::handlers::screencopy::PendingScreencopy;
@@ -468,7 +466,8 @@ pub struct Ferese {
pub(crate) output_redraw_pending: Vec<Output>, pub(crate) output_redraw_pending: Vec<Output>,
pub(crate) locked_pointer_hint: Option<LockedPointerHint>, pub(crate) locked_pointer_hint: Option<LockedPointerHint>,
pub(crate) last_pointer_time: u32, pub(crate) last_pointer_time: u32,
pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) cursor_loader: crate::cursor::Loader,
pub(crate) cursor_animation: crate::cursor::Animation,
pub(crate) named_cursors: HashMap<CursorIcon, NamedCursor>, pub(crate) named_cursors: HashMap<CursorIcon, NamedCursor>,
pub intercepted_keys: HashSet<Keycode>, pub intercepted_keys: HashSet<Keycode>,
pub(crate) swipe: Swipe, pub(crate) swipe: Swipe,
@@ -479,7 +478,7 @@ pub struct Ferese {
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>, pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<DirectBackendState>, pub direct_backend: Option<DirectBackendState>,
_ipc_socket: Option<IpcSocketGuard>, _ipc_socket: Option<IpcSocketGuard>,
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, pub(crate) dmabuf_imports: crate::dmabuf_imports::ImportManager,
pub(crate) activation_inputs: crate::handlers::activation::InputHistory, pub(crate) activation_inputs: crate::handlers::activation::InputHistory,
pub(crate) pending_screencopies: Vec<PendingScreencopy>, pub(crate) pending_screencopies: Vec<PendingScreencopy>,
// Screenshot requests outlive the readback that filled them: the // Screenshot requests outlive the readback that filled them: the
@@ -630,8 +629,8 @@ impl Ferese {
let socket_name = Self::init_wayland_listener(display, event_loop)?; let socket_name = Self::init_wayland_listener(display, event_loop)?;
let start_time = Instant::now(); let start_time = Instant::now();
let cursor_theme = cursor_theme(); let cursor_loader = crate::cursor::Loader::new(event_loop.get_signal())?;
let default_cursor = load_named_cursor(&cursor_theme, CursorIcon::Default); let default_cursor = crate::cursor::fallback_cursor();
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
let mut state = Self { let mut state = Self {
@@ -712,7 +711,8 @@ impl Ferese {
output_redraw_pending: Vec::new(), output_redraw_pending: Vec::new(),
locked_pointer_hint: None, locked_pointer_hint: None,
last_pointer_time: 0, last_pointer_time: 0,
cursor_theme, cursor_loader,
cursor_animation: crate::cursor::Animation::default(),
named_cursors, named_cursors,
intercepted_keys: HashSet::new(), intercepted_keys: HashSet::new(),
swipe: Swipe::default(), swipe: Swipe::default(),
@@ -723,7 +723,7 @@ impl Ferese {
active_shortcuts_inhibitor: None, active_shortcuts_inhibitor: None,
direct_backend: None, direct_backend: None,
_ipc_socket: None, _ipc_socket: None,
pending_dmabuf_imports: Vec::new(), dmabuf_imports: crate::dmabuf_imports::ImportManager::default(),
activation_inputs: Default::default(), activation_inputs: Default::default(),
pending_screencopies: Vec::new(), pending_screencopies: Vec::new(),
screenshot: Coordinator::new(), screenshot: Coordinator::new(),
@@ -809,10 +809,6 @@ impl Ferese {
if input_changed && let Some(keyboard) = self.seat.get_keyboard() { if input_changed && let Some(keyboard) = self.seat.get_keyboard() {
if keyboard_changed { if keyboard_changed {
self.input_capture.close_all();
if self.input_capture.restore_focus {
self.restore_input_capture_focus();
}
let options = (!config.input_settings.xkb_options.is_empty()) let options = (!config.input_settings.xkb_options.is_empty())
.then(|| config.input_settings.xkb_options.join(",")); .then(|| config.input_settings.xkb_options.join(","));
@@ -827,6 +823,11 @@ impl Ferese {
}, },
) )
.map_err(|e| format!("keymap reload failed: {e}"))?; .map_err(|e| format!("keymap reload failed: {e}"))?;
self.input_capture.close_all();
if self.input_capture.restore_focus {
self.restore_input_capture_focus();
}
} }
keyboard.change_repeat_info(config.input_settings.repeat_rate, config.input_settings.repeat_delay_ms); keyboard.change_repeat_info(config.input_settings.repeat_rate, config.input_settings.repeat_delay_ms);
} }
@@ -931,29 +932,6 @@ impl Ferese {
Ok(()) Ok(())
} }
pub(crate) fn queue_dmabuf_import(&mut self, dmabuf: Dmabuf, notifier: ImportNotifier) {
self.pending_dmabuf_imports.push((dmabuf, notifier));
}
pub(crate) fn process_dmabuf_imports<R>(&mut self, renderer: &mut R, node: Option<DrmNode>)
where
R: ImportDma,
{
for (dmabuf, notifier) in self.pending_dmabuf_imports.drain(..) {
if renderer.import_dmabuf(&dmabuf, None).is_ok() {
if let Some(node) = node {
dmabuf.set_node(node);
}
if let Err(error) = notifier.successful::<Self>() {
tracing::debug!(?error, "dma-buf client disappeared before import completed");
}
} else {
notifier.failed();
}
}
}
fn init_wayland_listener( fn init_wayland_listener(
display: Display<Self>, display: Display<Self>,
event_loop: &mut EventLoop<'static, Self>, event_loop: &mut EventLoop<'static, Self>,
@@ -31,6 +31,190 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese
(events, state, output) (events, state, output)
} }
#[test]
fn late_keymap_failure_preserves_capture_sessions_and_runtime_settings() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::late_keymap_failure_preserves_capture_sessions_and_runtime_settings",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let focus = state.focused_window;
let session = state.input_capture.register(42, 1).unwrap();
state.input_capture.enable(session).unwrap();
let previous_layout = state.input_settings.xkb_layout.clone();
let previous_repeat = state.input_settings.repeat_rate;
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
// Preparation succeeded; force the real late application failure.
runtime.input_settings.xkb_layout = "ferese-nonexistent-layout-for-regression".into();
runtime.input_settings.repeat_rate = previous_repeat + 1;
let result = state.apply_runtime_config(runtime, &serde_json::json!({"input": "changed"}));
assert!(result.unwrap_err().contains("keymap reload failed"));
assert!(state.input_capture.has_sessions());
state.input_capture.enable(session).unwrap();
assert!(!state.input_capture.restore_focus);
assert_eq!(state.focused_window, focus);
assert_eq!(state.input_settings.xkb_layout, previous_layout);
assert_eq!(state.input_settings.repeat_rate, previous_repeat);
assert!(state.windows.ids().contains_key(&window));
}
#[test]
fn dmabuf_validation_uses_the_originating_global_without_a_desktop_frame() {
use smithay::backend::allocator::{Buffer, Format, Fourcc, Modifier};
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::dmabuf_validation_uses_the_originating_global_without_a_desktop_frame",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let formats = [Format {
code: Fourcc::Argb8888,
modifier: Modifier::Linear,
}];
let a = state
.dmabuf_state
.create_global::<Ferese>(&state.display_handle, formats);
state.dmabuf_imports.register(a);
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(ClientState::default()))
.unwrap();
request(&mut wire, 1, 1, &[2], None);
dispatch(&mut events, &mut state);
let read = |wire: &mut UnixStream| {
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap();
bytes
};
let mut registry = read(&mut wire);
// Register B after the initial registry roundtrip so notification order
// identifies A and B without depending on backend map iteration order.
let b = state
.dmabuf_state
.create_global::<Ferese>(&state.display_handle, formats);
state.dmabuf_imports.register(b);
state.display_handle.flush_clients().unwrap();
registry.extend(read(&mut wire));
let mut offset = 0;
let mut names = Vec::new();
while offset < registry.len() {
let header = u32::from_ne_bytes(registry[offset + 4..offset + 8].try_into().unwrap());
let length = (header >> 16) as usize;
let message = &registry[offset..offset + length];
if message
.windows(b"zwp_linux_dmabuf_v1\0".len())
.any(|text| text == b"zwp_linux_dmabuf_v1\0")
{
names.push(u32::from_ne_bytes(message[8..12].try_into().unwrap()));
}
offset += length;
}
assert_eq!(names.len(), 2);
let interface = b"zwp_linux_dmabuf_v1\0";
for (name, id) in names.into_iter().zip([3, 4]) {
let mut args = vec![name, interface.len() as u32];
let mut padded = interface.to_vec();
padded.resize(padded.len().next_multiple_of(4), 0);
args.extend(
padded
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
args.extend([3, id]);
request(&mut wire, 2, 0, &args, None);
}
dispatch(&mut events, &mut state);
read(&mut wire);
let file = tempfile::tempfile().unwrap();
file.set_len(64).unwrap();
// Both asynchronous and immediate imports must survive the wrong GPU
// being visited first. The mock capability accepts only B's buffers.
for (params, buffer) in [(5, None), (6, Some(7))] {
request(&mut wire, 4, 1, &[params], None);
request(&mut wire, params, 1, &[0, 0, 16, 0, 0], Some(file.as_raw_fd()));
let mut args = vec![4, 4, Fourcc::Argb8888 as u32, 0];
if let Some(buffer) = buffer {
args.insert(0, buffer);
}
request(&mut wire, params, if buffer.is_some() { 3 } else { 2 }, &args, None);
dispatch(&mut events, &mut state);
assert!(state.space.outputs().next().is_none());
state.process_pending_dmabuf_imports();
assert!(
read(&mut wire).is_empty(),
"unavailable renderer must defer notification"
);
state
.dmabuf_imports
.process_with(a, None, |_| panic!("B's request reached A's renderer"));
let mut calls = 0;
state.dmabuf_imports.process_with(b, None, |dmabuf| {
calls += 1;
assert_eq!(dmabuf.size(), (4, 4).into());
true
});
assert_eq!(calls, 1);
state.display_handle.flush_clients().unwrap();
let response = read(&mut wire);
if buffer.is_none() {
assert_eq!(u32::from_ne_bytes(response[..4].try_into().unwrap()), params);
assert_eq!(u32::from_ne_bytes(response[4..8].try_into().unwrap()) & 0xffff, 0);
} else {
assert!(response.is_empty(), "create_immed success must not emit a failed event");
}
assert!(client.get_credentials(&state.display_handle).is_ok());
}
// Deferred requests receive one terminal failure on timeout or removal.
for params in [8, 9] {
request(&mut wire, 4, 1, &[params], None);
request(&mut wire, params, 1, &[0, 0, 16, 0, 0], Some(file.as_raw_fd()));
request(&mut wire, params, 2, &[4, 4, Fourcc::Argb8888 as u32, 0], None);
dispatch(&mut events, &mut state);
state.process_pending_dmabuf_imports();
if params == 8 {
state.dmabuf_imports.expire(Instant::now() + Duration::from_secs(5));
} else {
state.dmabuf_imports.remove(b);
}
state.display_handle.flush_clients().unwrap();
let response = read(&mut wire);
assert_eq!(response.len(), 8);
assert_eq!(u32::from_ne_bytes(response[..4].try_into().unwrap()), params);
assert_eq!(u32::from_ne_bytes(response[4..8].try_into().unwrap()) & 0xffff, 1);
state
.dmabuf_imports
.process_with(b, None, |_| panic!("failed request survived"));
assert!(client.get_credentials(&state.display_handle).is_ok());
}
state.dmabuf_imports.register(b);
request(&mut wire, 4, 1, &[10], None);
request(&mut wire, 10, 1, &[0, 0, 16, 0, 0], Some(file.as_raw_fd()));
request(&mut wire, 10, 2, &[4, 4, Fourcc::Argb8888 as u32, 0], None);
dispatch(&mut events, &mut state);
drop(wire);
dispatch(&mut events, &mut state);
state.process_pending_dmabuf_imports();
state
.dmabuf_imports
.process_with(b, None, |_| panic!("disconnected client's request survived"));
}
#[test] #[test]
fn hidden_null_commit_removes_membership_and_remaps_without_workspace_switch() { fn hidden_null_commit_removes_membership_and_remaps_without_workspace_switch() {
if !crate::startup_tests::private_runtime( if !crate::startup_tests::private_runtime(
+297 -39
View File
@@ -12,9 +12,7 @@ use smithay::desktop::layer_map_for_output;
use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel}; use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::EventLoop; use smithay::reexports::calloop::EventLoop;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind; use smithay::utils::{Physical, Rectangle, Size, Transform};
use smithay::utils::{Clock, Monotonic, Physical, Rectangle, Transform};
use smithay::wayland::presentation::Refresh;
use crate::Ferese; use crate::Ferese;
use crate::metrics::{FrameEffectMetrics, RenderMetrics}; use crate::metrics::{FrameEffectMetrics, RenderMetrics};
@@ -32,14 +30,18 @@ type DamageRenderResult = Result<
pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<(), Box<dyn Error>> { pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<(), Box<dyn Error>> {
let (mut backend, event_source) = winit::init::<GlesRenderer>()?; let (mut backend, event_source) = winit::init::<GlesRenderer>()?;
configure_nested_protocols(state);
let initial_size = backend.window_size();
let drawable = Rc::new(Cell::new(usable_size(initial_size)));
let dmabuf_formats = backend.renderer().dmabuf_formats(); let dmabuf_formats = backend.renderer().dmabuf_formats();
let display_handle = state.display_handle.clone(); let display_handle = state.display_handle.clone();
state let dmabuf_global = state
.dmabuf_state .dmabuf_state
.create_global::<Ferese>(&display_handle, dmabuf_formats); .create_global::<Ferese>(&display_handle, dmabuf_formats);
state.dmabuf_imports.register(dmabuf_global);
let initial_scale = normalized_scale(backend.scale_factor()); let initial_scale = normalized_scale(backend.scale_factor());
let mode = Mode { let mode = Mode {
size: backend.window_size(), size: if drawable.get() { initial_size } else { (1, 1).into() },
refresh: backend refresh: backend
.window() .window()
.current_monitor() .current_monitor()
@@ -67,8 +69,6 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
state.register_output(&output, "nested-primary".to_owned()); state.register_output(&output, "nested-primary".to_owned());
let mut damage_tracker = OutputDamageTracker::from_output(&output); let mut damage_tracker = OutputDamageTracker::from_output(&output);
let clock = Clock::<Monotonic>::new();
let mut sequence = 0_u64;
let mut missed_deadlines = 0_u64; let mut missed_deadlines = 0_u64;
let mut render_metrics = RenderMetrics::from_environment(output.name()); let mut render_metrics = RenderMetrics::from_environment(output.name());
@@ -82,11 +82,16 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
state.nested_backend = Some(backend.clone()); state.nested_backend = Some(backend.clone());
let redraw_backend = backend.clone(); let redraw_backend = backend.clone();
let redraw_refresh = refresh.clone(); let redraw_refresh = refresh.clone();
let redraw_drawable = drawable.clone();
event_loop event_loop
.handle() .handle()
.insert_source(Timer::from_duration(refresh.get()), move |_, _, _| { .insert_source(Timer::from_duration(refresh.get()), move |_, _, _| {
if redraw_drawable.get() {
redraw_backend.borrow().window().request_redraw(); redraw_backend.borrow().window().request_redraw();
TimeoutAction::ToDuration(redraw_refresh.get()) TimeoutAction::ToDuration(redraw_refresh.get())
} else {
TimeoutAction::ToDuration(Duration::from_millis(250))
}
})?; })?;
let mut capture_texture = None; let mut capture_texture = None;
@@ -97,21 +102,16 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
match event { match event {
WinitEvent::Resized { size, scale_factor } => { WinitEvent::Resized { size, scale_factor } => {
tracing::debug!(target: "ferese::nested_input", ?size, scale_factor, "host resized nested output"); tracing::debug!(target: "ferese::nested_input", ?size, scale_factor, "host resized nested output");
let scale = normalized_scale(scale_factor);
let rate = backend let rate = backend
.window() .window()
.current_monitor() .current_monitor()
.and_then(|monitor| monitor.refresh_rate_millihertz()) .and_then(|monitor| monitor.refresh_rate_millihertz())
.unwrap_or(60_000) as i32; .unwrap_or(60_000) as i32;
refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64)); if !resize_nested_output(state, &output, &drawable, size, scale_factor, rate) {
return;
}
let mut runtime = state.current_desktop_outputs(); refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64));
let resized = runtime.iter_mut().find(|entry| entry.output == output).unwrap();
resized.mode = Mode { size, refresh: rate };
resized.scale = Scale::Fractional(scale);
state.begin_desktop_transition();
let changes = state.publish_desktop(runtime).expect("valid nested output geometry");
state.finish_desktop_transition(changes);
if let Err(error) = state.display_handle.flush_clients() { if let Err(error) = state.display_handle.flush_clients() {
tracing::debug!(%error, "failed to flush output-resize configure"); tracing::debug!(%error, "failed to flush output-resize configure");
} }
@@ -125,13 +125,17 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
"host input in nested output"); "host input in nested output");
} }
WinitEvent::Redraw => { WinitEvent::Redraw => {
if !drawable.get() || !usable_size(backend.window_size()) {
drawable.set(false);
return;
}
state.advance_animations(Instant::now()); state.advance_animations(Instant::now());
let age = backend.buffer_age().unwrap_or(0); let age = backend.buffer_age().unwrap_or(0);
let render_started = Instant::now(); let render_started = Instant::now();
let rendered = (|| -> DamageRenderResult { let rendered = (|| -> DamageRenderResult {
{ {
let (renderer, mut framebuffer) = backend.bind()?; let (renderer, mut framebuffer) = backend.bind()?;
state.process_dmabuf_imports(renderer, None);
let elements = animated_window_elements(state, renderer, &output); let elements = animated_window_elements(state, renderer, &output);
let effects = frame_effect_metrics(&elements, output.current_scale().fractional_scale()); let effects = frame_effect_metrics(&elements, output.current_scale().fractional_scale());
let result = damage_tracker.render_output( let result = damage_tracker.render_output(
@@ -143,8 +147,12 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
)?; )?;
// Keep the existing readback-only path when the // Keep the existing readback-only path when the
// displayed scene has no privacy exclusions. // displayed scene has no privacy exclusions.
let mut capture_changed_binding = !state.has_capture_exclusions() capture_with_display_restore(
&& state.process_screencopies(renderer, &framebuffer, &output, true); renderer,
&mut framebuffer,
|renderer, framebuffer| {
let mut changed = !state.has_capture_exclusions()
&& state.process_screencopies(renderer, framebuffer, &output, true);
if state.has_pending_screencopy(&output, false) if state.has_pending_screencopy(&output, false)
|| state.has_pending_screencopy(&output, true) || state.has_pending_screencopy(&output, true)
{ {
@@ -156,18 +164,24 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
&output, &output,
&scene, &scene,
); );
capture_changed_binding = true; changed = true;
} }
if capture_changed_binding {
// Restore the display framebuffer binding after offscreen readback. changed
},
|renderer, framebuffer| {
// Capture failures are handled locally. Failure to restore
// the display target means the renderer itself is unusable.
let _ = renderer let _ = renderer
.render( .render(
&mut framebuffer, framebuffer,
output.current_mode().expect("output has a mode").size, output.current_mode().unwrap().size,
output.current_transform(), output.current_transform(),
)? )?
.finish()?; .finish()?;
} Ok::<_, smithay::backend::renderer::gles::GlesError>(())
},
)?;
Ok((result.damage.cloned(), effects, result.states)) Ok((result.damage.cloned(), effects, result.states))
} }
@@ -212,19 +226,6 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
} }
render_metrics.record_frame(elapsed, &damage, missed_deadlines, effects); render_metrics.record_frame(elapsed, &damage, missed_deadlines, effects);
let mut presentation = crate::presentation::take_output_feedback(
state,
&output,
&rendered_states,
PresentationKind::Vsync,
);
sequence = sequence.wrapping_add(1);
presentation.presented(
clock.now(),
Refresh::fixed(refresh.get()),
sequence,
PresentationKind::Vsync,
);
send_nested_frame_callbacks(state, &output); send_nested_frame_callbacks(state, &output);
state.space.refresh(); state.space.refresh();
state.popups.cleanup(); state.popups.cleanup();
@@ -240,6 +241,74 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
Ok(()) Ok(())
} }
fn configure_nested_protocols(state: &mut Ferese) {
// Winit's swap has no host presentation timestamp or retrace counter.
// Do not advertise timing we cannot report accurately.
state
.display_handle
.disable_global::<Ferese>(state.presentation_state.global());
}
fn usable_size(size: Size<i32, Physical>) -> bool {
size.w > 0 && size.h > 0
}
fn resize_nested_output(
state: &mut Ferese,
output: &Output,
drawable: &Cell<bool>,
size: Size<i32, Physical>,
scale_factor: f64,
rate: i32,
) -> bool {
if !usable_size(size) {
drawable.set(false);
return false;
}
let mut runtime = state.current_desktop_outputs();
let Some(resized) = runtime.iter_mut().find(|entry| entry.output == *output) else {
drawable.set(false);
return false;
};
resized.mode = Mode {
size,
refresh: rate.max(1),
};
resized.scale = Scale::Fractional(normalized_scale(scale_factor));
// Paused host time must not advance springs when publication relayouts.
if !drawable.get() {
state.reset_animation_clock();
}
state.begin_desktop_transition();
let changes = match state.publish_desktop(runtime) {
Ok(changes) => changes,
Err(error) => {
state.desktop_transition = None;
drawable.set(false);
tracing::warn!(%error, "ignoring invalid nested output geometry");
return false;
}
};
state.finish_desktop_transition(changes);
drawable.set(true);
true
}
fn capture_with_display_restore<R, F, E>(
renderer: &mut R,
framebuffer: &mut F,
capture: impl FnOnce(&mut R, &mut F) -> bool,
restore: impl FnOnce(&mut R, &mut F) -> Result<(), E>,
) -> Result<(), E> {
if capture(renderer, framebuffer) {
restore(renderer, framebuffer)?;
}
Ok(())
}
fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output) { fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output) {
let eligible = state.callback_outputs(); let eligible = state.callback_outputs();
if state.session_lock.active() { if state.session_lock.active() {
@@ -268,7 +337,196 @@ fn normalized_scale(scale: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::normalized_scale; use super::*;
#[test]
fn zero_host_resize_preserves_geometry_and_recovers() {
if !crate::startup_tests::private_runtime("winit::tests::zero_host_resize_preserves_geometry_and_recovers") {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = Output::new(
"nested".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (800, 600).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "nested-primary".into());
let drawable = Cell::new(true);
assert!(resize_nested_output(
&mut state,
&output,
&drawable,
(900, 600).into(),
1.25,
60_000
));
let before = output.current_mode();
for size in [(0, 600), (900, 0), (0, 0), (-1, 600)] {
assert!(!resize_nested_output(
&mut state,
&output,
&drawable,
size.into(),
2.0,
144_000
));
assert_eq!(output.current_mode(), before);
assert_eq!(output.current_scale().fractional_scale(), 1.25);
assert!(!drawable.get());
assert!(state.desktop_transition.is_none());
}
assert!(resize_nested_output(
&mut state,
&output,
&drawable,
(1200, 800).into(),
1.5,
120_000
));
assert_eq!(output.current_mode().unwrap().size, (1200, 800).into());
assert!(drawable.get());
assert!(state.desktop_transition.is_none());
}
#[test]
fn nested_registry_does_not_advertise_unobserved_presentation_timing() {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
if !crate::startup_tests::private_runtime(
"winit::tests::nested_registry_does_not_advertise_unobserved_presentation_timing",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
configure_nested_protocols(&mut state);
let (server, mut wire) = UnixStream::pair().unwrap();
let _client = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
wire.write_all(
&[1u32, 12 << 16 | 1, 2]
.into_iter()
.flat_map(u32::to_ne_bytes)
.collect::<Vec<_>>(),
)
.unwrap();
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
state.display_handle.flush_clients().unwrap();
wire.set_nonblocking(true).unwrap();
let mut globals = Vec::new();
let _ = wire.read_to_end(&mut globals);
assert!(
globals
.windows(b"wl_compositor\0".len())
.any(|bytes| bytes == b"wl_compositor\0")
);
assert!(
!globals
.windows(b"wp_presentation\0".len())
.any(|bytes| bytes == b"wp_presentation\0")
);
}
#[test]
#[ignore = "requires an EGL device and private Wayland sockets"]
fn capture_failure_restores_display_target_and_subsequent_frames_render() {
use smithay::backend::allocator::Fourcc;
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::gles::{GlesError, GlesTexture};
use smithay::backend::renderer::{Bind, Color32F, ExportMem, Offscreen};
use smithay::reexports::calloop::channel;
use smithay::utils::Buffer;
if !crate::startup_tests::private_runtime(
"winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let display = unsafe { EGLDisplay::new(EGLDevice::enumerate().unwrap().last().unwrap()).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = Output::new(
"nested".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let size: Size<i32, Physical> = (32, 24).into();
let mut displayed: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (32, 24).into()).unwrap();
let mut offscreen: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (32, 24).into()).unwrap();
let damage = [Rectangle::from_size(size)];
let mut framebuffer = renderer.bind(&mut displayed).unwrap();
for changed_binding in [false, true] {
let (sender, receiver) = channel::channel();
state
.pending_screencopies
.push(crate::handlers::screencopy::PendingScreencopy::owned(
1,
0,
sender,
output.clone(),
Rectangle::<i32, Buffer>::from_size((32, 24).into()),
));
capture_with_display_restore(
&mut renderer,
&mut framebuffer,
|renderer, _| {
if changed_binding {
let _target = renderer.bind(&mut offscreen).unwrap();
}
assert!(
crate::backends::direct::capture::capture_request(&mut state, &output, false, || {
Err::<(), _>("injected offscreen allocation/render failure")
})
.is_none()
);
true
},
|renderer, framebuffer| {
let _ = renderer.render(framebuffer, size, Transform::Normal)?.finish()?;
Ok::<_, GlesError>(())
},
)
.unwrap();
assert!(receiver.try_recv().unwrap().result.is_err());
assert!(receiver.try_recv().is_err());
assert!(state.pending_screencopies.is_empty());
let mut frame = renderer.render(&mut framebuffer, size, Transform::Normal).unwrap();
frame.clear(Color32F::new(0.0, 0.0, 1.0, 1.0), &damage).unwrap();
frame.finish().unwrap().wait().unwrap();
let pixels = renderer
.copy_framebuffer(&framebuffer, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
.unwrap();
assert_eq!(&renderer.map_texture(&pixels).unwrap()[..4], &[0, 0, 255, 255]);
}
}
#[test] #[test]
fn accepts_positive_finite_scale() { fn accepts_positive_finite_scale() {
assert_eq!(normalized_scale(1.25), 1.25); assert_eq!(normalized_scale(1.25), 1.25);
-155
View File
@@ -1,155 +0,0 @@
# Performance measurements
Use release builds and a fixed workload when comparing performance. Record the
commit, output size, scale, refresh rate, graphics driver, and whether the session
is nested or direct. Keep the nested preview visible: host occlusion changes
rendering behavior.
## Collect a sample
```sh
cargo build --release --locked -p ferese -p ferese-shell -p feresectl
FERESE_TRACE_PERFORMANCE=1 target/release/ferese --backend nested \
--grant-effects --grant-shell-control -- target/release/ferese-shell
```
Measure idle, several windows, overview, Settings navigation, translucent popovers
and screen capture separately. Wait for startup to settle, then repeat each sample.
Record process CPU, child-process CPU, RSS, render attempts and damaged frames.
Do not build or run unrelated workloads during the sample.
CPU percentages below use one core as 100%. These short samples describe observed
behavior; they are not guarantees or hardware-session benchmarks.
## Direct frame timing
The DRM backend schedules each output against its own predicted presentation
time. Render cost and a safety margin determine when compositing starts. Idle
outputs wake on demand; callback-only updates are paced to the output refresh
cycle without submitting unchanged buffers. Animation forecasts are restored
before processing input or rendering another output.
For a direct session, enable `FERESE_TRACE_PERFORMANCE=1` and
`RUST_LOG=ferese::render=debug` before starting Ferese. The timing records include
the planned render start, actual render start, timer lateness, presentation
target, DRM presentation timestamp, and request-to-presentation duration.
Missed deadlines are counted against submitted frames' presentation targets;
idle gaps do not count. Request-to-presentation timing starts at the compositor's
redraw request. It does not include device input latency, client rendering before
the request or the display's pixel response. Render cost measures CPU preparation
and queueing. Feedback-driven margins allow conservatively for asynchronous GPU work.
Check sustained animation, idle wakeup, callback-only clients, and mixed-refresh
outputs separately. Nested sessions do not exercise the DRM scheduler.
## Redraw and animation fallback counters
With the same environment variables, direct sessions report `redraw requests`
by source file and line, with call counts, requested outputs and connected outputs.
`render performance` adds per-output redraw requests and counts requests made
while a render timer was scheduled or a frame was in flight. Compare these with
`frames`, `no_damage_frames`, render time and missed deadlines; requests can
coalesce and are not themselves rendered frames.
`animation fallback scheduling` reports watchdog arms, cancellations, wakeups,
recovery requests and animations whose normal frame chain is intact. Ferese emits
summaries every five seconds during redraw activity and on normal exit, resetting
the fallback counters each time. When an animation loses its output's frame chain,
the watchdog deadline is two refresh intervals later. Recovery keeps a requested
final frame even if another output settles the animation while scheduling is
unavailable. Idle, powered-off and disconnected outputs do not set that deadline.
The watchdog is suppressed when a render is already scheduled or a page flip is
pending. It does not replace DRM error recovery or diagnose a permanently lost flip.
The regression replays use a 60 Hz animation beside an idle 240 Hz output.
The old fallback rule produces 60 unnecessary wakes in one simulated second;
the new rule produces zero while the normal chain remains intact. Three cursor
moves within one output require three output requests rather than the old six.
These counts come from deterministic scheduling replays. They do not measure live
DRM CPU use, latency or battery consumption. Measure those separately with the same
hardware workload before claiming power savings.
## Settled overview damage
On 2026-10-02, a release-build comparison against `6f2951c` used four parked
foot terminals in a private nested desktop: 1418 × 1692 pixels, 2× scale, 60 Hz,
Intel Core Ultra 7 258V and Mesa 26.2.3. Ten-second process samples measured
5.9% CPU before and 1.9% after stabilizing caption/thumbnail drawing order and
removing forced full damage from unchanged rounded thumbnails.
A settled five-second interval changed from 300 damaged frames to zero.
The nested backend still makes about 300 no-damage render attempts per interval.
The change skips unchanged submissions; it does not alter nested scheduling.
These short samples exclude host compositor CPU and do not measure battery
discharge or direct DRM performance.
The opt-in regression checks a full settled interval, a real client content
commit, and settling again. It fails against the original release.
```sh
FERESE_TEST_OVERVIEW_DAMAGE=1 FERESE_TEST_BINARY=target/release/ferese \
FERESE_TEST_CTL=target/release/feresectl \
python3 scripts/tests/test_overview_damage_isolated.py
```
## Idle shell audio polling
On 2026-10-02, the installed release shell was compared with the audio-event
cache in private nested desktops on the same machine. Each release had ten
seconds to settle, followed by three twenty-second samples without `perf`.
CPU includes waited child processes; one core is 100%.
| Measurement | Periodic audio queries | Persistent audio watcher |
| --- | --- | --- |
| Combined CPU, sample range | 2.25–3.15% | 0.90% |
| Combined CPU, median | 3.10% | 0.90% |
| Shell CPU, median | 0.75% | 0.75% |
| Child-process CPU, median | 2.35% | 0.15% |
| Shell RSS | 32.3–32.4 MiB | 34.9 MiB |
A separate twenty-second `perf` sample recorded twenty `wpctl` executions before
and zero afterward. The new connection observes sink properties, device routes,
and default-sink metadata. Relevant events and control actions invalidate the
cached reading; the existing `wpctl` reader preserves volume and device semantics.
Disconnected subscriptions retain bounded polling, and missing sinks retry.
Brightness and other status polling remain unchanged.
These short nested samples exclude the host compositor and do not measure battery
discharge. Raw measurements and profiles were saved locally in
`/tmp/ferese-shell-profile-20261002`.
The subscription regression starts its own PipeWire server, changes default
metadata, mute and volume, removes a sink, restarts the server, and checks shutdown.
It does not change the desktop's audio server.
```sh
cargo test --release --locked -p ferese-shell native_subscription -- --ignored
```
## Historical results
On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,
reporting 60 Hz on a 120 Hz host. Samples lasted 5–10 seconds.
| Scenario | Compositor CPU, two runs | Shell CPU, two runs |
| --- | --- | --- |
| Idle, no application window | 5.3% / 5.8% | 1.5% / 1.6% |
| Overview with one terminal | 18.6% / 16.2% | 1.3% / 1.4% |
| After transitions | 6.6% / 6.4% | 1.5% / 1.4% |
Compositor RSS increased from about 81–82 MiB to 91–92 MiB during the exercise.
These samples do not establish a leak or long-term stability. A follow-up had
lower rendering rates, so the original observations did not prove duplicate scheduling.
A 20-second status-worker comparison removed five commands per poll (15 to 10).
Total measured CPU time including child processes fell from 2.41 to 1.90 seconds.
The timing wrapper was included, and this was one comparison.
For push status delivery (`4bf0757` to `b0583eb`), a synthetic transport test
measured median delivery delay of 129.398 ms before and 0.130 ms after. It excluded
queries, UI dispatch, and painting. Visible shell samples suggested lower typical
CPU, but an after-run spike raised the mean; they did not establish a robust
average CPU saving.
These results predate later changes. Run fresh comparisons for current claims,
and validate direct rendering and multiple monitors separately.
+5 -2
View File
@@ -42,8 +42,11 @@ remembered InputCapture grants are not supported. InputCapture forwards local
input; it does not inject remote input. input; it does not inject remote input.
Wallpaper approval copies the image into Ferese's data directory and updates the Wallpaper approval copies the image into Ferese's data directory and updates the
config. Set `theme.background.lock-path` for a separate lock-screen image; otherwise active light or dark appearance in the config. The other appearance keeps its
it follows the desktop wallpaper. wallpaper. A desktop-only change keeps the current lock-screen image, and a
lock-screen-only change keeps the desktop image. Set `theme.background.lock-path`
for a shared lock-screen image, or override it under `theme.light.background` and
`theme.dark.background`. Without a lock-screen path, it follows the desktop wallpaper.
Global shortcuts cannot replace Ferese bindings or reserved escape/console keys. Global shortcuts cannot replace Ferese bindings or reserved escape/console keys.
They stop when the app session closes. They never activate on the lock screen. They stop when the app session closes. They never activate on the lock screen.
+135 -16
View File
@@ -8,7 +8,7 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use tokio::sync::{Mutex, Notify}; use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, Semaphore};
use zbus::Connection; use zbus::Connection;
use zbus::message::Header; use zbus::message::Header;
use zbus::object_server::SignalEmitter; use zbus::object_server::SignalEmitter;
@@ -22,6 +22,7 @@ pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(()); pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(());
const FRONTEND: &str = "org.freedesktop.portal.Desktop"; const FRONTEND: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop"; const PATH: &str = "/org/freedesktop/portal/desktop";
const MAX_SESSIONS: usize = 8;
#[derive(Debug)] #[derive(Debug)]
enum StartError { enum StartError {
@@ -82,11 +83,22 @@ struct Session {
persist_mode: u32, persist_mode: u32,
restore: Option<crate::restore::Restore>, restore: Option<crate::restore::Restore>,
cancel: Arc<Cancel>, cancel: Arc<Cancel>,
work: Arc<OwnedSemaphorePermit>,
} }
#[derive(Clone, Default)] #[derive(Clone)]
pub struct Backend { pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>, sessions: Arc<Mutex<HashMap<String, Session>>>,
work: Arc<Semaphore>,
}
impl Default for Backend {
fn default() -> Self {
Self {
sessions: Arc::default(),
work: Arc::new(Semaphore::new(MAX_SESSIONS)),
}
}
} }
// This private interface only transfers stop controls to the shell's own recorder. // This private interface only transfers stop controls to the shell's own recorder.
@@ -252,11 +264,17 @@ impl Backend {
return Err(error("Invalid session")); return Err(error("Invalid session"));
} }
let mut sessions = self.sessions.lock().await; let mut sessions = self.sessions.lock().await;
if sessions.len() >= 8 || sessions.contains_key(session_handle.as_str()) { if sessions.len() >= MAX_SESSIONS || sessions.contains_key(session_handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded( return Err(zbus::fdo::Error::LimitsExceeded(
"Session limit reached or duplicate session".into(), "Session limit reached or duplicate session".into(),
)); ));
} }
let work = Arc::new(
self.work
.clone()
.try_acquire_owned()
.map_err(|_| zbus::fdo::Error::LimitsExceeded("Capture work is still shutting down".into()))?,
);
let inserted = connection let inserted = connection
.object_server() .object_server()
.at( .at(
@@ -284,6 +302,7 @@ impl Backend {
persist_mode: 0, persist_mode: 0,
restore: None, restore: None,
cancel: Arc::default(), cancel: Arc::default(),
work,
}, },
); );
Ok((0, Options::new())) Ok((0, Options::new()))
@@ -347,7 +366,7 @@ impl Backend {
return Err(error("Invalid request path")); return Err(error("Invalid request path"));
} }
let (multiple, cursor, source_types, bar_controlled, persist_mode, restore, cancel) = { let (multiple, cursor, source_types, bar_controlled, persist_mode, restore, cancel, work) = {
let mut sessions = self.sessions.lock().await; let mut sessions = self.sessions.lock().await;
let session = sessions let session = sessions
.get_mut(session_handle.as_str()) .get_mut(session_handle.as_str())
@@ -366,6 +385,7 @@ impl Backend {
session.persist_mode, session.persist_mode,
session.restore.clone(), session.restore.clone(),
session.cancel.clone(), session.cancel.clone(),
session.work.clone(),
) )
}; };
let inserted = connection let inserted = connection
@@ -400,6 +420,8 @@ impl Backend {
requested_persistence: persist_mode, requested_persistence: persist_mode,
restore, restore,
bar_controlled, bar_controlled,
cancel: cancel.clone(),
work,
}, },
); );
let result = tokio::select! { let result = tokio::select! {
@@ -543,6 +565,39 @@ struct StreamOptions {
requested_persistence: u32, requested_persistence: u32,
restore: Option<crate::restore::Restore>, restore: Option<crate::restore::Restore>,
bar_controlled: bool, bar_controlled: bool,
cancel: Arc<Cancel>,
work: Arc<OwnedSemaphorePermit>,
}
async fn capture_work<T: Send + 'static>(
cancel: Arc<Cancel>,
permit: Arc<OwnedSemaphorePermit>,
operation: impl FnOnce(&AtomicBool) -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
tokio::task::spawn_blocking(move || {
// A dropped async future does not stop spawn_blocking. This lease
// stays with the worker until its connection and work are released.
let _permit = permit;
if cancel.stopped.load(Ordering::SeqCst) {
return Err("Capture cancelled".into());
}
operation(&cancel.stopped)
})
.await
.map_err(|error| error.to_string())?
}
async fn capture_ipc(
cancel: &Arc<Cancel>,
work: &Arc<OwnedSemaphorePermit>,
command: &'static str,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
capture_work(cancel.clone(), work.clone(), move |_| {
crate::desktop::ipc_sync(command, args)
})
.await
} }
async fn start_streams( async fn start_streams(
@@ -557,20 +612,20 @@ async fn start_streams(
requested_persistence, requested_persistence,
restore, restore,
bar_controlled, bar_controlled,
cancel,
work,
} = options; } = options;
let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false))) let mut discovery = capture_work(cancel.clone(), work.clone(), Capture::connect).await?;
.await
.map_err(|e| e.to_string())??;
let mut sources = if source_types & 1 != 0 { let mut sources = if source_types & 1 != 0 {
discovery.sources() discovery.sources()
} else { } else {
Vec::new() Vec::new()
}; };
if source_types & 2 != 0 && discovery.supports_windows() { if source_types & 2 != 0 && discovery.supports_windows() {
let windows = crate::desktop::ipc("get-windows", serde_json::json!({})).await?; let windows = capture_ipc(&cancel, &work, "get-windows", serde_json::json!({})).await?;
sources.extend(crate::desktop::sharing_window_sources(&windows)?); sources.extend(crate::desktop::sharing_window_sources(&windows)?);
} }
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; let outputs = capture_ipc(&cancel, &work, "get-outputs", serde_json::json!({})).await?;
let rememberable = sources let rememberable = sources
.iter() .iter()
.filter(|source| { .filter(|source| {
@@ -616,13 +671,21 @@ async fn start_streams(
return Err("Invalid persistence selection".into()); return Err("Invalid persistence selection".into());
} }
let selected = validate_selection(&prompt.sources, &selection.names, multiple)?; let selected = validate_selection(&prompt.sources, &selection.names, multiple)?;
crate::desktop::wait_for_surface_removal(picker_pid).await?; crate::desktop::wait_for_surface_removal_using(|| {
capture_ipc(
&cancel,
&work,
"has-client-surfaces",
serde_json::json!({"pid": picker_pid}),
)
})
.await?;
(selected, selection.persist_mode) (selected, selection.persist_mode)
}; };
let mut reply = Options::new(); let mut reply = Options::new();
let fresh_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; let fresh_outputs = capture_ipc(&cancel, &work, "get-outputs", serde_json::json!({})).await?;
let monitors = selected let monitors = selected
.iter() .iter()
@@ -698,11 +761,12 @@ async fn start_streams(
children.push(child); children.push(child);
} }
tokio::task::spawn_blocking(move || discovery.validate_sources(&generations)) capture_work(cancel.clone(), work.clone(), move |stop| {
.await discovery.validate_sources(&generations, stop)
.map_err(|error| error.to_string())??; })
.await?;
let final_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?; let final_outputs = capture_ipc(&cancel, &work, "get-outputs", serde_json::json!({})).await?;
if !crate::restore::selection_unchanged(&monitors, &outputs, &final_outputs) { if !crate::restore::selection_unchanged(&monitors, &outputs, &final_outputs) {
return Err("A selected display changed during stream startup".into()); return Err("A selected display changed during stream startup".into());
} }
@@ -711,7 +775,7 @@ async fn start_streams(
return Err("A selected stream ended during startup".into()); return Err("A selected stream ended during startup".into());
} }
let final_windows = crate::desktop::ipc("get-windows", serde_json::json!({})).await?; let final_windows = capture_ipc(&cancel, &work, "get-windows", serde_json::json!({})).await?;
let streams = streams let streams = streams
.into_iter() .into_iter()
.map(|(name, ready)| { .map(|(name, ready)| {
@@ -914,6 +978,61 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn cancelled_async_work_holds_session_capacity_until_worker_exits() {
let backend = Backend::default();
let cancel = Arc::new(Cancel::default());
let permits: Vec<_> = (0..MAX_SESSIONS)
.map(|_| Arc::new(backend.work.clone().try_acquire_owned().unwrap()))
.collect();
let (started, received) = tokio::sync::oneshot::channel();
let (release, blocked) = std::sync::mpsc::channel();
let permit = permits[0].clone();
let flag = cancel.clone();
let task = tokio::spawn(async move {
capture_work(flag, permit, move |stop| {
started.send(()).unwrap();
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
assert!(stop.load(Ordering::SeqCst));
Ok(())
})
.await
});
received.await.unwrap();
cancel.stop();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
// End the corresponding session while its blocking worker is alive.
let mut permits = permits;
permits.remove(0);
assert!(backend.work.clone().try_acquire_owned().is_err());
release.send(()).unwrap();
let recovered = tokio::time::timeout(Duration::from_secs(1), backend.work.clone().acquire_owned())
.await
.unwrap()
.unwrap();
assert_eq!(recovered.num_permits(), 1);
assert_eq!(backend.work.available_permits(), 0);
drop(recovered);
drop(permits);
assert_eq!(backend.work.available_permits(), MAX_SESSIONS);
}
#[tokio::test]
async fn already_cancelled_capture_never_starts_blocking_discovery() {
let backend = Backend::default();
let cancel = Arc::new(Cancel::default());
cancel.stop();
let permit = Arc::new(backend.work.clone().try_acquire_owned().unwrap());
let result = capture_work(cancel, permit, |_| -> Result<(), String> {
panic!("Cancelled discovery must not start");
})
.await;
assert!(result.unwrap_err().contains("cancelled"));
assert_eq!(backend.work.available_permits(), MAX_SESSIONS);
}
#[test] #[test]
fn bar_control_claims_are_bound_to_the_recorder_connection() { fn bar_control_claims_are_bound_to_the_recorder_connection() {
assert!(owns_session( assert!(owns_session(
+122 -26
View File
@@ -1,15 +1,25 @@
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use serde_json::Value as Json; use serde_json::Value as Json;
use tokio::sync::Notify;
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct Bridge(Arc<BridgeInner>); pub(crate) struct Bridge(Arc<BridgeInner>);
struct Connection {
stream: UnixStream,
id: u64,
}
struct BridgeInner { struct BridgeInner {
stream: std::sync::Mutex<UnixStream>, connection: std::sync::Mutex<Connection>,
shutdown: UnixStream, shutdown: UnixStream,
closed: AtomicBool,
changed: Notify,
timeout: Duration,
} }
impl Bridge { impl Bridge {
@@ -19,19 +29,32 @@ impl Bridge {
.ok_or("Missing runtime directory")? .ok_or("Missing runtime directory")?
.join("ferese/control.sock"); .join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?; let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
Self::from_stream(stream, Duration::from_secs(2))
}
fn from_stream(stream: UnixStream, timeout: Duration) -> Result<Self, String> {
stream stream
.set_read_timeout(Some(Duration::from_secs(2))) .set_read_timeout(Some(timeout))
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
stream stream
.set_write_timeout(Some(Duration::from_secs(2))) .set_write_timeout(Some(timeout))
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let shutdown = stream.try_clone().map_err(|error| error.to_string())?; let shutdown = stream.try_clone().map_err(|error| error.to_string())?;
Ok(Self(Arc::new(BridgeInner { Ok(Self(Arc::new(BridgeInner {
stream: std::sync::Mutex::new(stream), connection: std::sync::Mutex::new(Connection { stream, id: 0 }),
shutdown, shutdown,
closed: AtomicBool::new(false),
changed: Notify::new(),
timeout,
}))) })))
} }
#[cfg(test)]
pub(crate) fn test_pair() -> (Self, UnixStream) {
let (stream, peer) = UnixStream::pair().unwrap();
(Self::from_stream(stream, Duration::from_secs(1)).unwrap(), peer)
}
pub(crate) async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> { pub(crate) async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> {
self.request(command, args, false).await self.request(command, args, false).await
} }
@@ -43,28 +66,68 @@ impl Bridge {
async fn request(&self, command: &'static str, args: Json, wait: bool) -> Result<Json, String> { async fn request(&self, command: &'static str, args: Json, wait: bool) -> Result<Json, String> {
let bridge = self.clone(); let bridge = self.clone();
tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || bridge.request_sync(command, args, wait)).await {
let mut stream = bridge.0.stream.lock().map_err(|_| "Compositor connection failed")?; Ok(result) => result,
stream Err(error) => {
.set_read_timeout(if wait { None } else { Some(Duration::from_secs(2)) }) self.close();
.map_err(|error| error.to_string())?; Err(error.to_string())
}
}
}
fn request_sync(&self, command: &'static str, args: Json, wait: bool) -> Result<Json, String> {
let mut connection = match self.0.connection.lock() {
Ok(connection) => connection,
Err(_) => {
self.close();
return Err("Compositor connection failed".into());
}
};
let response = (|| {
if self.is_closed() {
return Err("Compositor connection is closed".to_owned());
}
connection.id = connection.id.checked_add(1).ok_or("Compositor request IDs exhausted")?;
let request = ferese_ipc::Request { let request = ferese_ipc::Request {
version: ferese_ipc::VERSION, version: ferese_ipc::VERSION,
id: 1, id: connection.id,
kind: "command".into(), kind: "command".into(),
command: command.into(), command: command.into(),
args, args,
}; };
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?; connection
let response: ferese_ipc::Response = .stream
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?; .set_read_timeout(if wait { None } else { Some(self.0.timeout) })
if let Some(error) = response.error { .map_err(|error| error.to_string())?;
return Err(error.message); ferese_ipc::write_frame(&mut connection.stream, &request).map_err(|error| error.to_string())?;
let value: Json = ferese_ipc::read_frame(&mut connection.stream).map_err(|error| error.to_string())?;
let has_result = value.get("result").is_some();
let response: ferese_ipc::Response = serde_json::from_value(value).map_err(|error| error.to_string())?;
if response.id != request.id || response.version != ferese_ipc::VERSION {
return Err("Unexpected compositor response ID or protocol version".into());
}
if has_result == response.error.is_some() {
return Err("Invalid compositor response outcome".into());
}
Ok(response)
})();
match response {
Ok(response) => match response.error {
Some(error) => Err(error.message),
None => Ok(response.result.unwrap_or(Json::Null)),
},
Err(error) => {
// Poison the stream before releasing the request lock so a
// queued caller cannot read a late or partial response.
self.close();
Err(error)
}
} }
response.result.ok_or("Missing compositor response".into())
})
.await
.map_err(|error| error.to_string())?
} }
pub(crate) async fn capture_watch(&self, session: u64) -> Result<Json, String> { pub(crate) async fn capture_watch(&self, session: u64) -> Result<Json, String> {
@@ -72,24 +135,52 @@ impl Bridge {
.await .await
} }
pub(crate) fn is_closed(&self) -> bool {
self.0.closed.load(Ordering::SeqCst)
}
pub(crate) async fn terminated(&self) {
let changed = self.0.changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
if !self.is_closed() {
changed.await;
}
}
pub(crate) async fn closed(&self) { pub(crate) async fn closed(&self) {
use std::io::Read;
let Ok(reader) = self.0.shutdown.try_clone() else { let Ok(reader) = self.0.shutdown.try_clone() else {
return; return;
}; };
if reader.set_nonblocking(true).is_err() {
return;
}
let Ok(reader) = tokio::io::unix::AsyncFd::new(reader) else { let Ok(reader) = tokio::io::unix::AsyncFd::new(reader) else {
return; return;
}; };
loop { loop {
let Ok(mut ready) = reader.readable().await else { let readable = tokio::select! {
_ = self.terminated() => return,
ready = reader.readable() => ready,
};
let Ok(mut ready) = readable else {
return; return;
}; };
match ready.try_io(|reader| { match ready.try_io(|reader| {
let mut socket = reader.get_ref(); use std::os::fd::AsRawFd;
socket.read(&mut [0_u8; 1]) let mut byte = 0u8;
// Per-call nonblocking peek leaves the shared descriptor flags
// and response bytes untouched for the request reader.
let result = unsafe {
libc::recv(
reader.get_ref().as_raw_fd(),
(&mut byte as *mut u8).cast(),
1,
libc::MSG_PEEK | libc::MSG_DONTWAIT,
)
};
if result < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(result)
}
}) { }) {
Ok(_) => return, Ok(_) => return,
Err(_) => continue, Err(_) => continue,
@@ -98,6 +189,11 @@ impl Bridge {
} }
pub(crate) fn close(&self) { pub(crate) fn close(&self) {
self.0.closed.store(true, Ordering::SeqCst);
let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both); let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both);
self.0.changed.notify_waiters();
} }
} }
#[cfg(test)]
mod tests;
+158
View File
@@ -0,0 +1,158 @@
use super::*;
use ferese_ipc::{Request, Response, read_frame, write_frame};
use serde_json::json;
use std::io::{Read, Write};
fn pair() -> (Bridge, UnixStream) {
pair_with_timeout(Duration::from_secs(1))
}
fn pair_with_timeout(timeout: Duration) -> (Bridge, UnixStream) {
let (client, server) = UnixStream::pair().unwrap();
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
(Bridge::from_stream(client, timeout).unwrap(), server)
}
#[tokio::test]
async fn valid_errors_preserve_connection_and_request_ids_are_unique() {
let (bridge, mut server) = pair();
let peer = std::thread::spawn(move || {
let first: Request = read_frame(&mut server).unwrap();
write_frame(&mut server, &Response::error(first.id, "denied", "Denied")).unwrap();
let second: Request = read_frame(&mut server).unwrap();
assert_eq!(second.id, first.id + 1);
write_frame(&mut server, &Response::success(second.id, json!({"enabled":false}))).unwrap();
let third: Request = read_frame(&mut server).unwrap();
assert_eq!(third.id, second.id + 1);
write_frame(&mut server, &Response::success(third.id, Json::Null)).unwrap();
});
assert_eq!(bridge.call("enable", json!({})).await.unwrap_err(), "Denied");
assert!(!bridge.is_closed());
assert_eq!(
bridge.call("disable", json!({})).await.unwrap(),
json!({"enabled":false})
);
assert!(!bridge.is_closed());
assert_eq!(bridge.call("empty-result", json!({})).await.unwrap(), Json::Null);
peer.join().unwrap();
}
#[tokio::test]
async fn timeout_closes_stream_before_a_late_reply_can_be_reused() {
let (bridge, mut server) = pair_with_timeout(Duration::from_millis(40));
let (release, blocked) = std::sync::mpsc::channel();
let peer = std::thread::spawn(move || {
let request: Request = read_frame(&mut server).unwrap();
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
assert!(write_frame(&mut server, &Response::success(request.id, json!({"enabled":true}))).is_err());
assert_eq!(server.read(&mut [0u8; 1]).unwrap(), 0);
});
assert!(bridge.call("enable", json!({})).await.is_err());
assert!(bridge.is_closed());
tokio::time::timeout(Duration::from_secs(1), bridge.terminated())
.await
.unwrap();
assert!(bridge.call("disable", json!({})).await.unwrap_err().contains("closed"));
release.send(()).unwrap();
peer.join().unwrap();
}
#[tokio::test]
async fn partial_reply_timeout_cannot_corrupt_the_next_frame() {
let (bridge, mut server) = pair_with_timeout(Duration::from_millis(40));
let (release, blocked) = std::sync::mpsc::channel();
let peer = std::thread::spawn(move || {
let _: Request = read_frame(&mut server).unwrap();
server.write_all(&20u32.to_be_bytes()).unwrap();
server.write_all(b"{\"id\":").unwrap();
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(server.read(&mut [0u8; 1]).unwrap(), 0);
});
assert!(bridge.call("enable", json!({})).await.is_err());
assert!(bridge.is_closed());
assert!(bridge.call("disable", json!({})).await.is_err());
release.send(()).unwrap();
peer.join().unwrap();
}
#[tokio::test]
async fn malformed_or_mismatched_replies_poison_the_bridge() {
for case in 0..6 {
let (bridge, mut server) = pair();
let peer = std::thread::spawn(move || {
let request: Request = read_frame(&mut server).unwrap();
let mut response = Response::success(request.id, json!({}));
match case {
0 => response.id += 1,
1 => response.version += 1,
2 => response.result = None,
3 => response.error = Response::error(request.id, "failed", "Failed").error,
4 => {
server.write_all(&1u32.to_be_bytes()).unwrap();
server.write_all(b"{").unwrap();
return;
}
_ => {
server.write_all(&u32::MAX.to_be_bytes()).unwrap();
return;
}
}
write_frame(&mut server, &response).unwrap();
});
assert!(bridge.call("enable", json!({})).await.is_err(), "case {case}");
assert!(bridge.is_closed(), "case {case}");
assert!(bridge.call("disable", json!({})).await.is_err());
peer.join().unwrap();
}
}
#[tokio::test]
async fn closing_any_clone_interrupts_a_blocked_watch() {
let (bridge, mut server) = pair();
let (started, received) = tokio::sync::oneshot::channel();
let peer = std::thread::spawn(move || {
let request: Request = read_frame(&mut server).unwrap();
assert_eq!(request.command, "input-capture-watch");
started.send(()).unwrap();
assert_eq!(server.read(&mut [0u8; 1]).unwrap(), 0);
});
let watcher = bridge.clone();
let task = tokio::spawn(async move { watcher.capture_watch(7).await });
received.await.unwrap();
bridge.close();
assert!(
tokio::time::timeout(Duration::from_secs(1), task)
.await
.unwrap()
.unwrap()
.is_err()
);
assert!(bridge.call("enable", json!({})).await.is_err());
peer.join().unwrap();
}
#[tokio::test]
async fn peer_watch_keeps_descriptor_flags_and_response_bytes_untouched() {
use std::os::fd::AsRawFd;
let (bridge, mut server) = pair();
let flags = unsafe { libc::fcntl(bridge.0.shutdown.as_raw_fd(), libc::F_GETFL) };
server.write_all(b"x").unwrap();
tokio::time::timeout(Duration::from_secs(1), bridge.closed())
.await
.unwrap();
assert_eq!(
unsafe { libc::fcntl(bridge.0.shutdown.as_raw_fd(), libc::F_GETFL) },
flags
);
let mut byte = [0u8; 1];
bridge
.0
.connection
.lock()
.unwrap()
.stream
.read_exact(&mut byte)
.unwrap();
assert_eq!(byte, *b"x");
}
+41 -2
View File
@@ -87,6 +87,10 @@ pub struct Capture {
impl Capture { impl Capture {
pub fn connect(stop: &AtomicBool) -> Result<Self, String> { pub fn connect(stop: &AtomicBool) -> Result<Self, String> {
let connection = Connection::connect_to_env().map_err(|e| e.to_string())?; let connection = Connection::connect_to_env().map_err(|e| e.to_string())?;
Self::connect_with_connection(connection, stop)
}
fn connect_with_connection(connection: Connection, stop: &AtomicBool) -> Result<Self, String> {
let queue = connection.new_event_queue(); let queue = connection.new_event_queue();
connection.display().get_registry(&queue.handle(), ()); connection.display().get_registry(&queue.handle(), ());
let mut this = Self { let mut this = Self {
@@ -135,8 +139,8 @@ impl Capture {
Ok(()) Ok(())
} }
pub fn validate_sources(&mut self, selected: &[(String, u32)]) -> Result<(), String> { pub fn validate_sources(&mut self, selected: &[(String, u32)], stop: &AtomicBool) -> Result<(), String> {
self.sync(&AtomicBool::new(false))?; self.sync(stop)?;
for (name, generation) in selected { for (name, generation) in selected {
if self.generation(name) != Some(*generation) { if self.generation(name) != Some(*generation) {
return Err("A selected display disconnected during approval".into()); return Err("A selected display disconnected during approval".into());
@@ -534,6 +538,41 @@ impl Dispatch<window_manager::FereseWindowCaptureManagerV1, ()> for State {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn discovery_and_final_validation_cancel_on_a_stalled_compositor() {
use std::io::Read;
use std::os::unix::net::UnixStream;
for discovery in [true, false] {
let (client, mut server) = UnixStream::pair().unwrap();
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let flag = stop.clone();
let worker = std::thread::spawn(move || {
let connection = Connection::from_socket(client).unwrap();
if discovery {
Capture::connect_with_connection(connection, &flag).err().unwrap()
} else {
let queue = connection.new_event_queue();
let mut capture = Capture {
connection,
queue,
state: State::default(),
selected_output: None,
};
capture.validate_sources(&[], &flag).unwrap_err()
}
});
// Wait for the real Wayland request before cancelling its roundtrip.
assert!(server.read(&mut [0u8; 64]).unwrap() > 0);
let cancelled = Instant::now();
stop.store(true, Ordering::SeqCst);
assert!(worker.join().unwrap().to_lowercase().contains("cancel"));
assert!(cancelled.elapsed() < Duration::from_secs(1));
}
}
#[test] #[test]
fn capture_orientation_matches_presentation_for_all_transforms() { fn capture_orientation_matches_presentation_for_all_transforms() {
use wl_output::Transform::*; use wl_output::Transform::*;
+205 -25
View File
@@ -235,7 +235,7 @@ pub(crate) async fn ipc(command: &'static str, args: serde_json::Value) -> Resul
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
} }
fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> { pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR") let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from) .map(PathBuf::from)
.ok_or("Missing runtime directory")? .ok_or("Missing runtime directory")?
@@ -294,8 +294,16 @@ async fn select_geometry(point: bool) -> Result<Option<String>, String> {
} }
pub(crate) async fn wait_for_surface_removal(pid: u32) -> Result<(), String> { pub(crate) async fn wait_for_surface_removal(pid: u32) -> Result<(), String> {
wait_for_surface_removal_using(|| ipc("has-client-surfaces", serde_json::json!({"pid": pid}))).await
}
pub(crate) async fn wait_for_surface_removal_using<
F: std::future::Future<Output = Result<serde_json::Value, String>>,
>(
mut check: impl FnMut() -> F,
) -> Result<(), String> {
for _ in 0..100 { for _ in 0..100 {
if ipc("has-client-surfaces", serde_json::json!({"pid": pid})).await? == false { if check().await? == false {
return Ok(()); return Ok(());
} }
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
@@ -633,7 +641,7 @@ async fn wallpaper(
return Ok(None); return Ok(None);
} }
let (_, path) = file.keep().map_err(|error| error.to_string())?; let (_, path) = file.keep().map_err(|error| error.to_string())?;
if let Err(error) = update_wallpaper(&path, target) { if let Err(error) = update_wallpaper(&path, target).await {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
return Err(error); return Err(error);
} }
@@ -699,44 +707,99 @@ fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result<tempfile::NamedTem
Ok(file) Ok(file)
} }
fn update_wallpaper(image: &Path, target: &str) -> Result<(), String> { async fn update_wallpaper(image: &Path, target: &str) -> Result<(), String> {
let value = crate::bridge::Bridge::connect()?
.call("theme-get", serde_json::json!({}))
.await?;
let snapshot = ferese_ipc::theme::Snapshot::decode(value, ferese_config::families::builtins)?;
let image = image.to_owned();
let target = target.to_owned();
tokio::task::spawn_blocking(move || {
let _transaction = crate::backend::CONFIG_TRANSACTION let _transaction = crate::backend::CONFIG_TRANSACTION
.lock() .lock()
.map_err(|_| "Configuration transaction failed")?; .map_err(|_| "Configuration transaction failed")?;
let path = ferese_config::config_path().ok_or("Missing config path")?; let path = ferese_config::config_path().ok_or("Missing config path")?;
edit_wallpaper(&path, image, target) edit_wallpaper(&path, &image, &target, snapshot.theme.appearance)
})
.await
.map_err(|error| error.to_string())?
} }
fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String> { fn wallpaper_theme(
document: &ferese_config::Document,
directory: &Path,
appearance: ferese_config::theme::Appearance,
) -> Result<ferese_config::theme::ResolvedTheme, String> {
let mut document = document.clone();
let mode = match appearance {
ferese_config::theme::Appearance::Light => "light",
ferese_config::theme::Appearance::Dark => "dark",
};
// The compositor knows the active appearance, including system overrides.
// Resolve that variant without changing the user's automatic policy.
document
.set("theme.mode", mode.into())
.map_err(|error| error.to_string())?;
ferese_config::theme::resolve(&document, directory, jiff::Timestamp::now(), |file| {
std::fs::read_to_string(file).map_err(|error| error.to_string())
})
.map(|candidate| candidate.theme)
}
fn edit_wallpaper(
path: &Path,
image: &Path,
target: &str,
appearance: ferese_config::theme::Appearance,
) -> Result<(), String> {
use ferese_config::theme::Appearance;
let source = match std::fs::read_to_string(path) { let source = match std::fs::read_to_string(path) {
Ok(source) => source, Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error.to_string()), Err(error) => return Err(error.to_string()),
}; };
let mut document = ferese_config::Document::parse(&source).map_err(|error| error.to_string())?; let mut document = ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
if target == "background" && document.get("theme.background.lock_path").is_none() { let directory = path.parent().ok_or("Invalid config path")?;
let old = document let before = wallpaper_theme(&document, directory, appearance)?.tokens.background;
.get("theme.background.path") let (variant, other) = match appearance {
.and_then(serde_json::Value::as_str) Appearance::Light => ("light", Appearance::Dark),
.unwrap_or(ferese_config::default_wallpaper()) Appearance::Dark => ("dark", Appearance::Light),
.to_owned(); };
let other_before = wallpaper_theme(&document, directory, other)?.tokens.background;
let base = format!("theme.{variant}.background");
if target == "background" && before.lock_path.is_none() {
let old = before
.path
.unwrap_or_else(|| ferese_config::default_wallpaper_for(appearance).into());
document document
.set("theme.background.lock_path", old.into()) .set(&format!("{base}.lock_path"), old.to_string_lossy().into_owned().into())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
} }
if target != "lockscreen" { if target != "lockscreen" {
document document
.set("theme.background.path", image.to_string_lossy().into_owned().into()) .set(&format!("{base}.path"), image.to_string_lossy().into_owned().into())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
} }
if target != "background" { if target != "background" {
document document
.set( .set(
"theme.background.lock_path", &format!("{base}.lock_path"),
image.to_string_lossy().into_owned().into(), image.to_string_lossy().into_owned().into(),
) )
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
} }
let after = wallpaper_theme(&document, directory, appearance)?.tokens.background;
if (target != "lockscreen" && after.path.as_deref() != Some(image))
|| (target != "background" && after.lock_path.as_deref() != Some(image))
|| wallpaper_theme(&document, directory, other)?.tokens.background != other_before
{
return Err("Wallpaper change did not resolve to the requested image".into());
}
let parent = path.parent().ok_or("Invalid config path")?; let parent = path.parent().ok_or("Invalid config path")?;
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?; let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?;
@@ -821,6 +884,9 @@ mod tests {
#[test] #[test]
fn wallpaper_targets_preserve_independent_paths_and_other_settings() { fn wallpaper_targets_preserve_independent_paths_and_other_settings() {
use ferese_config::theme::Appearance::{Dark, Light};
for appearance in [Light, Dark] {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.kdl"); let path = dir.path().join("config.kdl");
std::fs::write( std::fs::write(
@@ -828,21 +894,135 @@ mod tests {
"// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n", "// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n",
) )
.unwrap(); .unwrap();
edit_wallpaper(&path, Path::new("/desktop.png"), "background").unwrap(); let resolve = |mode| {
let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(document.get("theme.background.path").unwrap(), "/desktop.png"); wallpaper_theme(&document, dir.path(), mode).unwrap().tokens.background
assert_eq!(document.get("theme.background.lock_path").unwrap(), "/old.png"); };
let other = if appearance == Light { Dark } else { Light };
let untouched = resolve(other);
edit_wallpaper(&path, Path::new("/desktop.png"), "background", appearance).unwrap();
assert_eq!(resolve(appearance).path.as_deref(), Some(Path::new("/desktop.png")));
assert_eq!(resolve(appearance).lock_path.as_deref(), Some(Path::new("/old.png")));
assert_eq!(resolve(other), untouched);
assert!(std::fs::read_to_string(&path).unwrap().contains("Keep my settings")); assert!(std::fs::read_to_string(&path).unwrap().contains("Keep my settings"));
edit_wallpaper(&path, Path::new("/lock.png"), "lockscreen").unwrap();
edit_wallpaper(&path, Path::new("/lock.png"), "lockscreen", appearance).unwrap();
assert_eq!(resolve(appearance).path.as_deref(), Some(Path::new("/desktop.png")));
assert_eq!(resolve(appearance).lock_path.as_deref(), Some(Path::new("/lock.png")));
assert_eq!(resolve(other), untouched);
edit_wallpaper(&path, Path::new("/both.png"), "both", appearance).unwrap();
assert_eq!(resolve(appearance).path.as_deref(), Some(Path::new("/both.png")));
assert_eq!(resolve(appearance).lock_path.as_deref(), Some(Path::new("/both.png")));
assert_eq!(resolve(other), untouched);
let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(document.get("theme.background.path").unwrap(), "/desktop.png"); assert_eq!(document.get("theme.background.path").unwrap(), "/old.png");
assert_eq!(document.get("theme.background.lock_path").unwrap(), "/lock.png");
edit_wallpaper(&path, Path::new("/both.png"), "both").unwrap();
let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(document.get("theme.background.path").unwrap(), "/both.png");
assert_eq!(document.get("theme.background.lock_path").unwrap(), "/both.png");
assert_eq!(document.get("commands.terminal").unwrap(), &serde_json::json!(["foot"])); assert_eq!(document.get("commands.terminal").unwrap(), &serde_json::json!(["foot"]));
} }
}
#[test]
fn wallpaper_replaces_active_overrides_without_changing_auto_policy() {
use ferese_config::theme::Appearance::{Dark, Light};
for appearance in [Light, Dark] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.kdl");
std::fs::write(
&path,
r#"theme {
mode "auto"
schedule { source "system"; }
background { path "/shared.png"; lock-path "/shared-lock.png"; }
light { background { path "light.png"; lock-path "light-lock.png"; }; }
dark { background { path "dark.png"; lock-path "dark-lock.png"; }; }
}
"#,
)
.unwrap();
let before = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let other = if appearance == Light { Dark } else { Light };
let old_active = wallpaper_theme(&before, dir.path(), appearance)
.unwrap()
.tokens
.background;
let old_other = wallpaper_theme(&before, dir.path(), other).unwrap().tokens.background;
edit_wallpaper(&path, Path::new("/new.png"), "background", appearance).unwrap();
let after = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let current = wallpaper_theme(&after, dir.path(), appearance)
.unwrap()
.tokens
.background;
assert_eq!(current.path.as_deref(), Some(Path::new("/new.png")));
assert_eq!(current.lock_path, old_active.lock_path);
assert_eq!(
wallpaper_theme(&after, dir.path(), other).unwrap().tokens.background,
old_other
);
assert_eq!(after.get("theme.mode").unwrap(), "auto");
assert_eq!(after.get("theme.background"), before.get("theme.background"));
edit_wallpaper(&path, Path::new("/new-lock.png"), "lockscreen", appearance).unwrap();
let after = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let current = wallpaper_theme(&after, dir.path(), appearance)
.unwrap()
.tokens
.background;
assert_eq!(current.path.as_deref(), Some(Path::new("/new.png")));
assert_eq!(current.lock_path.as_deref(), Some(Path::new("/new-lock.png")));
assert_eq!(
wallpaper_theme(&after, dir.path(), other).unwrap().tokens.background,
old_other
);
}
}
#[test]
fn desktop_only_change_pins_the_effective_variant_lock_wallpaper() {
use ferese_config::theme::Appearance::{Dark, Light};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.kdl");
std::fs::write(
&path,
r#"theme {
background { path "shared.png"; }
light { background { path "light.png"; }; }
dark { background { path "dark.png"; }; }
}
"#,
)
.unwrap();
let before = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let previous = wallpaper_theme(&before, dir.path(), Dark).unwrap().tokens.background;
assert_eq!(previous.lock_path, None);
edit_wallpaper(&path, Path::new("/new.png"), "background", Dark).unwrap();
let after = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let current = wallpaper_theme(&after, dir.path(), Dark).unwrap().tokens.background;
assert_eq!(current.lock_path, previous.path);
assert_eq!(
wallpaper_theme(&after, dir.path(), Light).unwrap().tokens.background,
wallpaper_theme(&before, dir.path(), Light).unwrap().tokens.background
);
}
#[test]
fn wallpaper_validation_failure_preserves_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.kdl");
let source = "theme { dark { file \"missing.kdl\"; }; }\n";
std::fs::write(&path, source).unwrap();
assert!(
edit_wallpaper(
&path,
Path::new("/new.png"),
"both",
ferese_config::theme::Appearance::Dark
)
.is_err()
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
}
#[test] #[test]
fn wallpaper_rejects_nonregular_files_and_cancellation_before_copy() { fn wallpaper_rejects_nonregular_files_and_cancellation_before_copy() {
+105 -13
View File
@@ -105,6 +105,20 @@ impl InputCapture {
} }
} }
async fn finish_native_call(
&self,
connection: &Connection,
path: &OwnedObjectPath,
bridge: &Bridge,
result: Result<serde_json::Value, String>,
) -> zbus::fdo::Result<serde_json::Value> {
if bridge.is_closed() {
self.end(connection, path.as_str()).await;
}
result.map_err(failed)
}
async fn native(session: &Session) -> zbus::fdo::Result<(Bridge, u64)> { async fn native(session: &Session) -> zbus::fdo::Result<(Bridge, u64)> {
let state = session.state.lock().await; let state = session.state.lock().await;
if !state.started || session.cancel.stopped.load(Ordering::SeqCst) { if !state.started || session.cancel.stopped.load(Ordering::SeqCst) {
@@ -127,9 +141,16 @@ impl InputCapture {
bridge: Bridge, bridge: Bridge,
id: u64, id: u64,
) { ) {
let control = session.state.lock().await.bridge.clone();
let Some(control) = control else {
self.end(&connection, path.as_str()).await;
return;
};
loop { loop {
let response = tokio::select! { let response = tokio::select! {
_ = session.cancel.wait() => break, _ = session.cancel.wait() => break,
_ = control.terminated() => break,
result = bridge.capture_watch(id) => result, result = bridge.capture_watch(id) => result,
}; };
let Ok(response) = response else { let Ok(response) = response else {
@@ -453,7 +474,9 @@ impl InputCapture {
if cancel.stopped.load(Ordering::SeqCst) || session.cancel.stopped.load(Ordering::SeqCst) || result.is_none() { if cancel.stopped.load(Ordering::SeqCst) || session.cancel.stopped.load(Ordering::SeqCst) || result.is_none() {
return Ok((1, Options::new())); return Ok((1, Options::new()));
} }
let result = result.unwrap().map_err(failed)?; let result = self
.finish_native_call(connection, &session_handle, &bridge, result.unwrap())
.await?;
let zones: Vec<(u32, u32, i32, i32)> = let zones: Vec<(u32, u32, i32, i32)> =
serde_json::from_value(result["zones"].clone()).map_err(|_| invalid("Invalid native capture zones"))?; serde_json::from_value(result["zones"].clone()).map_err(|_| invalid("Invalid native capture zones"))?;
Ok(( Ok((
@@ -515,7 +538,9 @@ impl InputCapture {
self.end(connection, session_handle.as_str()).await; self.end(connection, session_handle.as_str()).await;
return Ok((1, Options::new())); return Ok((1, Options::new()));
} }
let result = result.unwrap().map_err(failed)?; let result = self
.finish_native_call(connection, &session_handle, &bridge, result.unwrap())
.await?;
let failed_barriers: Vec<u32> = serde_json::from_value(result["failed_barriers"].clone()) let failed_barriers: Vec<u32> = serde_json::from_value(result["failed_barriers"].clone())
.map_err(|_| invalid("Invalid native barrier response"))?; .map_err(|_| invalid("Invalid native barrier response"))?;
Ok(( Ok((
@@ -580,10 +605,9 @@ impl InputCapture {
} }
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
} }
bridge let result = bridge.call("input-capture-enable", json!({"session":id})).await;
.call("input-capture-enable", json!({"session":id})) self.finish_native_call(connection, &session_handle, &bridge, result)
.await .await?;
.map_err(failed)?;
Ok((0, Options::new())) Ok((0, Options::new()))
} }
@@ -597,10 +621,9 @@ impl InputCapture {
) -> zbus::fdo::Result<(u32, Options)> { ) -> zbus::fdo::Result<(u32, Options)> {
let session = self.session(connection, &header, &session_handle, &app_id).await?; let session = self.session(connection, &header, &session_handle, &app_id).await?;
let (bridge, id) = Self::native(&session).await?; let (bridge, id) = Self::native(&session).await?;
bridge let result = bridge.call("input-capture-disable", json!({"session":id})).await;
.call("input-capture-disable", json!({"session":id})) self.finish_native_call(connection, &session_handle, &bridge, result)
.await .await?;
.map_err(failed)?;
Ok((0, Options::new())) Ok((0, Options::new()))
} }
@@ -633,13 +656,14 @@ impl InputCapture {
Ok(position) Ok(position)
}) })
.transpose()?; .transpose()?;
bridge let result = bridge
.call( .call(
"input-capture-release", "input-capture-release",
json!({"session":id,"activation_id":activation,"cursor_position":position}), json!({"session":id,"activation_id":activation,"cursor_position":position}),
) )
.await .await;
.map_err(failed)?; self.finish_native_call(connection, &session_handle, &bridge, result)
.await?;
Ok((0, Options::new())) Ok((0, Options::new()))
} }
@@ -705,6 +729,74 @@ impl SessionObject {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn broken_control_transport_ends_capture_but_valid_rejection_does_not() {
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
let mut bus = tokio::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut address = String::new();
BufReader::new(bus.stdout.take().unwrap())
.read_line(&mut address)
.await
.unwrap();
let connection = zbus::connection::Builder::address(address.trim())
.unwrap()
.build()
.await
.unwrap();
for transport_failure in [false, true] {
let capture = InputCapture::default();
let path: OwnedObjectPath = "/org/freedesktop/portal/desktop/session/test/capture"
.try_into()
.unwrap();
let (control, mut peer) = Bridge::test_pair();
let (watch, _peer) = Bridge::test_pair();
let session = Arc::new(Session {
owner: connection.unique_name().unwrap().to_string(),
app: "test".into(),
cancel: Arc::default(),
state: Mutex::new(State {
started: true,
bridge: Some(control.clone()),
watch: Some(watch.clone()),
..State::default()
}),
});
capture.sessions.lock().await.insert(path.to_string(), session.clone());
let response = std::thread::spawn(move || {
let request: ferese_ipc::Request = ferese_ipc::read_frame(&mut peer).unwrap();
if !transport_failure {
ferese_ipc::write_frame(&mut peer, &ferese_ipc::Response::error(request.id, "denied", "Denied"))
.unwrap();
}
});
let result = control.call("input-capture-disable", json!({})).await;
assert!(
capture
.finish_native_call(&connection, &path, &control, result)
.await
.is_err()
);
response.join().unwrap();
assert_eq!(session.cancel.stopped.load(Ordering::SeqCst), transport_failure);
assert_eq!(watch.is_closed(), transport_failure);
assert_eq!(
capture.sessions.lock().await.contains_key(path.as_str()),
!transport_failure
);
capture.end(&connection, path.as_str()).await;
}
bus.kill().await.unwrap();
bus.wait().await.unwrap();
}
#[test] #[test]
fn requested_capabilities_are_required_and_supported_subset_is_returned() { fn requested_capabilities_are_required_and_supported_subset_is_returned() {
assert!(capabilities(&Options::new()).is_err()); assert!(capabilities(&Options::new()).is_err());