Extend output profiles with monitor reconciliation and mirroring
This commit is contained in:
1 parent
ac184576f6
commit
d14cd3cbd2
23 files changed
+3614
-474
No files matched your search
+633
-350
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,7 @@ pub(super) fn capture_output(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
||||
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &'a mut Option<GlesTexture>,
|
||||
output: &Output,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Resume reads are asynchronous; newer switch events win over old replies.
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -63,8 +63,51 @@ impl LidState {
|
||||
}
|
||||
}
|
||||
|
||||
struct InhibitorLease<T> {
|
||||
active: bool,
|
||||
generation: u64,
|
||||
descriptor: Option<T>,
|
||||
suspend: bool,
|
||||
}
|
||||
|
||||
impl<T> Default for InhibitorLease<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
generation: 0,
|
||||
descriptor: None,
|
||||
suspend: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> InhibitorLease<T> {
|
||||
fn activate(&mut self, active: bool) -> u64 {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.active = active;
|
||||
self.suspend = false;
|
||||
// Closing here releases logind immediately, independently of worker progress.
|
||||
self.descriptor = None;
|
||||
self.generation
|
||||
}
|
||||
|
||||
fn accept(&mut self, generation: u64, descriptor: T) {
|
||||
if self.active && self.generation == generation {
|
||||
self.descriptor = Some(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PolicyRequest {
|
||||
Activate(u64),
|
||||
Suspend,
|
||||
}
|
||||
|
||||
pub(super) struct Reader {
|
||||
requests: mpsc::Sender<Probe>,
|
||||
suspend: mpsc::Sender<PolicyRequest>,
|
||||
lease: Arc<Mutex<InhibitorLease<zbus::zvariant::OwnedFd>>>,
|
||||
suspend_requested: bool,
|
||||
pub deadline: Option<RegistrationToken>,
|
||||
}
|
||||
|
||||
@@ -99,7 +142,94 @@ fn read_acpi() -> Option<bool> {
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
pub fn set_active(&mut self, active: bool) {
|
||||
let generation = self.lease.lock().unwrap().activate(active);
|
||||
self.suspend_requested = false;
|
||||
if active {
|
||||
let _ = self.suspend.send(PolicyRequest::Activate(generation));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(state: &Ferese) -> Result<Self, Box<dyn Error>> {
|
||||
let (suspend, suspend_requests) = mpsc::channel::<PolicyRequest>();
|
||||
let lease = Arc::new(Mutex::new(InhibitorLease::default()));
|
||||
let worker_lease = lease.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-lid-policy".into())
|
||||
.spawn(move || {
|
||||
let connection = zbus::blocking::connection::Builder::system()
|
||||
.and_then(|builder| builder.method_timeout(Duration::from_secs(5)).build());
|
||||
let Ok(connection) = connection else { return };
|
||||
while let Ok(request) = suspend_requests.recv() {
|
||||
if let PolicyRequest::Activate(generation) = request {
|
||||
let current = worker_lease.lock().unwrap();
|
||||
if !current.active || current.generation != generation {
|
||||
continue;
|
||||
}
|
||||
drop(current);
|
||||
let inhibitor = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Inhibit",
|
||||
&(
|
||||
"handle-lid-switch",
|
||||
"Ferese",
|
||||
"Reconcile monitors before lid action",
|
||||
"block",
|
||||
),
|
||||
)
|
||||
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedFd>());
|
||||
match inhibitor {
|
||||
Ok(descriptor) => worker_lease.lock().unwrap().accept(generation, descriptor),
|
||||
Err(error) => tracing::warn!(%error, "could not own logind lid policy"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let current = worker_lease.lock().unwrap();
|
||||
if !current.active || !current.suspend {
|
||||
continue;
|
||||
}
|
||||
drop(current);
|
||||
if read_logind(&connection).ok() != Some(true) {
|
||||
continue;
|
||||
}
|
||||
let allowed = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.DBus.Properties"),
|
||||
"Get",
|
||||
&("org.freedesktop.login1.Manager", "BlockInhibited"),
|
||||
)
|
||||
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedValue>())
|
||||
.and_then(|value| String::try_from(value).map_err(Into::into));
|
||||
if allowed
|
||||
.as_ref()
|
||||
.is_ok_and(|inhibitors| inhibitors.split(':').any(|kind| kind == "sleep"))
|
||||
{
|
||||
tracing::debug!("lid suspend prevented by sleep inhibitor");
|
||||
continue;
|
||||
}
|
||||
let current = worker_lease.lock().unwrap();
|
||||
let requested = current.active && current.suspend;
|
||||
drop(current);
|
||||
if !requested {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = connection.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Suspend",
|
||||
&(false,),
|
||||
) {
|
||||
tracing::warn!(%error, "logind rejected lid suspend");
|
||||
}
|
||||
}
|
||||
worker_lease.lock().unwrap().activate(false);
|
||||
})?;
|
||||
let (requests, receiver) = mpsc::channel::<Probe>();
|
||||
let (replies, events) = smithay::reexports::calloop::channel::channel();
|
||||
state.loop_handle.insert_source(events, |event, _, state| {
|
||||
@@ -126,10 +256,15 @@ impl Reader {
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
let mut reader = Self {
|
||||
requests,
|
||||
suspend,
|
||||
lease,
|
||||
suspend_requested: false,
|
||||
deadline: None,
|
||||
})
|
||||
};
|
||||
reader.set_active(true);
|
||||
Ok(reader)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +287,8 @@ pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
|
||||
cancel_output_timers(&state.loop_handle, output);
|
||||
}
|
||||
}
|
||||
backend.lid_reader.suspend_requested = false;
|
||||
backend.lid_reader.lease.lock().unwrap().suspend = false;
|
||||
let probe = backend.lid.begin(reactivate);
|
||||
let sent = backend.lid_reader.requests.send(probe).is_ok();
|
||||
let timer = state
|
||||
@@ -191,6 +328,7 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
|
||||
|
||||
// Waking a locked session may request redraws. Hold them until the fresh
|
||||
// device/connector snapshot has been reconciled.
|
||||
backend.lid_reader.suspend_requested = false;
|
||||
backend.topology.reconciling = true;
|
||||
state.reset_animation_clock();
|
||||
state.lock_input_activity();
|
||||
@@ -198,10 +336,40 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
|
||||
reconcile_outputs(state, reactivate);
|
||||
}
|
||||
|
||||
/// Only logind requests suspend. Inactive events wait for fresh resume topology.
|
||||
pub(super) fn apply_suspend_policy(state: &mut Ferese) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let requested = backend.active && backend.desired_outputs.suspend && backend.lid.closed();
|
||||
backend.lid_reader.lease.lock().unwrap().suspend = requested;
|
||||
if backend.lid_reader.suspend_requested != requested {
|
||||
backend.lid_reader.suspend_requested = requested;
|
||||
let _ = backend.lid_reader.suspend.send(PolicyRequest::Suspend);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pause_releases_inhibitor_and_rejects_delayed_acquisition() {
|
||||
let mut lease = InhibitorLease::default();
|
||||
let first = lease.activate(true);
|
||||
lease.accept(first, "fd");
|
||||
assert_eq!(lease.descriptor, Some("fd"));
|
||||
lease.activate(false);
|
||||
assert_eq!(lease.descriptor, None);
|
||||
lease.accept(first, "late fd");
|
||||
assert_eq!(lease.descriptor, None);
|
||||
let next = lease.activate(true);
|
||||
lease.accept(first, "old session fd");
|
||||
assert_eq!(lease.descriptor, None);
|
||||
lease.accept(next, "new session fd");
|
||||
assert_eq!(lease.descriptor, Some("new session fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires dbus-daemon and private test sockets"]
|
||||
fn logind_reads_are_fresh_without_property_change_signals() {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! A target displays a composition of the source, with no second workspace or input domain.
|
||||
//! Source alone releases client callbacks/presentation feedback. Targets have independent KMS clocks.
|
||||
use super::*;
|
||||
use smithay::backend::renderer::Texture;
|
||||
use smithay::utils::{Buffer, Physical, Rectangle};
|
||||
|
||||
/// Fit the complete source into the transformed target; black letterbox, never crop.
|
||||
fn fit(source: (i32, i32), target: (i32, i32)) -> Rectangle<i32, Physical> {
|
||||
let scale = (target.0 as f64 / source.0.max(1) as f64).min(target.1 as f64 / source.1.max(1) as f64);
|
||||
let width = (source.0 as f64 * scale).round().max(1.0) as i32;
|
||||
let height = (source.1 as f64 * scale).round().max(1.0) as i32;
|
||||
Rectangle::new(
|
||||
((target.0 - width) / 2, (target.1 - height) / 2).into(),
|
||||
(width, height).into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn elements(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
source: &Output,
|
||||
frame: &crate::state::FrameScene,
|
||||
target: &mut DirectOutput,
|
||||
) -> Result<Vec<crate::render::AnimatedWindowRenderElement>, smithay::backend::renderer::gles::GlesError> {
|
||||
let elements = sampled_output_elements(state, renderer, source, true, frame);
|
||||
// Target renderer owns this texture, including when source and target use different GPUs.
|
||||
let canvas = target.mirror_canvas.get_or_insert_with(|| {
|
||||
Output::new(
|
||||
"mirror-canvas".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "Ferese".into(),
|
||||
model: "Mirror composition".into(),
|
||||
},
|
||||
)
|
||||
});
|
||||
compose(renderer, &mut target.mirror_texture, canvas, source, &elements)?;
|
||||
let texture = target
|
||||
.mirror_texture
|
||||
.as_ref()
|
||||
.expect("source composition has a texture")
|
||||
.clone();
|
||||
target.mirror_commit.increment();
|
||||
Ok(vec![
|
||||
fitted_texture(texture, &target.output, target.mirror_id.clone(), target.mirror_commit).into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn compose(
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &mut Option<GlesTexture>,
|
||||
canvas: &Output,
|
||||
source: &Output,
|
||||
elements: &[crate::render::AnimatedWindowRenderElement],
|
||||
) -> Result<(), smithay::backend::renderer::gles::GlesError> {
|
||||
let source_mode = source.current_mode().expect("source has a mode");
|
||||
canvas.change_current_state(
|
||||
Some(OutputMode {
|
||||
size: source.current_transform().transform_size(source_mode.size),
|
||||
refresh: source_mode.refresh,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
Some(source.current_scale()),
|
||||
None,
|
||||
);
|
||||
let framebuffer = capture::render_capture(renderer, texture, canvas, elements)?;
|
||||
drop(framebuffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fitted_texture(
|
||||
texture: GlesTexture,
|
||||
target: &Output,
|
||||
id: smithay::backend::renderer::element::Id,
|
||||
commit: CommitCounter,
|
||||
) -> crate::presentation::NativeTextureElement {
|
||||
let source_size = texture.size();
|
||||
let size = target
|
||||
.current_transform()
|
||||
.transform_size(target.current_mode().expect("target has a mode").size);
|
||||
crate::presentation::NativeTextureElement {
|
||||
id,
|
||||
commit,
|
||||
source: Rectangle::<f64, Buffer>::from_size(source_size.to_f64()),
|
||||
texture,
|
||||
geometry: fit((source_size.w, source_size.h), (size.w, size.h)),
|
||||
alpha: 1.0,
|
||||
program: None,
|
||||
uniforms: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn different_native_sizes_fit_without_cropping() {
|
||||
assert_eq!(
|
||||
fit((1920, 1080), (1280, 1024)),
|
||||
Rectangle::new((0, 152).into(), (1280, 720).into())
|
||||
);
|
||||
assert_eq!(
|
||||
fit((1920, 1080), (1080, 1920)),
|
||||
Rectangle::new((0, 656).into(), (1080, 608).into())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn mirrored_pixels_fit_rotated_source_and_target_without_cropping() {
|
||||
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
|
||||
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||
use smithay::backend::renderer::element::{Id, Kind};
|
||||
use smithay::backend::renderer::{Color32F, ExportMem};
|
||||
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let output = |name: &str| {
|
||||
Output::new(
|
||||
name.into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
)
|
||||
};
|
||||
let source = output("source");
|
||||
source.change_current_state(
|
||||
Some(OutputMode {
|
||||
size: (64, 32).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::_90),
|
||||
Some(Scale::Fractional(2.0)),
|
||||
None,
|
||||
);
|
||||
let canvas = output("canvas");
|
||||
let colors: Vec<crate::render::AnimatedWindowRenderElement> = [(0, [1., 0., 0., 1.]), (32, [0., 0., 1., 1.])]
|
||||
.into_iter()
|
||||
.map(|(y, color)| {
|
||||
SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
Rectangle::new((0, y).into(), (32, 32).into()),
|
||||
CommitCounter::default(),
|
||||
Color32F::from(color),
|
||||
Kind::Unspecified,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut texture = None;
|
||||
compose(&mut renderer, &mut texture, &canvas, &source, &colors).unwrap();
|
||||
assert_eq!(texture.as_ref().unwrap().size(), (32, 64).into());
|
||||
let target = output("target");
|
||||
for (transform, colored_area) in [(Transform::Normal, 2048), (Transform::_90, 4608)] {
|
||||
target.change_current_state(
|
||||
Some(OutputMode {
|
||||
size: (96, 64).into(),
|
||||
refresh: 120_000,
|
||||
}),
|
||||
Some(transform),
|
||||
Some(Scale::Fractional(1.5)),
|
||||
None,
|
||||
);
|
||||
let element = fitted_texture(
|
||||
texture.as_ref().unwrap().clone(),
|
||||
&target,
|
||||
Id::new(),
|
||||
CommitCounter::default(),
|
||||
);
|
||||
let mut target_texture = None;
|
||||
let framebuffer = capture::render_capture(&mut renderer, &mut target_texture, &target, &[element]).unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(&framebuffer, Rectangle::from_size((96, 64).into()), Fourcc::Abgr8888)
|
||||
.unwrap();
|
||||
let pixels = renderer.map_texture(&mapping).unwrap();
|
||||
let red = pixels
|
||||
.chunks_exact(4)
|
||||
.filter(|pixel| pixel[0] > 100 && pixel[0] > pixel[2])
|
||||
.count();
|
||||
let blue = pixels
|
||||
.chunks_exact(4)
|
||||
.filter(|pixel| pixel[2] > 100 && pixel[2] > pixel[0])
|
||||
.count();
|
||||
assert_eq!(red, colored_area / 2, "{transform:?}");
|
||||
assert_eq!(blue, colored_area / 2, "{transform:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ pub(super) struct Topology {
|
||||
pub reconciling: bool,
|
||||
pub dirty: bool,
|
||||
pub retry_timer: Option<RegistrationToken>,
|
||||
pub settle_timer: Option<RegistrationToken>,
|
||||
pub coalescer: crate::output_policy::Debouncer,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
@@ -93,73 +95,6 @@ impl Topology {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
|
||||
let mut topology = Topology::default();
|
||||
assert!(!topology.request(false));
|
||||
assert!(!topology.request(false));
|
||||
assert!(topology.dirty);
|
||||
assert!(topology.request(true));
|
||||
topology.reconciling = true;
|
||||
assert!(!topology.request(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replug_matches_hardware_even_when_card_numbers_change() {
|
||||
let original = Path::new("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
|
||||
let mut slot = DeviceState {
|
||||
identity: Some(identity.clone()),
|
||||
..DeviceState::default()
|
||||
};
|
||||
slot.failed(Instant::now());
|
||||
slot.missing();
|
||||
let observed = vec![
|
||||
(
|
||||
PathBuf::from("/dev/dri/card0"),
|
||||
Some(PathBuf::from("/sys/devices/other-gpu")),
|
||||
),
|
||||
(PathBuf::from("/dev/dri/card2"), Some(identity)),
|
||||
];
|
||||
assert_eq!(
|
||||
slot.current_path(original, &observed),
|
||||
Some(Path::new("/dev/dri/card2"))
|
||||
);
|
||||
assert!(slot.current_path(original, &observed[..1]).is_none());
|
||||
assert!(slot.current_path(original, &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_devices_retry_independently_with_capped_backoff() {
|
||||
let now = Instant::now();
|
||||
let mut topology = Topology::default();
|
||||
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
|
||||
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
|
||||
let mut second = DeviceState::default();
|
||||
second.ready(healthy);
|
||||
topology.devices.insert("/dev/dri/card1".into(), second);
|
||||
let device = topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap();
|
||||
device.failed(now);
|
||||
assert_eq!(device.retry_at, Some(now + Duration::from_secs(1)));
|
||||
for _ in 0..20 {
|
||||
device.failed(now);
|
||||
}
|
||||
assert_eq!(device.retry_at, Some(now + Duration::from_secs(32)));
|
||||
assert_eq!(
|
||||
topology.devices[Path::new("/dev/dri/card1")].availability,
|
||||
Availability::Ready(healthy)
|
||||
);
|
||||
assert!(topology.retry_deadline(false).is_none());
|
||||
assert_eq!(topology.retry_deadline(true), Some(now + Duration::from_secs(32)));
|
||||
topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap().missing();
|
||||
assert!(topology.retry_deadline(true).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState {
|
||||
state
|
||||
.direct_backend
|
||||
@@ -180,8 +115,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(token) = backend.topology.settle_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
backend.topology.coalescer.clear();
|
||||
backend.topology.reconciling = true;
|
||||
backend.topology.dirty = false;
|
||||
backend.output_error = None;
|
||||
if reactivate {
|
||||
backend
|
||||
.presentation
|
||||
@@ -191,7 +131,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
if let Some(token) = backend.topology.retry_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
let paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
||||
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
||||
let invalidated = std::mem::take(&mut backend.topology.invalidated);
|
||||
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
||||
paths
|
||||
@@ -209,6 +149,23 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
backend.low_power =
|
||||
automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent());
|
||||
|
||||
if let Ok(observed) = &observed {
|
||||
for (path, identity) in observed {
|
||||
if !backend
|
||||
.topology
|
||||
.devices
|
||||
.values()
|
||||
.any(|slot| slot.identity == *identity && identity.is_some())
|
||||
&& !backend.topology.devices.contains_key(path)
|
||||
{
|
||||
backend.topology.devices.insert(path.clone(), DeviceState::known(path));
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
let mut ready = Vec::new();
|
||||
|
||||
for node in invalidated {
|
||||
remove_device(state, node);
|
||||
}
|
||||
@@ -301,7 +258,9 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
.remove(&crtc)
|
||||
.unwrap();
|
||||
cancel_output_timers(&state.loop_handle, &mut output);
|
||||
state.display_handle.disable_global::<Ferese>(output.global);
|
||||
if let Some(global) = output.global.take() {
|
||||
state.display_handle.disable_global::<Ferese>(global);
|
||||
}
|
||||
state.unregister_output(&output.output);
|
||||
state
|
||||
.direct_backend
|
||||
@@ -311,28 +270,65 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
.remove(&(node, crtc));
|
||||
}
|
||||
|
||||
let complete = rescan_device(state, node);
|
||||
if complete.is_err() {
|
||||
remove_device(state, node);
|
||||
ready.push((path, node));
|
||||
}
|
||||
|
||||
let known_good = state.direct_backend.as_ref().unwrap().applied_outputs.clone();
|
||||
if let Err(error) = refresh_monitor_policy(state).and_then(|()| {
|
||||
transaction::retire_disconnected(state);
|
||||
validate_desired_outputs(state)
|
||||
}) {
|
||||
tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs");
|
||||
state.direct_backend.as_mut().unwrap().output_error = Some(error);
|
||||
for (path, _) in &ready {
|
||||
slot_mut(state, path).failed(Instant::now());
|
||||
}
|
||||
let entry = slot_mut(state, &path);
|
||||
if complete == Ok(true) {
|
||||
entry.ready(node);
|
||||
} else {
|
||||
entry.failed(Instant::now());
|
||||
// A partial connector failure must not take down working outputs.
|
||||
if complete == Ok(false) {
|
||||
entry.availability = Availability::Degraded(node);
|
||||
} else {
|
||||
// Activate destinations before disabling former owners, including across GPUs.
|
||||
ready.sort_by_key(|(_, node)| {
|
||||
let backend = state.direct_backend.as_ref().unwrap();
|
||||
let prefix = format!("{}/", drm_device_key(&backend.devices[node].drm));
|
||||
let desired = backend
|
||||
.desired_outputs
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| output.settings.enabled && output.key.starts_with(&prefix));
|
||||
let logical_sources = desired.clone().filter(|output| output.mirror_source.is_none()).count();
|
||||
std::cmp::Reverse((logical_sources, desired.count()))
|
||||
});
|
||||
let mut succeeded = Vec::new();
|
||||
for (path, node) in &ready {
|
||||
let complete = rescan_device(state, *node);
|
||||
let entry = slot_mut(state, path);
|
||||
if complete == Ok(true) {
|
||||
entry.ready(*node);
|
||||
succeeded.push(*node);
|
||||
} else {
|
||||
entry.failed(Instant::now());
|
||||
entry.availability = Availability::Degraded(*node);
|
||||
// Device boundaries are not cross-GPU atomic. Compensate already
|
||||
// applied devices using their last actual hardware settings.
|
||||
if !known_good.outputs.is_empty() {
|
||||
let backend = state.direct_backend.as_mut().unwrap();
|
||||
let requested = backend.desired_outputs.clone();
|
||||
backend.desired_outputs =
|
||||
crate::output_policy::restore_configuration(&known_good, &backend.monitors);
|
||||
for previous in succeeded {
|
||||
let _ = rescan_device(state, previous);
|
||||
}
|
||||
state.direct_backend.as_mut().unwrap().desired_outputs = requested;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if state.direct_backend.as_ref().unwrap().output_error.is_none() {
|
||||
remember_applied_configuration(state);
|
||||
}
|
||||
}
|
||||
|
||||
transaction::recover_last_output(state);
|
||||
refresh_connected_info(state);
|
||||
let backend = state.direct_backend.as_mut().unwrap();
|
||||
backend.connected_outputs = backend
|
||||
.devices
|
||||
.values()
|
||||
.flat_map(|device| device.connected_outputs.clone())
|
||||
.collect();
|
||||
backend.topology.reconciling = false;
|
||||
let retry = backend.topology.retry_deadline(backend.active);
|
||||
if let Some(deadline) = retry {
|
||||
@@ -355,4 +351,110 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
state.restore_output_focus();
|
||||
state.relayout();
|
||||
render_all(state);
|
||||
lid::apply_suspend_policy(state);
|
||||
}
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer.
|
||||
pub(super) fn hotplug(state: &mut Ferese) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
backend.topology.dirty = true;
|
||||
if let Some(token) = backend.topology.settle_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
let deadline = backend
|
||||
.topology
|
||||
.coalescer
|
||||
.request(monotonic_now(), backend.active && !backend.lid.pending());
|
||||
if deadline.is_none() {
|
||||
return;
|
||||
}
|
||||
match state
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(SETTLE), |_, _, state| {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.topology.settle_timer = None;
|
||||
if !backend.topology.coalescer.take_due(monotonic_now()) {
|
||||
return TimeoutAction::Drop;
|
||||
}
|
||||
}
|
||||
reconcile_outputs(state, false);
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(token) => state.direct_backend.as_mut().unwrap().topology.settle_timer = Some(token),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not debounce output events");
|
||||
reconcile_outputs(state, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
|
||||
let mut topology = Topology::default();
|
||||
assert!(!topology.request(false));
|
||||
assert!(!topology.request(false));
|
||||
assert!(topology.dirty);
|
||||
assert!(topology.request(true));
|
||||
topology.reconciling = true;
|
||||
assert!(!topology.request(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replug_matches_hardware_even_when_card_numbers_change() {
|
||||
let original = Path::new("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
|
||||
let mut slot = DeviceState {
|
||||
identity: Some(identity.clone()),
|
||||
..DeviceState::default()
|
||||
};
|
||||
slot.failed(Instant::now());
|
||||
slot.missing();
|
||||
let observed = vec![
|
||||
(
|
||||
PathBuf::from("/dev/dri/card0"),
|
||||
Some(PathBuf::from("/sys/devices/other-gpu")),
|
||||
),
|
||||
(PathBuf::from("/dev/dri/card2"), Some(identity)),
|
||||
];
|
||||
assert_eq!(
|
||||
slot.current_path(original, &observed),
|
||||
Some(Path::new("/dev/dri/card2"))
|
||||
);
|
||||
assert!(slot.current_path(original, &observed[..1]).is_none());
|
||||
assert!(slot.current_path(original, &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_devices_retry_independently_with_capped_backoff() {
|
||||
let now = Instant::now();
|
||||
let mut topology = Topology::default();
|
||||
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
|
||||
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
|
||||
let mut second = DeviceState::default();
|
||||
second.ready(healthy);
|
||||
topology.devices.insert("/dev/dri/card1".into(), second);
|
||||
let device = topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap();
|
||||
device.failed(now);
|
||||
assert_eq!(device.retry_at, Some(now + Duration::from_secs(1)));
|
||||
for _ in 0..20 {
|
||||
device.failed(now);
|
||||
}
|
||||
assert_eq!(device.retry_at, Some(now + Duration::from_secs(32)));
|
||||
assert_eq!(
|
||||
topology.devices[Path::new("/dev/dri/card1")].availability,
|
||||
Availability::Ready(healthy)
|
||||
);
|
||||
assert!(topology.retry_deadline(false).is_none());
|
||||
assert_eq!(topology.retry_deadline(true), Some(now + Duration::from_secs(32)));
|
||||
topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap().missing();
|
||||
assert!(topology.retry_deadline(true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! Validate a device-wide desired topology, then use Smithay for every real commit.
|
||||
//! Smithay exposes TEST_ONLY per surface, but not a combined CRTC request. The
|
||||
//! one raw request here is validation only; mode/plane ownership stays in Smithay.
|
||||
use super::*;
|
||||
use smithay::backend::allocator::Allocator;
|
||||
use smithay::backend::drm::exporter::{ExportBuffer, ExportFramebuffer};
|
||||
use smithay::reexports::drm::control::{AtomicCommitFlags, ResourceHandle, atomic::AtomicModeReq};
|
||||
|
||||
fn property<D: ControlDevice, H: ResourceHandle>(drm: &D, handle: H, name: &str) -> io::Result<property::Handle> {
|
||||
for (property, _) in drm.get_properties(handle)?.iter() {
|
||||
if drm.get_property(*property)?.name().to_bytes() == name.as_bytes() {
|
||||
return Ok(*property);
|
||||
}
|
||||
}
|
||||
Err(io::Error::other(format!("missing DRM property {name}")))
|
||||
}
|
||||
|
||||
fn add<H: ResourceHandle>(
|
||||
drm: &DrmDevice,
|
||||
request: &mut AtomicModeReq,
|
||||
handle: H,
|
||||
name: &str,
|
||||
value: u64,
|
||||
) -> io::Result<()> {
|
||||
request.add_raw_property(handle.into(), property(drm, handle, name)?, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_secondary_planes(
|
||||
drm: &DrmDevice,
|
||||
request: &mut AtomicModeReq,
|
||||
crtc: crtc::Handle,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let planes = drm.planes(&crtc)?;
|
||||
for plane in planes.cursor.iter().chain(&planes.overlay) {
|
||||
let properties = drm.get_properties(plane.handle)?;
|
||||
let owned = properties.iter().any(|(handle, value)| {
|
||||
drm.get_property(*handle)
|
||||
.is_ok_and(|info| info.name().to_bytes() == b"CRTC_ID" && *value == u32::from(crtc) as u64)
|
||||
});
|
||||
if owned {
|
||||
add(drm, request, plane.handle, "FB_ID", 0)?;
|
||||
add(drm, request, plane.handle, "CRTC_ID", 0)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Blobs {
|
||||
fd: DrmDeviceFd,
|
||||
ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Drop for Blobs {
|
||||
fn drop(&mut self) {
|
||||
for id in &self.ids {
|
||||
let _ = self.fd.destroy_property_blob(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_device(device: &DirectDevice, scan: &OutputScan) -> Result<(), Box<dyn Error>> {
|
||||
if scan.selections.iter().all(|selection| {
|
||||
device
|
||||
.outputs
|
||||
.get(&selection.crtc)
|
||||
.is_some_and(|output| output.connector == selection.connector.handle() && output.mode == selection.mode)
|
||||
}) && scan.selections.len() == device.outputs.len()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Legacy KMS has no atomic test. Smithay validates its surface/mode as far as
|
||||
// the API permits; commit failures still go through the restoration path.
|
||||
if !device.drm.is_atomic() {
|
||||
return Ok(());
|
||||
}
|
||||
let drm = &device.drm;
|
||||
let mut request = AtomicModeReq::new();
|
||||
let mut blobs = Blobs {
|
||||
fd: drm.device_fd().clone(),
|
||||
ids: Vec::new(),
|
||||
};
|
||||
let mut buffers = Vec::new();
|
||||
let mut framebuffers = Vec::new();
|
||||
let mut allocator = GbmAllocator::new(device.gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT);
|
||||
let exporter = GbmFramebufferExporter::new(device.gbm.clone(), Some(device.render_node));
|
||||
let mut used_planes = HashSet::new();
|
||||
let desired_crtcs = scan
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| selection.crtc)
|
||||
.collect::<HashSet<_>>();
|
||||
let desired_connectors = scan
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| selection.connector.handle())
|
||||
.collect::<HashSet<_>>();
|
||||
for output in device.outputs.values() {
|
||||
if !desired_connectors.contains(&output.connector) {
|
||||
add(drm, &mut request, output.connector, "CRTC_ID", 0)?;
|
||||
}
|
||||
if !desired_crtcs.contains(&output.surface.crtc()) {
|
||||
clear_secondary_planes(drm, &mut request, output.surface.crtc())?;
|
||||
add(drm, &mut request, output.surface.crtc(), "ACTIVE", 0)?;
|
||||
add(drm, &mut request, output.surface.crtc(), "MODE_ID", 0)?;
|
||||
add(drm, &mut request, output.surface.plane(), "FB_ID", 0)?;
|
||||
add(drm, &mut request, output.surface.plane(), "CRTC_ID", 0)?;
|
||||
}
|
||||
}
|
||||
for selection in &scan.selections {
|
||||
clear_secondary_planes(drm, &mut request, selection.crtc)?;
|
||||
let planes = drm.planes(&selection.crtc)?;
|
||||
let primary = device
|
||||
.outputs
|
||||
.get(&selection.crtc)
|
||||
.map(|output| output.surface.plane())
|
||||
.or_else(|| {
|
||||
planes
|
||||
.primary
|
||||
.iter()
|
||||
.find(|plane| !used_planes.contains(&plane.handle))
|
||||
.map(|plane| plane.handle)
|
||||
})
|
||||
.ok_or("no usable primary plane")?;
|
||||
used_planes.insert(primary);
|
||||
if let Ok(rotation) = property(drm, primary, "rotation") {
|
||||
request.add_raw_property(primary.into(), rotation, 1);
|
||||
}
|
||||
let blob: u64 = drm.create_property_blob(&selection.mode)?.into();
|
||||
blobs.ids.push(blob);
|
||||
add(drm, &mut request, selection.crtc, "MODE_ID", blob)?;
|
||||
add(drm, &mut request, selection.crtc, "ACTIVE", 1)?;
|
||||
add(
|
||||
drm,
|
||||
&mut request,
|
||||
selection.connector.handle(),
|
||||
"CRTC_ID",
|
||||
u32::from(selection.crtc) as u64,
|
||||
)?;
|
||||
let (width, height) = selection.mode.size();
|
||||
let formats = &planes
|
||||
.primary
|
||||
.iter()
|
||||
.find(|plane| plane.handle == primary)
|
||||
.ok_or("primary plane disappeared")?
|
||||
.formats;
|
||||
let mut allocated = None;
|
||||
for format in formats.iter().filter(|format| {
|
||||
matches!(
|
||||
format.code,
|
||||
Fourcc::Argb8888 | Fourcc::Abgr8888 | Fourcc::Xrgb8888 | Fourcc::Xbgr8888
|
||||
)
|
||||
}) {
|
||||
if let Ok(buffer) = allocator.create_buffer(width.into(), height.into(), format.code, &[format.modifier])
|
||||
&& let Ok(Some(fb)) = exporter.add_framebuffer(drm.device_fd(), ExportBuffer::Allocator(&buffer), false)
|
||||
{
|
||||
allocated = Some((buffer, fb));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (buffer, fb) = allocated.ok_or("unable to allocate a DRM validation buffer")?;
|
||||
add(drm, &mut request, primary, "FB_ID", u32::from(*fb.as_ref()) as u64)?;
|
||||
add(drm, &mut request, primary, "CRTC_ID", u32::from(selection.crtc) as u64)?;
|
||||
for (name, value) in [
|
||||
("SRC_X", 0),
|
||||
("SRC_Y", 0),
|
||||
("SRC_W", (width as u64) << 16),
|
||||
("SRC_H", (height as u64) << 16),
|
||||
("CRTC_X", 0),
|
||||
("CRTC_Y", 0),
|
||||
("CRTC_W", width as u64),
|
||||
("CRTC_H", height as u64),
|
||||
] {
|
||||
add(drm, &mut request, primary, name, value)?;
|
||||
}
|
||||
buffers.push(buffer);
|
||||
framebuffers.push(fb);
|
||||
}
|
||||
drm.atomic_commit(AtomicCommitFlags::TEST_ONLY | AtomicCommitFlags::ALLOW_MODESET, request)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
surface: &mut OutputCompositor,
|
||||
renderer: &mut GlesRenderer,
|
||||
mode: DrmMode,
|
||||
settings: &OutputSettings,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
surface.use_mode(mode)?;
|
||||
surface.set_output_mode_source(OutputModeSource::Static {
|
||||
size: OutputMode::from(mode).size,
|
||||
scale: settings.scale.into(),
|
||||
transform: output_transform(settings.transform),
|
||||
});
|
||||
surface.reset_buffer_ages();
|
||||
let elements: Vec<crate::render::AnimatedWindowRenderElement> = Vec::new();
|
||||
let result = surface.render_frame(
|
||||
renderer,
|
||||
&elements,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
smithay::backend::drm::compositor::FrameFlags::empty(),
|
||||
)?;
|
||||
if let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element {
|
||||
primary.sync.wait()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire a logical output only after its hardware is unavailable or confirmed disabled.
|
||||
fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: crtc::Handle) {
|
||||
cancel_output_timers(&state.loop_handle, output);
|
||||
if let Some(global) = output.global.take() {
|
||||
state.display_handle.disable_global::<Ferese>(global);
|
||||
}
|
||||
state.session_lock.output_removed(&output.output);
|
||||
state.unregister_output(&output.output);
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.presentation
|
||||
.remove(&(node, crtc));
|
||||
}
|
||||
|
||||
struct DeviceTransaction<'a> {
|
||||
state: &'a mut Ferese,
|
||||
device: &'a mut DirectDevice,
|
||||
selections: &'a HashMap<crtc::Handle, OutputSelection>,
|
||||
staged: HashMap<crtc::Handle, DirectOutput>,
|
||||
replaced: HashSet<crtc::Handle>,
|
||||
}
|
||||
|
||||
impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
|
||||
type Key = crtc::Handle;
|
||||
|
||||
fn stage(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
let selection = &self.selections[&crtc];
|
||||
if self
|
||||
.device
|
||||
.outputs
|
||||
.get(&crtc)
|
||||
.is_none_or(|output| output.connector != selection.connector.handle())
|
||||
{
|
||||
let output = create_direct_output(
|
||||
self.state,
|
||||
&mut self.device.drm,
|
||||
&self.device.gbm,
|
||||
&self.device.renderer,
|
||||
selection.clone(),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.staged.insert(crtc, output);
|
||||
}
|
||||
let output = self
|
||||
.staged
|
||||
.get_mut(&crtc)
|
||||
.or_else(|| self.device.outputs.get_mut(&crtc))
|
||||
.unwrap();
|
||||
output
|
||||
.surface
|
||||
.set_connectors(&[selection.connector.handle()])
|
||||
.map_err(|error| error.to_string())?;
|
||||
prepare(
|
||||
&mut output.surface,
|
||||
&mut self.device.renderer,
|
||||
selection.mode,
|
||||
&selection.settings,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn commit(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
if self.staged.contains_key(&crtc) && self.device.outputs.contains_key(&crtc) {
|
||||
// Connector and mode cannot be changed together through use_mode /
|
||||
// set_connectors: each tests against the other's old value. Stage a
|
||||
// replacement surface, retire the old one *before* its first commit
|
||||
// (Smithay clears the CRTC on surface drop), then publish metadata.
|
||||
let replacement = self.staged.remove(&crtc).unwrap();
|
||||
let previous = self.device.outputs.get_mut(&crtc).unwrap();
|
||||
previous.surface = replacement.surface;
|
||||
self.replaced.insert(crtc);
|
||||
}
|
||||
self.staged
|
||||
.get_mut(&crtc)
|
||||
.or_else(|| self.device.outputs.get_mut(&crtc))
|
||||
.unwrap()
|
||||
.surface
|
||||
.commit_frame()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn disable(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
self.device
|
||||
.outputs
|
||||
.get_mut(&crtc)
|
||||
.unwrap()
|
||||
.surface
|
||||
.clear()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn abort_staged(&mut self) {
|
||||
for output in self.staged.values_mut() {
|
||||
let _ = output.surface.clear();
|
||||
}
|
||||
self.staged.clear();
|
||||
}
|
||||
|
||||
fn restore(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
if self.replaced.contains(&crtc) {
|
||||
let output = &self.device.outputs[&crtc];
|
||||
let connector = self
|
||||
.device
|
||||
.drm
|
||||
.get_connector(output.connector, true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if connector.state() != connector::State::Connected {
|
||||
return Err("previous connector is no longer available".into());
|
||||
}
|
||||
let selection = OutputSelection {
|
||||
connector,
|
||||
crtc,
|
||||
mode: output.mode,
|
||||
settings: output.settings.clone(),
|
||||
identity: output.identity.clone(),
|
||||
mirror_source: output.mirror_source.clone(),
|
||||
};
|
||||
let mut replacement = create_direct_output(
|
||||
self.state,
|
||||
&mut self.device.drm,
|
||||
&self.device.gbm,
|
||||
&self.device.renderer,
|
||||
selection,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
prepare(
|
||||
&mut replacement.surface,
|
||||
&mut self.device.renderer,
|
||||
replacement.mode,
|
||||
&replacement.settings,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let previous = self.device.outputs.get_mut(&crtc).unwrap();
|
||||
previous.surface = replacement.surface;
|
||||
previous.surface.commit_frame().map_err(|error| error.to_string())?;
|
||||
}
|
||||
let Some(output) = self.device.outputs.get_mut(&crtc) else {
|
||||
return Ok(());
|
||||
};
|
||||
output.surface.reset_state().map_err(|error| error.to_string())?;
|
||||
let restored = output
|
||||
.surface
|
||||
.set_connectors(&[output.connector])
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|()| {
|
||||
prepare(
|
||||
&mut output.surface,
|
||||
&mut self.device.renderer,
|
||||
output.mode,
|
||||
&output.settings,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.and_then(|()| output.surface.commit_frame().map_err(|error| error.to_string()));
|
||||
self.state.session_lock.output_added(&output.output);
|
||||
output.lock_frame_pending = false;
|
||||
output.surface.set_output_mode_source((&output.output).into());
|
||||
cancel_output_timers(&self.state.loop_handle, output);
|
||||
output.frame_pending = false;
|
||||
restored
|
||||
}
|
||||
}
|
||||
|
||||
/// All allocations/tests precede commits. Logical publication follows every
|
||||
/// successful commit on this device; other devices are separately rollbackable.
|
||||
pub(super) fn apply_device(
|
||||
state: &mut Ferese,
|
||||
node: DrmNode,
|
||||
device: &mut DirectDevice,
|
||||
scan: OutputScan,
|
||||
) -> Result<(), String> {
|
||||
let selections = scan
|
||||
.selections
|
||||
.into_iter()
|
||||
.map(|selection| (selection.crtc, selection))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut changed = selections
|
||||
.iter()
|
||||
.filter_map(|(crtc, selection)| {
|
||||
let same = device.outputs.get(crtc).is_some_and(|output| {
|
||||
output.connector == selection.connector.handle()
|
||||
&& output.identity == selection.identity
|
||||
&& output.mode == selection.mode
|
||||
&& output.settings.same_configuration(&selection.settings)
|
||||
&& output.mirror_source == selection.mirror_source
|
||||
});
|
||||
(!same).then_some(*crtc)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
changed.sort_by_key(|crtc| (selections[crtc].mirror_source.is_some(), u32::from(*crtc)));
|
||||
let removals = device
|
||||
.outputs
|
||||
.keys()
|
||||
.filter(|crtc| !selections.contains_key(crtc))
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let hardware_changes = changed
|
||||
.iter()
|
||||
.filter(|crtc| {
|
||||
!device.outputs.get(crtc).is_some_and(|output| {
|
||||
let selection = &selections[crtc];
|
||||
output.connector == selection.connector.handle() && output.mode == selection.mode
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let mut driver = DeviceTransaction {
|
||||
state,
|
||||
device,
|
||||
selections: &selections,
|
||||
staged: HashMap::new(),
|
||||
replaced: HashSet::new(),
|
||||
};
|
||||
let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &removals);
|
||||
let mut staged = std::mem::take(&mut driver.staged);
|
||||
if let Err(failure) = result {
|
||||
for crtc in failure.unavailable {
|
||||
if let Some(mut output) = device.outputs.remove(&crtc) {
|
||||
retire(state, &mut output, node, crtc);
|
||||
}
|
||||
}
|
||||
return Err(failure.error);
|
||||
}
|
||||
for crtc in &changed {
|
||||
let selection = &selections[crtc];
|
||||
let mut output = staged.remove(crtc).or_else(|| device.outputs.remove(crtc)).unwrap();
|
||||
let old_geometry = state.space.output_geometry(&output.output);
|
||||
let old_id = state.output_id(&output.output);
|
||||
if output.connector != selection.connector.handle() || output.identity != selection.identity {
|
||||
retire(state, &mut output, node, *crtc);
|
||||
let (handle, _) = create_output(
|
||||
state,
|
||||
&selection.connector,
|
||||
selection.mode,
|
||||
selection.identity.clone(),
|
||||
&selection.settings,
|
||||
false,
|
||||
);
|
||||
output.output = handle;
|
||||
}
|
||||
if selection.mirror_source.is_some() && output.global.is_some() {
|
||||
retire(state, &mut output, node, *crtc);
|
||||
}
|
||||
output.connector = selection.connector.handle();
|
||||
output.internal = internal_connector(selection.connector.interface());
|
||||
output.identity = selection.identity.clone();
|
||||
output.settings = selection.settings.clone();
|
||||
output.mode = selection.mode;
|
||||
output.mirror_source = selection.mirror_source.clone();
|
||||
let position = selection.settings.position.unwrap_or_else(|| {
|
||||
old_geometry
|
||||
.map(|geometry| [geometry.loc.x, geometry.loc.y])
|
||||
.unwrap_or_else(|| {
|
||||
[
|
||||
state
|
||||
.space
|
||||
.outputs()
|
||||
.filter_map(|output| state.space.output_geometry(output))
|
||||
.map(|geometry| geometry.loc.x + geometry.size.w)
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
0,
|
||||
]
|
||||
})
|
||||
});
|
||||
output.output.change_current_state(
|
||||
Some(OutputMode::from(output.mode)),
|
||||
Some(output_transform(output.settings.transform)),
|
||||
Some(Scale::Fractional(output.settings.scale)),
|
||||
Some((position[0], position[1]).into()),
|
||||
);
|
||||
if output.mirror_source.is_none() {
|
||||
if output.global.is_none() {
|
||||
output.global = Some(output.output.create_global::<Ferese>(&state.display_handle));
|
||||
}
|
||||
state.space.map_output(&output.output, (position[0], position[1]));
|
||||
if state.output_id(&output.output).is_none() {
|
||||
state.register_output(&output.output, output.identity.clone());
|
||||
}
|
||||
if let Some(id) = state.output_id(&output.output)
|
||||
&& let Some(geometry) = state.space.output_geometry(&output.output)
|
||||
{
|
||||
state.output_workspaces.update_geometry(
|
||||
id,
|
||||
ferese_core::OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h),
|
||||
);
|
||||
if old_id == Some(id)
|
||||
&& let Some(old) = old_geometry
|
||||
{
|
||||
state.reposition_output_floats(
|
||||
id,
|
||||
ferese_layout::Rect::new(
|
||||
old.loc.x as f64,
|
||||
old.loc.y as f64,
|
||||
old.size.w as f64,
|
||||
old.size.h as f64,
|
||||
),
|
||||
ferese_layout::Rect::new(
|
||||
geometry.loc.x as f64,
|
||||
geometry.loc.y as f64,
|
||||
geometry.size.w as f64,
|
||||
geometry.size.h as f64,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.session_lock.output_added(&output.output);
|
||||
output.lock_frame_pending = false;
|
||||
output.surface.set_output_mode_source((&output.output).into());
|
||||
cancel_output_timers(&state.loop_handle, &mut output);
|
||||
output.frame_pending = false;
|
||||
output.primary_commit = None;
|
||||
output.capture_texture = None;
|
||||
output.power.suspended();
|
||||
let clock = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.presentation
|
||||
.entry((node, *crtc))
|
||||
.or_default();
|
||||
clock.set_refresh(OutputMode::from(output.mode).refresh);
|
||||
clock.reset_timing();
|
||||
device.outputs.insert(*crtc, output);
|
||||
}
|
||||
// Add/register destinations before removing former owners, reusing evacuation.
|
||||
for crtc in removals {
|
||||
let mut output = device.outputs.remove(&crtc).unwrap();
|
||||
retire(state, &mut output, node, crtc);
|
||||
}
|
||||
device.connected_outputs = scan.connected_outputs;
|
||||
update_applied_info(device);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn update_applied_info(device: &mut DirectDevice) {
|
||||
for info in &mut device.connected_outputs {
|
||||
info.enabled = false;
|
||||
info.current_mode = None;
|
||||
if let Some(output) = device.outputs.values().find(|output| output.identity == info.identity) {
|
||||
info.enabled = true;
|
||||
info.current_mode = Some(connected_mode_info(output.mode));
|
||||
info.scale = output.settings.scale;
|
||||
info.transform = output.settings.transform;
|
||||
info.configured_position = output.settings.position;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnected hardware cannot remain logically live, even if a new policy fails validation.
|
||||
pub(super) fn retire_disconnected(state: &mut Ferese) {
|
||||
let missing = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.devices
|
||||
.iter()
|
||||
.flat_map(|(node, device)| {
|
||||
device.outputs.iter().filter_map(|(crtc, output)| {
|
||||
device
|
||||
.drm
|
||||
.get_connector(output.connector, true)
|
||||
.ok()
|
||||
.filter(|connector| connector.state() != connector::State::Connected)
|
||||
.map(|_| (*node, *crtc))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (node, crtc) in missing {
|
||||
if let Some(mut output) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.devices
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.outputs
|
||||
.remove(&crtc)
|
||||
{
|
||||
let _ = output.surface.clear();
|
||||
retire(state, &mut output, node, crtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If initial activation or rollback leaves no usable desktop, try a preferred
|
||||
/// mode on each physical monitor independently. Never disable a surviving output.
|
||||
pub(super) fn recover_last_output(state: &mut Ferese) {
|
||||
if state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.devices
|
||||
.values()
|
||||
.any(|device| device.outputs.values().any(|output| output.global.is_some()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let backend = state.direct_backend.as_ref().unwrap();
|
||||
let requested = backend.desired_outputs.clone();
|
||||
let original_error = backend.output_error.clone();
|
||||
let mut candidates = backend
|
||||
.monitors
|
||||
.iter()
|
||||
.filter(|monitor| monitor.usable)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort_by_key(|monitor| (!monitor.internal, monitor.key.clone()));
|
||||
for candidate in candidates {
|
||||
let backend = state.direct_backend.as_mut().unwrap();
|
||||
backend.desired_outputs = DesiredOutputConfiguration {
|
||||
profile: None,
|
||||
suspend: requested.suspend,
|
||||
outputs: backend
|
||||
.monitors
|
||||
.iter()
|
||||
.map(|monitor| {
|
||||
let mut settings = crate::output_policy::defaults(monitor.identity.clone());
|
||||
settings.enabled = monitor.key == candidate.key;
|
||||
crate::output_policy::DesiredOutput {
|
||||
key: monitor.key.clone(),
|
||||
settings,
|
||||
mirror_source: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let nodes = backend
|
||||
.devices
|
||||
.iter()
|
||||
.filter(|(_, device)| candidate.key.starts_with(&format!("{}/", drm_device_key(&device.drm))))
|
||||
.map(|(node, _)| *node)
|
||||
.collect::<Vec<_>>();
|
||||
if validate_desired_outputs(state).is_ok() {
|
||||
for node in nodes {
|
||||
let _ = rescan_device(state, node);
|
||||
}
|
||||
if state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.devices
|
||||
.values()
|
||||
.any(|device| device.outputs.values().any(|output| output.global.is_some()))
|
||||
{
|
||||
remember_applied_configuration(state);
|
||||
state.direct_backend.as_mut().unwrap().output_error = original_error;
|
||||
tracing::warn!(identity = %candidate.identity, "recovered last usable output with preferred mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let backend = state.direct_backend.as_mut().unwrap();
|
||||
backend.desired_outputs = requested;
|
||||
backend.output_error = original_error;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
|
||||
pub use input_motion::InputSettings;
|
||||
use input_motion::*;
|
||||
use outputs::*;
|
||||
pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
|
||||
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
|
||||
use serde::Deserialize;
|
||||
use smithay::input::keyboard::{Keycode, keysyms, xkb};
|
||||
|
||||
@@ -1049,9 +1049,15 @@ mod tests {
|
||||
config.output_profiles().unwrap(),
|
||||
vec![OutputProfile {
|
||||
name: "docked".to_owned(),
|
||||
layout: OutputLayout::Extend,
|
||||
confirm_timeout: 15,
|
||||
lid_policy: LidPolicy::DockOrSuspend,
|
||||
lid_closed: None,
|
||||
mirror_source: None,
|
||||
outputs: vec![
|
||||
OutputSettings {
|
||||
auto_refresh: false,
|
||||
required: true,
|
||||
matcher: "HDMI-A-1".to_owned(),
|
||||
enabled: true,
|
||||
mode: Some(OutputModeRequest {
|
||||
@@ -1065,6 +1071,7 @@ mod tests {
|
||||
},
|
||||
OutputSettings {
|
||||
auto_refresh: false,
|
||||
required: true,
|
||||
matcher: "eDP-1".to_owned(),
|
||||
enabled: false,
|
||||
mode: None,
|
||||
@@ -1077,6 +1084,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_profile_metadata_and_invalid_mirrors() {
|
||||
let config = parse(
|
||||
"output-profile desk layout=\"mirror\" mirror-source=\"eDP-1\" lid-closed=#false confirm-timeout=0 lid-policy=\"ignore\" { output eDP-1; output DP-1 required=#false; }",
|
||||
);
|
||||
let profile = &config.output_profiles().unwrap()[0];
|
||||
assert_eq!(profile.layout, OutputLayout::Mirror);
|
||||
assert_eq!(profile.lid_closed, Some(false));
|
||||
assert_eq!(profile.confirm_timeout, 0);
|
||||
assert_eq!(profile.lid_policy, LidPolicy::Ignore);
|
||||
assert!(!profile.outputs[1].required);
|
||||
for source in [
|
||||
"output-profile a layout=\"mirror\" { output eDP-1; }",
|
||||
"output-profile a layout=\"extend\" mirror-source=\"eDP-1\" { output eDP-1; }",
|
||||
"output-profile a layout=\"mirror\" mirror-source=\"DP-2\" { output eDP-1; output DP-1; }",
|
||||
"output-profile a { output eDP-1 enabled=#false; }",
|
||||
"output-profile a { output eDP-1; }\noutput-profile a { output DP-1; }",
|
||||
] {
|
||||
assert!(parse(source).output_profiles().is_err(), "{source}");
|
||||
}
|
||||
assert!(Config::parse_source("output-profile a layout=\"docked\" { output eDP-1; }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_refresh_is_opt_in_and_rejects_wrong_types() {
|
||||
let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }");
|
||||
|
||||
@@ -43,6 +43,7 @@ impl Config {
|
||||
|
||||
Ok(OutputSettings {
|
||||
auto_refresh: output.auto_refresh,
|
||||
required: output.required,
|
||||
matcher: matcher.to_owned(),
|
||||
enabled: output.enabled,
|
||||
mode: output
|
||||
@@ -58,9 +59,38 @@ impl Config {
|
||||
})
|
||||
.collect::<Result<Vec<_>, ConfigError>>()?;
|
||||
|
||||
if !outputs.iter().any(|output| output.enabled) {
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?} disables every output"
|
||||
)));
|
||||
}
|
||||
if profile.layout != OutputLayout::Mirror && profile.mirror_source.is_some() {
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?}: mirror-source requires layout=\"mirror\""
|
||||
)));
|
||||
}
|
||||
if let Some(source) = &profile.mirror_source
|
||||
&& !outputs.iter().any(|output| &output.matcher == source && output.enabled)
|
||||
{
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?}: mirror source must name an enabled output"
|
||||
)));
|
||||
}
|
||||
if profile.layout == OutputLayout::Mirror && outputs.iter().filter(|output| output.enabled).count() < 2
|
||||
{
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?}: mirror needs at least two enabled outputs"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(OutputProfile {
|
||||
name: name.to_owned(),
|
||||
outputs,
|
||||
layout: profile.layout,
|
||||
confirm_timeout: profile.confirm_timeout,
|
||||
lid_policy: profile.lid_policy,
|
||||
lid_closed: profile.lid_closed,
|
||||
mirror_source: profile.mirror_source.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -112,11 +142,17 @@ pub(super) const fn default_output_scale() -> f64 {
|
||||
pub struct OutputProfile {
|
||||
pub name: String,
|
||||
pub outputs: Vec<OutputSettings>,
|
||||
pub layout: OutputLayout,
|
||||
pub confirm_timeout: u32,
|
||||
pub lid_policy: LidPolicy,
|
||||
pub lid_closed: Option<bool>,
|
||||
pub mirror_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OutputSettings {
|
||||
pub auto_refresh: bool,
|
||||
pub required: bool,
|
||||
pub matcher: String,
|
||||
pub enabled: bool,
|
||||
pub mode: Option<OutputModeRequest>,
|
||||
@@ -125,6 +161,18 @@ pub struct OutputSettings {
|
||||
pub position: Option<[i32; 2]>,
|
||||
}
|
||||
|
||||
impl OutputSettings {
|
||||
/// Selectors and requiredness affect matching, not the applied output state.
|
||||
pub(crate) fn same_configuration(&self, other: &Self) -> bool {
|
||||
self.enabled == other.enabled
|
||||
&& self.mode == other.mode
|
||||
&& self.auto_refresh == other.auto_refresh
|
||||
&& self.scale == other.scale
|
||||
&& self.transform == other.transform
|
||||
&& self.position == other.position
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct OutputModeRequest {
|
||||
pub width: u16,
|
||||
@@ -152,10 +200,40 @@ pub enum OutputTransform {
|
||||
Flipped270,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LidPolicy {
|
||||
#[default]
|
||||
DockOrSuspend,
|
||||
Ignore,
|
||||
}
|
||||
|
||||
const fn default_confirm_timeout() -> u32 {
|
||||
15
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OutputLayout {
|
||||
InternalOnly,
|
||||
ExternalOnly,
|
||||
#[default]
|
||||
Extend,
|
||||
Mirror,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct OutputProfileConfig {
|
||||
pub(super) name: String,
|
||||
#[serde(default)]
|
||||
pub(super) layout: OutputLayout,
|
||||
#[serde(default = "default_confirm_timeout")]
|
||||
pub(super) confirm_timeout: u32,
|
||||
#[serde(default)]
|
||||
pub(super) lid_policy: LidPolicy,
|
||||
pub(super) lid_closed: Option<bool>,
|
||||
pub(super) mirror_source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) outputs: Vec<OutputConfig>,
|
||||
}
|
||||
|
||||
@@ -163,6 +241,8 @@ pub(super) struct OutputProfileConfig {
|
||||
pub(super) struct OutputConfig {
|
||||
#[serde(default)]
|
||||
pub(super) auto_refresh: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub(super) required: bool,
|
||||
#[serde(rename = "match")]
|
||||
pub(super) matcher: String,
|
||||
#[serde(default = "default_true")]
|
||||
|
||||
+45
-5
@@ -694,7 +694,40 @@ impl Ferese {
|
||||
return Ok(json!(self.has_client_surfaces(pid)));
|
||||
}
|
||||
"get-workspaces" => return Ok(self.workspaces_json()),
|
||||
"get-outputs" => return Ok(self.outputs_json()),
|
||||
"outputs" | "get-outputs" => return Ok(self.outputs_json()),
|
||||
"output-profiles" => {
|
||||
return Ok(json!({
|
||||
"confirmation_pending": self.direct_backend.as_ref().is_some_and(|backend| backend.confirmation_pending()),
|
||||
"manual_profile": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.profile.as_ref()),
|
||||
"manual_internal": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.internal),
|
||||
"profiles": self.output_profiles.iter().map(|profile| json!({
|
||||
"name": profile.name, "layout": profile.layout,
|
||||
"confirm_timeout": profile.confirm_timeout, "lid_policy": profile.lid_policy, "lid_closed": profile.lid_closed, "mirror_source": profile.mirror_source,
|
||||
"outputs": profile.outputs.iter().map(|output| json!({"selector": output.matcher, "required": output.required, "enabled": output.enabled})).collect::<Vec<_>>()
|
||||
})).collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
"output-confirm" | "output-revert" => {
|
||||
crate::backends::direct::confirm_output_configuration(self, command == "output-confirm")
|
||||
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
|
||||
return Ok(self.outputs_json());
|
||||
}
|
||||
"output-profile" => {
|
||||
let name = args["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| CommandError::new("invalid_arguments", "output-profile requires a name or auto"))?;
|
||||
crate::backends::direct::set_output_profile(self, name)
|
||||
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
|
||||
return Ok(self.outputs_json());
|
||||
}
|
||||
"output-internal" => {
|
||||
let enabled = args["enabled"]
|
||||
.as_bool()
|
||||
.ok_or_else(|| CommandError::new("invalid_arguments", "output-internal requires on or off"))?;
|
||||
crate::backends::direct::set_internal_output(self, enabled)
|
||||
.map_err(|error| CommandError::new("output_configuration_failed", error))?;
|
||||
return Ok(self.outputs_json());
|
||||
}
|
||||
_ => {
|
||||
return Err(CommandError::new(
|
||||
"unknown_command",
|
||||
@@ -1026,8 +1059,8 @@ impl Ferese {
|
||||
.connected_outputs
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let mapped = self.space.outputs().find(|output| output.name() == info.connector);
|
||||
let id = mapped.and_then(|output| self.output_id(output));
|
||||
let mapped = self.output_by_identity(&info.identity);
|
||||
let id = self.persistent_output_id(&info.identity);
|
||||
let geometry = mapped.and_then(|output| self.space.output_geometry(output));
|
||||
let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id));
|
||||
let position = geometry
|
||||
@@ -1066,9 +1099,16 @@ impl Ferese {
|
||||
"name": info.connector,
|
||||
"connector": info.connector,
|
||||
"identity": info.identity,
|
||||
"connected": true,
|
||||
"enabled": info.enabled && mapped.is_some(),
|
||||
"connected": info.connected,
|
||||
"internal": info.internal,
|
||||
"requested_enabled": info.requested_enabled,
|
||||
"applied_enabled": info.enabled,
|
||||
"mirror_source": info.mirror_source,
|
||||
"enabled": info.enabled,
|
||||
"profile": info.profile,
|
||||
"requested_profile": info.requested_profile,
|
||||
"configuration_error": backend.output_configuration_error(),
|
||||
"confirmation_pending": backend.confirmation_pending(),
|
||||
"auto_refresh": info.auto_refresh,
|
||||
"low_power": backend.low_power,
|
||||
"focused": id.is_some() && id == focused,
|
||||
|
||||
@@ -16,6 +16,9 @@ mod input;
|
||||
mod input_capture;
|
||||
mod ipc;
|
||||
mod metrics;
|
||||
mod monitor_identity;
|
||||
mod output_policy;
|
||||
mod output_transaction;
|
||||
mod overview;
|
||||
mod portal_session;
|
||||
mod portal_shortcuts;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Human-readable EDID identities. Serial-less displays remain tied to a qualified connector.
|
||||
//! Duplicate serials are qualified too; a remembered collision stays qualified after unplug.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Identity {
|
||||
pub make: String,
|
||||
pub model: String,
|
||||
pub serial: Option<String>,
|
||||
}
|
||||
|
||||
fn escaped(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
|
||||
char::from(byte).to_string()
|
||||
} else {
|
||||
format!("%{byte:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn from_edid(edid: &[u8]) -> Option<Self> {
|
||||
if edid.len() < 128
|
||||
|| edid[..8] != [0, 255, 255, 255, 255, 255, 255, 0]
|
||||
|| edid[..128].iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte)) != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let manufacturer = u16::from_be_bytes([edid[8], edid[9]]);
|
||||
let mut make = String::new();
|
||||
for shift in [10, 5, 0] {
|
||||
let letter = ((manufacturer >> shift) & 31) as u8;
|
||||
if !(1..=26).contains(&letter) {
|
||||
return None;
|
||||
}
|
||||
make.push(char::from(b'A' + letter - 1));
|
||||
}
|
||||
let product = u16::from_le_bytes([edid[10], edid[11]]);
|
||||
let numeric_serial = u32::from_le_bytes(edid[12..16].try_into().ok()?);
|
||||
let mut model = format!("{product:04X}");
|
||||
let mut serial = None;
|
||||
for descriptor in edid[54..126].chunks_exact(18) {
|
||||
if descriptor[..3] != [0, 0, 0] {
|
||||
continue;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&descriptor[5..18])
|
||||
.trim_matches(|c: char| c.is_whitespace() || c == '\0')
|
||||
.to_owned();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match descriptor[3] {
|
||||
0xfc => model = format!("{product:04X}-{}", escaped(&text)),
|
||||
0xff if text != "0" => serial = Some(escaped(&text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if serial.is_none() && numeric_serial != 0 && numeric_serial != u32::MAX {
|
||||
serial = Some(format!("{numeric_serial:08X}"));
|
||||
}
|
||||
Some(Self { make, model, serial })
|
||||
}
|
||||
|
||||
pub fn base(&self) -> String {
|
||||
format!(
|
||||
"edid:{}:{}:{}",
|
||||
self.make,
|
||||
self.model,
|
||||
self.serial.as_deref().unwrap_or("no-serial")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IdentityRegistry {
|
||||
ambiguous: HashSet<String>,
|
||||
/// Retains the first monitor's identity if a later collision is discovered.
|
||||
known: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl IdentityRegistry {
|
||||
pub fn resolve(&mut self, monitors: &[(String, Option<Identity>)]) -> Vec<String> {
|
||||
let mut counts = HashMap::new();
|
||||
for (_, identity) in monitors {
|
||||
if let Some(identity) = identity {
|
||||
*counts.entry(identity.base()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
self.ambiguous
|
||||
.extend(counts.into_iter().filter(|(_, count)| *count > 1).map(|(base, _)| base));
|
||||
// Prefer already-known identities, but never permit duplicate runtime IDs.
|
||||
let mut used = HashSet::new();
|
||||
let mut ordered = monitors.iter().enumerate().collect::<Vec<_>>();
|
||||
ordered.sort_by_key(|(_, (key, _))| (!self.known.contains_key(key), key));
|
||||
let mut result = vec![String::new(); monitors.len()];
|
||||
for (index, (key, identity)) in ordered {
|
||||
let base = identity.as_ref().map(Identity::base);
|
||||
let qualified = match &base {
|
||||
Some(base) => format!("{base}@{}", escaped(key)),
|
||||
None => format!("drm:{}", escaped(key)),
|
||||
};
|
||||
let preferred = self
|
||||
.known
|
||||
.get(key)
|
||||
.filter(|known| {
|
||||
base.as_ref().is_some_and(|base| *known == base || *known == &qualified)
|
||||
|| (base.is_none() && *known == &qualified)
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
if identity.as_ref().is_some_and(|identity| identity.serial.is_some())
|
||||
&& !self.ambiguous.contains(base.as_ref().unwrap())
|
||||
{
|
||||
base.clone().unwrap()
|
||||
} else {
|
||||
qualified.clone()
|
||||
}
|
||||
});
|
||||
let resolved = if used.insert(preferred.clone()) {
|
||||
preferred
|
||||
} else {
|
||||
qualified
|
||||
};
|
||||
assert!(
|
||||
result.iter().all(|other| other != &resolved),
|
||||
"qualified hardware connector must be unique"
|
||||
);
|
||||
self.known.insert(key.clone(), resolved.clone());
|
||||
result[index] = resolved;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity(serial: Option<&str>) -> Identity {
|
||||
Identity {
|
||||
make: "DEL".into(),
|
||||
model: "1234-monitor".into(),
|
||||
serial: serial.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_on_same_connector_invalidates_cached_identity() {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let key = "gpu/DP-1".to_owned();
|
||||
let dell = identity(Some("123"));
|
||||
let mut lg = identity(Some("456"));
|
||||
lg.make = "GSM".into();
|
||||
let old = registry.resolve(&[(key.clone(), Some(dell.clone()))]);
|
||||
let new = registry.resolve(&[(key.clone(), Some(lg))]);
|
||||
assert_ne!(old, new);
|
||||
assert!(new[0].starts_with("edid:GSM:"));
|
||||
assert_eq!(registry.resolve(&[(key.clone(), Some(dell))]), old);
|
||||
assert!(registry.resolve(&[(key, None)])[0].starts_with("drm:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_survives_connector_change() {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let first = registry.resolve(&[("gpu/DP-1".into(), Some(identity(Some("123"))))]);
|
||||
let next = registry.resolve(&[("gpu/DP-2".into(), Some(identity(Some("123"))))]);
|
||||
assert_eq!(first, next);
|
||||
assert_eq!(first[0], "edid:DEL:1234-monitor:123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_without_serial_are_distinct_and_reconnect_stably() {
|
||||
for serial in [None, Some("duplicated")] {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let inputs = [
|
||||
("gpu/DP-1".into(), Some(identity(serial))),
|
||||
("gpu/DP-2".into(), Some(identity(serial))),
|
||||
];
|
||||
let resolved = registry.resolve(&inputs);
|
||||
assert_ne!(resolved[0], resolved[1]);
|
||||
assert_eq!(registry.resolve(&inputs[..1])[0], resolved[0]);
|
||||
assert_eq!(registry.resolve(&inputs), resolved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_after_first_connection_does_not_change_existing_identity() {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let a = ("gpu/DP-1".into(), Some(identity(Some("duplicate"))));
|
||||
let b = ("gpu/DP-2".into(), Some(identity(Some("duplicate"))));
|
||||
let first = registry.resolve(std::slice::from_ref(&a));
|
||||
let both = registry.resolve(&[a.clone(), b]);
|
||||
assert_eq!(both[0], first[0]);
|
||||
assert_ne!(both[0], both[1]);
|
||||
assert_eq!(registry.resolve(&[a]), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edid_decodes_make_product_and_numeric_serial_and_checks_checksum() {
|
||||
let mut edid = [0u8; 128];
|
||||
edid[..8].copy_from_slice(&[0, 255, 255, 255, 255, 255, 255, 0]);
|
||||
let del = (4u16 << 10) | (5 << 5) | 12;
|
||||
edid[8..10].copy_from_slice(&del.to_be_bytes());
|
||||
edid[10..12].copy_from_slice(&0x1234u16.to_le_bytes());
|
||||
edid[12..16].copy_from_slice(&42u32.to_le_bytes());
|
||||
edid[127] = 0u8.wrapping_sub(edid.iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte)));
|
||||
assert_eq!(Identity::from_edid(&edid).unwrap().base(), "edid:DEL:1234:0000002A");
|
||||
edid[127] ^= 1;
|
||||
assert!(Identity::from_edid(&edid).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Pure monitor selection. DRM handles and side effects never enter this module.
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use crate::config::{LidPolicy, OutputLayout, OutputProfile, OutputSettings, OutputTransform};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Monitor {
|
||||
/// Qualified hardware connector (GPU identity + connector), never a runtime Output handle.
|
||||
pub key: String,
|
||||
pub connector: String,
|
||||
pub identity: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub internal: bool,
|
||||
pub usable: bool,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn matches(&self, selector: &str) -> bool {
|
||||
selector == self.connector || selector == self.identity || self.aliases.iter().any(|alias| alias == selector)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct DesiredOutput {
|
||||
pub key: String,
|
||||
pub settings: OutputSettings,
|
||||
/// A mirror target has no workspace ownership of its own.
|
||||
pub mirror_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct DesiredOutputConfiguration {
|
||||
pub profile: Option<String>,
|
||||
pub outputs: Vec<DesiredOutput>,
|
||||
pub suspend: bool,
|
||||
}
|
||||
|
||||
/// Overrides expire on a *material* topology/lid change, independent of event ordering.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ManualOverride {
|
||||
pub profile: Option<String>,
|
||||
pub internal: Option<bool>,
|
||||
topology: Option<(BTreeSet<String>, bool)>,
|
||||
}
|
||||
|
||||
impl ManualOverride {
|
||||
pub fn observe(&mut self, monitors: &[Monitor], lid_closed: bool, profiles: &[OutputProfile]) {
|
||||
let topology = (
|
||||
monitors
|
||||
.iter()
|
||||
.map(|monitor| format!("{}={}", monitor.key, monitor.identity))
|
||||
.collect(),
|
||||
lid_closed,
|
||||
);
|
||||
if self.topology.as_ref().is_some_and(|old| old != &topology) {
|
||||
self.profile = None;
|
||||
self.internal = None;
|
||||
}
|
||||
if self.profile.as_ref().is_some_and(|name| {
|
||||
!profiles
|
||||
.iter()
|
||||
.any(|profile| profile.name == *name && profile_matches(profile, monitors, lid_closed))
|
||||
}) {
|
||||
self.profile = None;
|
||||
}
|
||||
self.topology = Some(topology);
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_matches(profile: &OutputProfile, monitors: &[Monitor], lid_closed: bool) -> bool {
|
||||
let mut matched = HashSet::new();
|
||||
if profile.outputs.iter().any(|settings| {
|
||||
monitors
|
||||
.iter()
|
||||
.filter(|monitor| monitor.matches(&settings.matcher))
|
||||
.any(|monitor| !matched.insert(&monitor.key))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
profile.lid_closed.is_none_or(|required| required == lid_closed)
|
||||
&& profile
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|settings| settings.required)
|
||||
.all(|settings| {
|
||||
// Ambiguous selectors cannot silently configure two monitors as one.
|
||||
monitors
|
||||
.iter()
|
||||
.filter(|monitor| monitor.matches(&settings.matcher))
|
||||
.count()
|
||||
== 1
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn defaults(matcher: String) -> OutputSettings {
|
||||
OutputSettings {
|
||||
auto_refresh: false,
|
||||
matcher,
|
||||
required: true,
|
||||
enabled: true,
|
||||
mode: None,
|
||||
scale: 1.0,
|
||||
transform: OutputTransform::Normal,
|
||||
position: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Most required monitors wins; equal specificity retains config order.
|
||||
/// Unlisted outputs keep working in extend profiles. Exclusive layouts deliberately filter them.
|
||||
pub(crate) fn select_profile(
|
||||
monitors: &[Monitor],
|
||||
lid_closed: bool,
|
||||
profiles: &[OutputProfile],
|
||||
manual: &ManualOverride,
|
||||
) -> DesiredOutputConfiguration {
|
||||
let selected = manual
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|name| {
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == *name && profile_matches(profile, monitors, lid_closed))
|
||||
})
|
||||
.or_else(|| {
|
||||
profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, profile)| profile_matches(profile, monitors, lid_closed))
|
||||
.max_by_key(|(index, profile)| {
|
||||
(
|
||||
profile.outputs.iter().filter(|output| output.required).count(),
|
||||
std::cmp::Reverse(*index),
|
||||
)
|
||||
})
|
||||
.map(|(_, profile)| profile)
|
||||
});
|
||||
let layout = selected.map_or(OutputLayout::Extend, |profile| profile.layout);
|
||||
let manage_lid = selected.is_none_or(|profile| profile.lid_policy == LidPolicy::DockOrSuspend);
|
||||
let external = monitors.iter().any(|monitor| monitor.usable && !monitor.internal);
|
||||
let mut outputs = monitors
|
||||
.iter()
|
||||
.map(|monitor| {
|
||||
let mut settings = selected
|
||||
.and_then(|profile| {
|
||||
profile
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|settings| monitor.matches(&settings.matcher))
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| defaults(monitor.identity.clone()));
|
||||
settings.enabled &= monitor.usable;
|
||||
settings.enabled &= match layout {
|
||||
OutputLayout::InternalOnly => monitor.internal,
|
||||
OutputLayout::ExternalOnly => !monitor.internal,
|
||||
OutputLayout::Extend | OutputLayout::Mirror => true,
|
||||
};
|
||||
if monitor.internal {
|
||||
if let Some(enabled) = manual.internal {
|
||||
settings.enabled = enabled && monitor.usable;
|
||||
} else if manage_lid && lid_closed && external {
|
||||
settings.enabled = false;
|
||||
}
|
||||
}
|
||||
DesiredOutput {
|
||||
key: monitor.key.clone(),
|
||||
settings,
|
||||
mirror_source: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !outputs.iter().any(|output| output.settings.enabled) {
|
||||
// Policy never disables the last usable display, even for an inapplicable exclusive layout.
|
||||
if let Some((index, _)) = monitors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, monitor)| monitor.usable)
|
||||
.min_by_key(|(_, monitor)| (!monitor.internal, &monitor.key))
|
||||
{
|
||||
outputs[index].settings = defaults(monitors[index].identity.clone());
|
||||
}
|
||||
}
|
||||
if layout == OutputLayout::Mirror {
|
||||
let source = selected
|
||||
.and_then(|profile| profile.mirror_source.as_ref())
|
||||
.and_then(|selector| {
|
||||
monitors
|
||||
.iter()
|
||||
.zip(&outputs)
|
||||
.find(|(monitor, output)| monitor.matches(selector) && output.settings.enabled)
|
||||
.map(|(_, output)| output.key.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
outputs
|
||||
.iter()
|
||||
.filter(|output| output.settings.enabled)
|
||||
.map(|output| output.key.clone())
|
||||
.min()
|
||||
});
|
||||
if let Some(source) = source {
|
||||
for output in &mut outputs {
|
||||
if output.settings.enabled && output.key != source {
|
||||
output.mirror_source = Some(source.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DesiredOutputConfiguration {
|
||||
profile: selected.map(|profile| profile.name.clone()),
|
||||
outputs,
|
||||
suspend: manage_lid && lid_closed && !external,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore actual settings only for surviving hardware; new monitors remain safe fallbacks.
|
||||
pub(crate) fn restore_configuration(
|
||||
previous: &DesiredOutputConfiguration,
|
||||
monitors: &[Monitor],
|
||||
) -> DesiredOutputConfiguration {
|
||||
let mut restored = previous.clone();
|
||||
restored
|
||||
.outputs
|
||||
.retain(|output| monitors.iter().any(|monitor| monitor.key == output.key));
|
||||
for monitor in monitors {
|
||||
if !restored.outputs.iter().any(|output| output.key == monitor.key) {
|
||||
let mut settings = defaults(monitor.identity.clone());
|
||||
settings.enabled = false;
|
||||
restored.outputs.push(DesiredOutput {
|
||||
key: monitor.key.clone(),
|
||||
settings,
|
||||
mirror_source: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
let source_keys = restored
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| output.settings.enabled)
|
||||
.map(|output| output.key.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
for output in &mut restored.outputs {
|
||||
if output
|
||||
.mirror_source
|
||||
.as_ref()
|
||||
.is_some_and(|source| !source_keys.contains(source))
|
||||
{
|
||||
output.mirror_source = None;
|
||||
}
|
||||
}
|
||||
if !restored.outputs.iter().any(|output| output.settings.enabled) {
|
||||
return select_profile(monitors, false, &[], &ManualOverride::default());
|
||||
}
|
||||
restored
|
||||
}
|
||||
|
||||
/// A diff carries only affected connectors; removing a connector is an explicit disable.
|
||||
pub(crate) fn changed_outputs<'a>(
|
||||
old: &'a DesiredOutputConfiguration,
|
||||
new: &'a DesiredOutputConfiguration,
|
||||
) -> Vec<&'a str> {
|
||||
let mut keys = HashSet::new();
|
||||
old.outputs
|
||||
.iter()
|
||||
.chain(&new.outputs)
|
||||
.filter_map(|output| {
|
||||
let key = output.key.as_str();
|
||||
if !keys.insert(key) {
|
||||
return None;
|
||||
}
|
||||
let previous = old.outputs.iter().find(|candidate| candidate.key == key);
|
||||
let next = new.outputs.iter().find(|candidate| candidate.key == key);
|
||||
let same = match (previous, next) {
|
||||
(Some(previous), Some(next)) => {
|
||||
previous.settings.same_configuration(&next.settings) && previous.mirror_source == next.mirror_source
|
||||
}
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
(!same).then_some(key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Debouncer {
|
||||
deadline: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl Debouncer {
|
||||
pub fn request(&mut self, now: std::time::Duration, active: bool) -> Option<std::time::Duration> {
|
||||
self.deadline = active.then(|| now + std::time::Duration::from_millis(150));
|
||||
self.deadline
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.deadline = None;
|
||||
}
|
||||
|
||||
pub fn take_due(&mut self, now: std::time::Duration) -> bool {
|
||||
if self.deadline.is_some_and(|deadline| deadline <= now) {
|
||||
self.deadline = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Placement {
|
||||
pub key: String,
|
||||
pub size: (i32, i32),
|
||||
pub previous: Option<[i32; 2]>,
|
||||
}
|
||||
|
||||
/// Explicit positions win. Preserve an automatic output's old position when
|
||||
/// it still fits; append new/conflicting outputs in stable hardware-key order.
|
||||
pub(crate) fn arrange_positions(
|
||||
desired: &mut DesiredOutputConfiguration,
|
||||
placements: &[Placement],
|
||||
) -> Result<(), String> {
|
||||
let mut occupied = Vec::new();
|
||||
let mut automatic = Vec::new();
|
||||
for (index, output) in desired.outputs.iter().enumerate() {
|
||||
if !output.settings.enabled || output.mirror_source.is_some() {
|
||||
continue;
|
||||
}
|
||||
let placement = placements
|
||||
.iter()
|
||||
.find(|placement| placement.key == output.key)
|
||||
.ok_or("missing output dimensions")?;
|
||||
if let Some(position) = output.settings.position {
|
||||
occupied.push((position[0], position[1], placement.size.0, placement.size.1));
|
||||
} else {
|
||||
automatic.push((index, placement));
|
||||
}
|
||||
}
|
||||
automatic.sort_by(|(_, a), (_, b)| (a.previous.is_none(), &a.key).cmp(&(b.previous.is_none(), &b.key)));
|
||||
for (index, placement) in automatic {
|
||||
let fits = |position: [i32; 2]| {
|
||||
let (x, y, w, h) = (
|
||||
position[0] as i64,
|
||||
position[1] as i64,
|
||||
placement.size.0 as i64,
|
||||
placement.size.1 as i64,
|
||||
);
|
||||
!occupied.iter().any(|&(ox, oy, ow, oh)| {
|
||||
x < ox as i64 + ow as i64 && x + w > ox as i64 && y < oy as i64 + oh as i64 && y + h > oy as i64
|
||||
})
|
||||
};
|
||||
let previous = placement.previous.filter(|position| fits(*position));
|
||||
let position = if let Some(previous) = previous {
|
||||
previous
|
||||
} else {
|
||||
let right = occupied
|
||||
.iter()
|
||||
.map(|&(x, _, w, _)| x as i64 + w as i64)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let x = i32::try_from(right).map_err(|_| "automatic output position overflows")?;
|
||||
x.checked_add(placement.size.0)
|
||||
.ok_or("automatic output geometry overflows")?;
|
||||
[x, 0]
|
||||
};
|
||||
desired.outputs[index].settings.position = Some(position);
|
||||
occupied.push((position[0], position[1], placement.size.0, placement.size.1));
|
||||
}
|
||||
let source_positions = desired
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| output.mirror_source.is_none())
|
||||
.map(|output| (output.key.clone(), output.settings.position))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
for output in &mut desired.outputs {
|
||||
if let Some(source) = &output.mirror_source {
|
||||
output.settings.position = source_positions.get(source).copied().flatten();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod placement_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_placement_is_stable_and_only_moves_overlapping_neighbors() {
|
||||
let monitors = [
|
||||
Monitor {
|
||||
key: "a".into(),
|
||||
connector: "a".into(),
|
||||
identity: "a".into(),
|
||||
aliases: vec![],
|
||||
internal: true,
|
||||
usable: true,
|
||||
},
|
||||
Monitor {
|
||||
key: "b".into(),
|
||||
connector: "b".into(),
|
||||
identity: "b".into(),
|
||||
aliases: vec![],
|
||||
internal: false,
|
||||
usable: true,
|
||||
},
|
||||
];
|
||||
let mut desired = select_profile(&monitors, false, &[], &ManualOverride::default());
|
||||
let placements = [
|
||||
Placement {
|
||||
key: "b".into(),
|
||||
size: (100, 100),
|
||||
previous: Some([100, 0]),
|
||||
},
|
||||
Placement {
|
||||
key: "a".into(),
|
||||
size: (100, 100),
|
||||
previous: Some([0, 0]),
|
||||
},
|
||||
];
|
||||
arrange_positions(&mut desired, &placements).unwrap();
|
||||
assert_eq!(desired.outputs[0].settings.position, Some([0, 0]));
|
||||
assert_eq!(desired.outputs[1].settings.position, Some([100, 0]));
|
||||
let mut expanded = select_profile(&monitors, false, &[], &ManualOverride::default());
|
||||
let placements = [
|
||||
Placement {
|
||||
key: "a".into(),
|
||||
size: (150, 100),
|
||||
previous: Some([0, 0]),
|
||||
},
|
||||
Placement {
|
||||
key: "b".into(),
|
||||
size: (100, 100),
|
||||
previous: Some([100, 0]),
|
||||
},
|
||||
];
|
||||
arrange_positions(&mut expanded, &placements).unwrap();
|
||||
assert_eq!(expanded.outputs[0].settings.position, Some([0, 0]));
|
||||
assert_eq!(expanded.outputs[1].settings.position, Some([150, 0]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn monitor(name: &str, internal: bool) -> Monitor {
|
||||
Monitor {
|
||||
key: name.into(),
|
||||
connector: name.into(),
|
||||
identity: format!("edid:{name}"),
|
||||
aliases: vec![],
|
||||
internal,
|
||||
usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(name: &str, outputs: &[&str], layout: OutputLayout) -> OutputProfile {
|
||||
OutputProfile {
|
||||
name: name.into(),
|
||||
outputs: outputs.iter().map(|name| defaults((*name).into())).collect(),
|
||||
layout,
|
||||
confirm_timeout: 15,
|
||||
lid_policy: LidPolicy::DockOrSuspend,
|
||||
lid_closed: None,
|
||||
mirror_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specificity_and_config_order() {
|
||||
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let profiles = [
|
||||
profile("mobile", &["eDP-1"], OutputLayout::Extend),
|
||||
profile("desk", &["eDP-1", "DP-1"], OutputLayout::Extend),
|
||||
profile("tie", &["DP-1", "eDP-1"], OutputLayout::Extend),
|
||||
];
|
||||
assert_eq!(
|
||||
select_profile(&monitors, false, &profiles, &ManualOverride::default())
|
||||
.profile
|
||||
.as_deref(),
|
||||
Some("desk")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_unknown_outputs_and_safe_fallback() {
|
||||
let monitors = [
|
||||
monitor("eDP-1", true),
|
||||
monitor("DP-1", false),
|
||||
monitor("HDMI-A-1", false),
|
||||
];
|
||||
for (layout, count) in [
|
||||
(OutputLayout::InternalOnly, 1),
|
||||
(OutputLayout::ExternalOnly, 2),
|
||||
(OutputLayout::Extend, 3),
|
||||
(OutputLayout::Mirror, 3),
|
||||
] {
|
||||
let desired = select_profile(
|
||||
&monitors,
|
||||
false,
|
||||
&[profile("test", &["eDP-1"], layout)],
|
||||
&ManualOverride::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
desired.outputs.iter().filter(|output| output.settings.enabled).count(),
|
||||
count
|
||||
);
|
||||
assert_eq!(
|
||||
desired
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| output.mirror_source.is_some())
|
||||
.count(),
|
||||
if layout == OutputLayout::Mirror { 2 } else { 0 }
|
||||
);
|
||||
}
|
||||
let missing = [profile("absent", &["DP-9"], OutputLayout::ExternalOnly)];
|
||||
let fallback = select_profile(&monitors, false, &missing, &ManualOverride::default());
|
||||
assert_eq!(fallback.profile, None);
|
||||
assert!(fallback.outputs.iter().all(|output| output.settings.enabled));
|
||||
let internal = &monitors[..1];
|
||||
let mut disabled = profile("disabled", &["eDP-1"], OutputLayout::Extend);
|
||||
disabled.outputs[0].enabled = false;
|
||||
assert!(
|
||||
select_profile(internal, false, &[disabled], &ManualOverride::default()).outputs[0]
|
||||
.settings
|
||||
.enabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_expires_only_on_material_change_or_invalid_reload() {
|
||||
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let profiles = [profile("mobile", &["eDP-1"], OutputLayout::InternalOnly)];
|
||||
let mut manual = ManualOverride::default();
|
||||
manual.observe(&monitors, false, &profiles);
|
||||
manual.profile = Some("mobile".into());
|
||||
manual.observe(&monitors, false, &profiles);
|
||||
assert_eq!(manual.profile.as_deref(), Some("mobile"));
|
||||
manual.observe(&monitors, true, &profiles);
|
||||
assert_eq!(manual.profile, None);
|
||||
manual.profile = Some("mobile".into());
|
||||
manual.observe(&monitors, true, &[]);
|
||||
assert_eq!(manual.profile, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lid_dock_undock_and_internal_toggle_never_disable_last_output() {
|
||||
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let docked = select_profile(&monitors, true, &[], &ManualOverride::default());
|
||||
assert!(!docked.suspend);
|
||||
assert!(!docked.outputs[0].settings.enabled);
|
||||
assert!(docked.outputs[1].settings.enabled);
|
||||
let manual = ManualOverride {
|
||||
internal: Some(false),
|
||||
..ManualOverride::default()
|
||||
};
|
||||
let undocked = select_profile(&monitors[..1], true, &[], &manual);
|
||||
assert!(undocked.suspend);
|
||||
assert!(undocked.outputs[0].settings.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desired_diff_is_local() {
|
||||
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let old = select_profile(&monitors, false, &[], &ManualOverride::default());
|
||||
assert!(changed_outputs(&old, &old).is_empty());
|
||||
let mut new = old.clone();
|
||||
new.outputs[1].settings.scale = 1.5;
|
||||
assert_eq!(changed_outputs(&old, &new), vec!["DP-1"]);
|
||||
}
|
||||
#[test]
|
||||
fn mirror_removal_and_extend_restore_single_ownership_plan() {
|
||||
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let mut mirror = profile("mirror", &["eDP-1", "DP-1"], OutputLayout::Mirror);
|
||||
mirror.mirror_source = Some("eDP-1".into());
|
||||
let old = select_profile(&monitors, false, &[mirror.clone()], &ManualOverride::default());
|
||||
assert_eq!(old.outputs[1].mirror_source.as_deref(), Some("eDP-1"));
|
||||
let removed = select_profile(&monitors[..1], false, &[mirror], &ManualOverride::default());
|
||||
assert!(removed.outputs[0].mirror_source.is_none());
|
||||
assert!(removed.outputs[0].settings.enabled);
|
||||
let extended = select_profile(&monitors, false, &[], &ManualOverride::default());
|
||||
assert!(extended.outputs.iter().all(|output| output.mirror_source.is_none()));
|
||||
assert_eq!(changed_outputs(&old, &extended), vec!["DP-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_outputs_and_lid_specific_profiles() {
|
||||
let monitors = [monitor("eDP-1", true)];
|
||||
let mut mobile = profile("mobile", &["eDP-1", "DP-1"], OutputLayout::Extend);
|
||||
mobile.outputs[1].required = false;
|
||||
mobile.lid_closed = Some(false);
|
||||
assert_eq!(
|
||||
select_profile(&monitors, false, &[mobile.clone()], &ManualOverride::default())
|
||||
.profile
|
||||
.as_deref(),
|
||||
Some("mobile")
|
||||
);
|
||||
assert_eq!(
|
||||
select_profile(&monitors, true, &[mobile], &ManualOverride::default()).profile,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapping_monitor_on_same_connector_expires_override() {
|
||||
let mut monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let profiles = [profile("mobile", &["eDP-1"], OutputLayout::InternalOnly)];
|
||||
let mut manual = ManualOverride::default();
|
||||
manual.observe(&monitors, false, &profiles);
|
||||
manual.profile = Some("mobile".into());
|
||||
monitors[1].identity = "edid:new-monitor".into();
|
||||
manual.observe(&monitors, false, &profiles);
|
||||
assert!(manual.profile.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoration_survives_disconnect_and_keeps_new_monitor_as_emergency_fallback() {
|
||||
let monitors = [monitor("eDP-1", true), monitor("DP-1", false)];
|
||||
let previous = select_profile(&monitors, false, &[], &ManualOverride::default());
|
||||
let restored = restore_configuration(&previous, &monitors[..1]);
|
||||
assert_eq!(restored.outputs.len(), 1);
|
||||
assert!(restored.outputs[0].settings.enabled);
|
||||
let unknown = [monitor("HDMI-A-2", false)];
|
||||
assert!(restore_configuration(&previous, &unknown).outputs[0].settings.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod debounce_tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn a_dock_burst_produces_one_reconciliation_after_last_event() {
|
||||
let mut debounce = Debouncer::default();
|
||||
for time in [0, 40, 110] {
|
||||
debounce.request(Duration::from_millis(time), true);
|
||||
}
|
||||
assert!(!debounce.take_due(Duration::from_millis(150)));
|
||||
assert!(!debounce.take_due(Duration::from_millis(259)));
|
||||
assert!(debounce.take_due(Duration::from_millis(260)));
|
||||
assert!(!debounce.take_due(Duration::from_millis(400)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactivity_cancels_deadlines_and_resume_uses_fresh_state() {
|
||||
let mut debounce = Debouncer::default();
|
||||
debounce.request(Duration::ZERO, true);
|
||||
assert_eq!(debounce.request(Duration::from_millis(30), false), None);
|
||||
assert!(!debounce.take_due(Duration::from_secs(10)));
|
||||
debounce.request(Duration::from_secs(10), true);
|
||||
assert!(debounce.take_due(Duration::from_millis(10150)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Device transaction sequencing, independent of DRM so failure paths are deterministic tests.
|
||||
|
||||
pub(crate) trait HardwareTransaction {
|
||||
type Key: Copy + Eq;
|
||||
fn stage(&mut self, key: Self::Key) -> Result<(), String>;
|
||||
fn commit(&mut self, key: Self::Key) -> Result<(), String>;
|
||||
fn disable(&mut self, key: Self::Key) -> Result<(), String>;
|
||||
fn abort_staged(&mut self);
|
||||
fn restore(&mut self, key: Self::Key) -> Result<(), String>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Failure<K> {
|
||||
pub error: String,
|
||||
pub unavailable: Vec<K>,
|
||||
}
|
||||
|
||||
/// Caller must validate the *complete* device plan first. No logical publication
|
||||
/// is permitted until success. Unavailable rollback results must be retired.
|
||||
pub(crate) fn apply<T: HardwareTransaction>(
|
||||
driver: &mut T,
|
||||
changes: &[T::Key],
|
||||
removals: &[T::Key],
|
||||
) -> Result<(), Failure<T::Key>> {
|
||||
let mut touched = Vec::new();
|
||||
let result = (|| {
|
||||
for key in changes {
|
||||
touched.push(*key);
|
||||
driver.stage(*key)?;
|
||||
}
|
||||
for key in changes {
|
||||
driver.commit(*key)?;
|
||||
}
|
||||
for key in removals {
|
||||
touched.push(*key);
|
||||
driver.disable(*key)?;
|
||||
}
|
||||
Ok::<_, String>(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
driver.abort_staged();
|
||||
let unavailable = touched
|
||||
.into_iter()
|
||||
.filter(|key| driver.restore(*key).is_err())
|
||||
.collect();
|
||||
return Err(Failure { error, unavailable });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
struct Fake {
|
||||
active: BTreeSet<u8>,
|
||||
previous: BTreeSet<u8>,
|
||||
events: Vec<(&'static str, u8)>,
|
||||
fail: Option<(&'static str, u8)>,
|
||||
restore_fails: bool,
|
||||
}
|
||||
|
||||
impl HardwareTransaction for Fake {
|
||||
type Key = u8;
|
||||
fn stage(&mut self, key: u8) -> Result<(), String> {
|
||||
self.event("stage", key)
|
||||
}
|
||||
fn commit(&mut self, key: u8) -> Result<(), String> {
|
||||
self.event("commit", key)?;
|
||||
self.active.insert(key);
|
||||
Ok(())
|
||||
}
|
||||
fn disable(&mut self, key: u8) -> Result<(), String> {
|
||||
self.event("disable", key)?;
|
||||
self.active.remove(&key);
|
||||
Ok(())
|
||||
}
|
||||
fn abort_staged(&mut self) {
|
||||
self.events.push(("abort", 0));
|
||||
}
|
||||
fn restore(&mut self, key: u8) -> Result<(), String> {
|
||||
self.events.push(("restore", key));
|
||||
if self.restore_fails {
|
||||
self.active.remove(&key);
|
||||
return Err("lost".into());
|
||||
}
|
||||
if self.previous.contains(&key) {
|
||||
self.active.insert(key);
|
||||
} else {
|
||||
self.active.remove(&key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
active: BTreeSet::from([1]),
|
||||
previous: BTreeSet::from([1]),
|
||||
events: vec![],
|
||||
fail: None,
|
||||
restore_fails: false,
|
||||
}
|
||||
}
|
||||
fn event(&mut self, operation: &'static str, key: u8) -> Result<(), String> {
|
||||
self.events.push((operation, key));
|
||||
if self.fail == Some((operation, key)) {
|
||||
Err("injected DRM failure".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotplug_then_remove_stages_before_applying_and_preserves_destination() {
|
||||
let mut fake = Fake::new();
|
||||
apply(&mut fake, &[2, 3], &[]).unwrap();
|
||||
assert_eq!(&fake.events[..2], &[("stage", 2), ("stage", 3)]);
|
||||
assert_eq!(fake.active, BTreeSet::from([1, 2, 3]));
|
||||
fake.previous = fake.active.clone();
|
||||
apply(&mut fake, &[], &[2, 3]).unwrap();
|
||||
assert_eq!(fake.active, BTreeSet::from([1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_commit_and_disable_failures_restore_known_good() {
|
||||
for failure in [("stage", 3), ("commit", 3), ("disable", 1)] {
|
||||
let mut fake = Fake::new();
|
||||
fake.fail = Some(failure);
|
||||
let error = apply(&mut fake, &[2, 3], &[1]).unwrap_err();
|
||||
assert!(error.unavailable.is_empty());
|
||||
assert_eq!(fake.active, fake.previous);
|
||||
assert!(fake.events.iter().any(|(event, _)| *event == "abort"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_restoration_reports_outputs_that_must_not_remain_logically_live() {
|
||||
let mut fake = Fake::new();
|
||||
fake.fail = Some(("commit", 1));
|
||||
fake.restore_fails = true;
|
||||
let failure = apply(&mut fake, &[1], &[]).unwrap_err();
|
||||
assert_eq!(failure.unavailable, vec![1]);
|
||||
assert!(!fake.active.contains(&1));
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,47 @@ mod tests {
|
||||
assert!(!state.inactive_dim.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_monitor_reload_keeps_last_good_runtime_configuration() {
|
||||
const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = Directory::new();
|
||||
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"reload::tests::invalid_monitor_reload_keeps_last_good_runtime_configuration",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("XDG_RUNTIME_DIR", &directory.0)
|
||||
.env("XDG_CONFIG_HOME", &directory.0)
|
||||
.env_remove("FERESE_SOCKET")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
||||
return;
|
||||
}
|
||||
let directory = Directory::new();
|
||||
let source = "output-profile mobile { output eDP-1 scale=1.75; }";
|
||||
let (_, runtime, _) = crate::theme::prepare(source, &directory.0).unwrap();
|
||||
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
||||
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
|
||||
state.config_source = Some(source.into());
|
||||
let accepted = state.output_profiles.clone();
|
||||
for invalid in [
|
||||
"output-profile bad { output eDP-1 scale=0; }",
|
||||
"output-profile bad layout=\"mirror\" { output eDP-1; }",
|
||||
"output-profile mobile { output eDP-1; }\noutput-profile mobile { output DP-1; }",
|
||||
] {
|
||||
assert!(state.reload_config_source(invalid.into()).is_err());
|
||||
assert_eq!(state.output_profiles, accepted);
|
||||
assert_eq!(state.config_source.as_deref(), Some(source));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_validation_rejects_bad_edits_before_application() {
|
||||
for source in [
|
||||
|
||||
@@ -145,6 +145,18 @@ impl Lock {
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
fn lock_outputs_protected(&self) -> bool {
|
||||
if let Some(backend) = self.direct_backend.as_ref() {
|
||||
// Reconciliation temporarily removes devices while staging changes.
|
||||
// Never interpret that partial inventory as protected displays.
|
||||
if backend.reconciling() {
|
||||
return false;
|
||||
}
|
||||
return self.session_lock.ready_for_idle(backend.physical_outputs());
|
||||
}
|
||||
self.session_lock.ready_for_idle(self.space.outputs())
|
||||
}
|
||||
|
||||
pub(crate) fn lock_input_activity(&mut self) {
|
||||
if self.session_lock.activity(Instant::now()) {
|
||||
crate::backends::direct::wake_locked_outputs(self);
|
||||
@@ -162,7 +174,7 @@ impl Ferese {
|
||||
if let Some(token) = self.session_lock.idle_timer.take() {
|
||||
self.loop_handle.remove(token);
|
||||
}
|
||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
||||
if !self.lock_outputs_protected() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,7 +201,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
fn update_lock_idle(&mut self, now: Instant) {
|
||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
||||
if !self.lock_outputs_protected() {
|
||||
return;
|
||||
}
|
||||
let fade_duration = self.animation_duration(Duration::from_millis(500));
|
||||
@@ -257,7 +269,7 @@ impl Ferese {
|
||||
pub(crate) fn confirm_lock_if_ready(&mut self) {
|
||||
// With no outputs there is no visible content to protect. Re-evaluate
|
||||
// here after acquisition and output removal as well as presentation.
|
||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
||||
if !self.lock_outputs_protected() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -629,6 +641,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_must_present_protected_frame_before_lock_is_ready() {
|
||||
let make_output = |name: &str| {
|
||||
Output::new(
|
||||
name.into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
)
|
||||
};
|
||||
let source = make_output("source");
|
||||
let mirror = make_output("mirror");
|
||||
let mut lock = Lock {
|
||||
lifecycle: Lifecycle::Orphaned,
|
||||
..Lock::default()
|
||||
};
|
||||
lock.presented.insert(source.clone());
|
||||
let displays = [&source, &mirror];
|
||||
assert!(
|
||||
!lock.ready_for_idle(displays.into_iter()),
|
||||
"failed mirror rendering must block confirmation"
|
||||
);
|
||||
lock.presented.insert(mirror.clone());
|
||||
assert!(lock.ready_for_idle(displays.into_iter()));
|
||||
lock.output_added(&mirror);
|
||||
assert!(
|
||||
!lock.ready_for_idle(displays.into_iter()),
|
||||
"reconfiguration invalidates physical protection"
|
||||
);
|
||||
lock.output_removed(&mirror);
|
||||
assert!(lock.ready_for_idle(std::iter::once(&source)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_fallback_frames_allow_idle_after_early_locker_crash() {
|
||||
let output = Output::new(
|
||||
|
||||
@@ -263,6 +263,14 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub(crate) fn send_shell_snapshots(&mut self) {
|
||||
if self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.reconciling())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.shell_resources.retain(|resource| resource.upgrade().is_ok());
|
||||
if self.shell_resources.is_empty() {
|
||||
self.last_shell_snapshot = None;
|
||||
|
||||
@@ -70,6 +70,14 @@ impl Ferese {
|
||||
}
|
||||
|
||||
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>) {
|
||||
if self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.reconciling())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(outputs) = redraw_outputs.as_mut() {
|
||||
outputs.extend(
|
||||
self.space
|
||||
|
||||
@@ -7,6 +7,24 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn output_by_identity(&self, identity: &str) -> Option<&Output> {
|
||||
self.output_identity_ids
|
||||
.get(identity)
|
||||
.and_then(|id| self.outputs_by_id.get(id))
|
||||
}
|
||||
|
||||
pub(crate) fn persistent_output_id(&self, identity: &str) -> Option<OutputId> {
|
||||
self.output_identity_ids.get(identity).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_output_identity(&mut self, identity: &str) -> OutputId {
|
||||
*self.output_identity_ids.entry(identity.to_owned()).or_insert_with(|| {
|
||||
let id = OutputId(self.next_output_id);
|
||||
self.next_output_id = self.next_output_id.checked_add(1).expect("output ID space exhausted");
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register_output(&mut self, output: &Output, identity: String) {
|
||||
if self.output_ids.is_empty() {
|
||||
self.reset_animation_clock();
|
||||
@@ -20,20 +38,40 @@ impl Ferese {
|
||||
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
||||
return;
|
||||
};
|
||||
let output_id = *self.output_identity_ids.entry(identity).or_insert_with(|| {
|
||||
let id = OutputId(self.next_output_id);
|
||||
self.next_output_id = self.next_output_id.saturating_add(1);
|
||||
id
|
||||
});
|
||||
let output_id = self.ensure_output_identity(&identity);
|
||||
let fallback_workspace = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none())
|
||||
.filter(|workspace| {
|
||||
self.output_workspaces.output_for_workspace(workspace.id).is_none()
|
||||
&& self
|
||||
.output_workspaces
|
||||
.home_output(workspace.id)
|
||||
.is_none_or(|home| home == output_id)
|
||||
})
|
||||
.map(|workspace| workspace.id)
|
||||
.min_by_key(|id| id.0)
|
||||
.unwrap_or_else(|| self.workspaces.create_workspace());
|
||||
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
|
||||
|
||||
let returning_floats = self
|
||||
.windows
|
||||
.ids()
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
if self.output_workspaces.home_output(workspace) != Some(output_id) {
|
||||
return None;
|
||||
}
|
||||
let owner = self.output_workspaces.output_for_workspace(workspace)?;
|
||||
let old = self.output_workspaces.geometry(owner)?;
|
||||
let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else {
|
||||
return None;
|
||||
};
|
||||
Some((*id, workspace, rect, old))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
|
||||
|
||||
match registration {
|
||||
@@ -41,6 +79,27 @@ impl Ferese {
|
||||
self.output_ids.insert(output.clone(), output_id);
|
||||
self.outputs_by_id.insert(output_id, output.clone());
|
||||
self.output_names.insert(output_id, output.name());
|
||||
for (id, workspace, rect, old) in returning_floats {
|
||||
if self.output_workspaces.output_for_workspace(workspace) == Some(output_id) {
|
||||
let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64);
|
||||
let new = Rect::new(
|
||||
geometry.x as f64,
|
||||
geometry.y as f64,
|
||||
geometry.width as f64,
|
||||
geometry.height as f64,
|
||||
);
|
||||
let _ = self
|
||||
.workspaces
|
||||
.set_floating_rect(id, moved_floating_rect(rect, old, new));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.reconciling())
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.restore_output_focus();
|
||||
let visible = self.visible_workspace_ids();
|
||||
self.reconcile_workspaces(&visible);
|
||||
@@ -80,8 +139,35 @@ impl Ferese {
|
||||
self.confirm_lock_if_ready();
|
||||
self.refresh_lock_idle_policy();
|
||||
|
||||
let old_geometry = self.output_workspaces.geometry(output_id);
|
||||
let evacuated = self
|
||||
.output_workspaces
|
||||
.assigned_workspaces(output_id)
|
||||
.collect::<Vec<_>>();
|
||||
match self.output_workspaces.disconnect(output_id) {
|
||||
Ok(Some(target)) => {
|
||||
if let (Some(old), Some(new)) = (old_geometry, self.output_workspaces.geometry(target)) {
|
||||
let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64);
|
||||
let new = Rect::new(new.x as f64, new.y as f64, new.width as f64, new.height as f64);
|
||||
let floats = self
|
||||
.windows
|
||||
.ids()
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
if !evacuated.contains(&workspace) {
|
||||
return None;
|
||||
}
|
||||
match self.workspaces.placement(*id)? {
|
||||
WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (id, rect) in floats {
|
||||
let _ = self.workspaces.set_floating_rect(id, rect);
|
||||
}
|
||||
}
|
||||
if let Some(workspace) = self.output_workspaces.active_workspace(target) {
|
||||
self.activate_output_workspace(target, workspace);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user