Extend output profiles with monitor reconciliation and mirroring
This commit is contained in:
1 parent
ac184576f6
commit
d14cd3cbd2
23 files changed
+3596
-456
No files matched your search
+618
-335
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,7 @@ pub(super) fn capture_output(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
||||||
renderer: &mut GlesRenderer,
|
renderer: &mut GlesRenderer,
|
||||||
texture: &'a mut Option<GlesTexture>,
|
texture: &'a mut Option<GlesTexture>,
|
||||||
output: &Output,
|
output: &Output,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Resume reads are asynchronous; newer switch events win over old replies.
|
//! Resume reads are asynchronous; newer switch events win over old replies.
|
||||||
use std::sync::mpsc;
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
|
||||||
use super::*;
|
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 {
|
pub(super) struct Reader {
|
||||||
requests: mpsc::Sender<Probe>,
|
requests: mpsc::Sender<Probe>,
|
||||||
|
suspend: mpsc::Sender<PolicyRequest>,
|
||||||
|
lease: Arc<Mutex<InhibitorLease<zbus::zvariant::OwnedFd>>>,
|
||||||
|
suspend_requested: bool,
|
||||||
pub deadline: Option<RegistrationToken>,
|
pub deadline: Option<RegistrationToken>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +142,94 @@ fn read_acpi() -> Option<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Reader {
|
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>> {
|
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 (requests, receiver) = mpsc::channel::<Probe>();
|
||||||
let (replies, events) = smithay::reexports::calloop::channel::channel();
|
let (replies, events) = smithay::reexports::calloop::channel::channel();
|
||||||
state.loop_handle.insert_source(events, |event, _, state| {
|
state.loop_handle.insert_source(events, |event, _, state| {
|
||||||
@@ -126,10 +256,15 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Self {
|
let mut reader = Self {
|
||||||
requests,
|
requests,
|
||||||
|
suspend,
|
||||||
|
lease,
|
||||||
|
suspend_requested: false,
|
||||||
deadline: None,
|
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);
|
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 probe = backend.lid.begin(reactivate);
|
||||||
let sent = backend.lid_reader.requests.send(probe).is_ok();
|
let sent = backend.lid_reader.requests.send(probe).is_ok();
|
||||||
let timer = state
|
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
|
// Waking a locked session may request redraws. Hold them until the fresh
|
||||||
// device/connector snapshot has been reconciled.
|
// device/connector snapshot has been reconciled.
|
||||||
|
backend.lid_reader.suspend_requested = false;
|
||||||
backend.topology.reconciling = true;
|
backend.topology.reconciling = true;
|
||||||
state.reset_animation_clock();
|
state.reset_animation_clock();
|
||||||
state.lock_input_activity();
|
state.lock_input_activity();
|
||||||
@@ -198,10 +336,40 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
|
|||||||
reconcile_outputs(state, reactivate);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
#[ignore = "requires dbus-daemon and private test sockets"]
|
#[ignore = "requires dbus-daemon and private test sockets"]
|
||||||
fn logind_reads_are_fresh_without_property_change_signals() {
|
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 reconciling: bool,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
pub retry_timer: Option<RegistrationToken>,
|
pub retry_timer: Option<RegistrationToken>,
|
||||||
|
pub settle_timer: Option<RegistrationToken>,
|
||||||
|
pub coalescer: crate::output_policy::Debouncer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Topology {
|
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 {
|
fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState {
|
||||||
state
|
state
|
||||||
.direct_backend
|
.direct_backend
|
||||||
@@ -180,8 +115,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
return;
|
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.reconciling = true;
|
||||||
backend.topology.dirty = false;
|
backend.topology.dirty = false;
|
||||||
|
backend.output_error = None;
|
||||||
if reactivate {
|
if reactivate {
|
||||||
backend
|
backend
|
||||||
.presentation
|
.presentation
|
||||||
@@ -191,7 +131,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
if let Some(token) = backend.topology.retry_timer.take() {
|
if let Some(token) = backend.topology.retry_timer.take() {
|
||||||
state.loop_handle.remove(token);
|
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 invalidated = std::mem::take(&mut backend.topology.invalidated);
|
||||||
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
||||||
paths
|
paths
|
||||||
@@ -209,6 +149,23 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
backend.low_power =
|
backend.low_power =
|
||||||
automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent());
|
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 {
|
for node in invalidated {
|
||||||
remove_device(state, node);
|
remove_device(state, node);
|
||||||
}
|
}
|
||||||
@@ -301,7 +258,9 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
.remove(&crtc)
|
.remove(&crtc)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
cancel_output_timers(&state.loop_handle, &mut output);
|
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.unregister_output(&output.output);
|
||||||
state
|
state
|
||||||
.direct_backend
|
.direct_backend
|
||||||
@@ -311,28 +270,65 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
.remove(&(node, crtc));
|
.remove(&(node, crtc));
|
||||||
}
|
}
|
||||||
|
|
||||||
let complete = rescan_device(state, node);
|
ready.push((path, node));
|
||||||
if complete.is_err() {
|
|
||||||
remove_device(state, node);
|
|
||||||
}
|
}
|
||||||
let entry = slot_mut(state, &path);
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
} 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) {
|
if complete == Ok(true) {
|
||||||
entry.ready(node);
|
entry.ready(*node);
|
||||||
|
succeeded.push(*node);
|
||||||
} else {
|
} else {
|
||||||
entry.failed(Instant::now());
|
entry.failed(Instant::now());
|
||||||
// A partial connector failure must not take down working outputs.
|
entry.availability = Availability::Degraded(*node);
|
||||||
if complete == Ok(false) {
|
// Device boundaries are not cross-GPU atomic. Compensate already
|
||||||
entry.availability = Availability::Degraded(node);
|
// 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();
|
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;
|
backend.topology.reconciling = false;
|
||||||
let retry = backend.topology.retry_deadline(backend.active);
|
let retry = backend.topology.retry_deadline(backend.active);
|
||||||
if let Some(deadline) = retry {
|
if let Some(deadline) = retry {
|
||||||
@@ -355,4 +351,110 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
state.restore_output_focus();
|
state.restore_output_focus();
|
||||||
state.relayout();
|
state.relayout();
|
||||||
render_all(state);
|
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;
|
pub use input_motion::InputSettings;
|
||||||
use input_motion::*;
|
use input_motion::*;
|
||||||
use outputs::*;
|
use outputs::*;
|
||||||
pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
|
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smithay::input::keyboard::{Keycode, keysyms, xkb};
|
use smithay::input::keyboard::{Keycode, keysyms, xkb};
|
||||||
|
|
||||||
@@ -1049,9 +1049,15 @@ mod tests {
|
|||||||
config.output_profiles().unwrap(),
|
config.output_profiles().unwrap(),
|
||||||
vec![OutputProfile {
|
vec![OutputProfile {
|
||||||
name: "docked".to_owned(),
|
name: "docked".to_owned(),
|
||||||
|
layout: OutputLayout::Extend,
|
||||||
|
confirm_timeout: 15,
|
||||||
|
lid_policy: LidPolicy::DockOrSuspend,
|
||||||
|
lid_closed: None,
|
||||||
|
mirror_source: None,
|
||||||
outputs: vec![
|
outputs: vec![
|
||||||
OutputSettings {
|
OutputSettings {
|
||||||
auto_refresh: false,
|
auto_refresh: false,
|
||||||
|
required: true,
|
||||||
matcher: "HDMI-A-1".to_owned(),
|
matcher: "HDMI-A-1".to_owned(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
mode: Some(OutputModeRequest {
|
mode: Some(OutputModeRequest {
|
||||||
@@ -1065,6 +1071,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
OutputSettings {
|
OutputSettings {
|
||||||
auto_refresh: false,
|
auto_refresh: false,
|
||||||
|
required: true,
|
||||||
matcher: "eDP-1".to_owned(),
|
matcher: "eDP-1".to_owned(),
|
||||||
enabled: false,
|
enabled: false,
|
||||||
mode: None,
|
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]
|
#[test]
|
||||||
fn automatic_refresh_is_opt_in_and_rejects_wrong_types() {
|
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; }");
|
let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }");
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ impl Config {
|
|||||||
|
|
||||||
Ok(OutputSettings {
|
Ok(OutputSettings {
|
||||||
auto_refresh: output.auto_refresh,
|
auto_refresh: output.auto_refresh,
|
||||||
|
required: output.required,
|
||||||
matcher: matcher.to_owned(),
|
matcher: matcher.to_owned(),
|
||||||
enabled: output.enabled,
|
enabled: output.enabled,
|
||||||
mode: output
|
mode: output
|
||||||
@@ -58,9 +59,38 @@ impl Config {
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, ConfigError>>()?;
|
.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 {
|
Ok(OutputProfile {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
outputs,
|
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()
|
.collect()
|
||||||
@@ -112,11 +142,17 @@ pub(super) const fn default_output_scale() -> f64 {
|
|||||||
pub struct OutputProfile {
|
pub struct OutputProfile {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub outputs: Vec<OutputSettings>,
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct OutputSettings {
|
pub struct OutputSettings {
|
||||||
pub auto_refresh: bool,
|
pub auto_refresh: bool,
|
||||||
|
pub required: bool,
|
||||||
pub matcher: String,
|
pub matcher: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub mode: Option<OutputModeRequest>,
|
pub mode: Option<OutputModeRequest>,
|
||||||
@@ -125,6 +161,18 @@ pub struct OutputSettings {
|
|||||||
pub position: Option<[i32; 2]>,
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct OutputModeRequest {
|
pub struct OutputModeRequest {
|
||||||
pub width: u16,
|
pub width: u16,
|
||||||
@@ -152,10 +200,40 @@ pub enum OutputTransform {
|
|||||||
Flipped270,
|
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)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub(super) struct OutputProfileConfig {
|
pub(super) struct OutputProfileConfig {
|
||||||
pub(super) name: String,
|
pub(super) name: String,
|
||||||
#[serde(default)]
|
#[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>,
|
pub(super) outputs: Vec<OutputConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +241,8 @@ pub(super) struct OutputProfileConfig {
|
|||||||
pub(super) struct OutputConfig {
|
pub(super) struct OutputConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) auto_refresh: bool,
|
pub(super) auto_refresh: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub(super) required: bool,
|
||||||
#[serde(rename = "match")]
|
#[serde(rename = "match")]
|
||||||
pub(super) matcher: String,
|
pub(super) matcher: String,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
|
|||||||
+45
-5
@@ -694,7 +694,40 @@ impl Ferese {
|
|||||||
return Ok(json!(self.has_client_surfaces(pid)));
|
return Ok(json!(self.has_client_surfaces(pid)));
|
||||||
}
|
}
|
||||||
"get-workspaces" => return Ok(self.workspaces_json()),
|
"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(
|
return Err(CommandError::new(
|
||||||
"unknown_command",
|
"unknown_command",
|
||||||
@@ -1026,8 +1059,8 @@ impl Ferese {
|
|||||||
.connected_outputs
|
.connected_outputs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|info| {
|
.map(|info| {
|
||||||
let mapped = self.space.outputs().find(|output| output.name() == info.connector);
|
let mapped = self.output_by_identity(&info.identity);
|
||||||
let id = mapped.and_then(|output| self.output_id(output));
|
let id = self.persistent_output_id(&info.identity);
|
||||||
let geometry = mapped.and_then(|output| self.space.output_geometry(output));
|
let geometry = mapped.and_then(|output| self.space.output_geometry(output));
|
||||||
let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id));
|
let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id));
|
||||||
let position = geometry
|
let position = geometry
|
||||||
@@ -1066,9 +1099,16 @@ impl Ferese {
|
|||||||
"name": info.connector,
|
"name": info.connector,
|
||||||
"connector": info.connector,
|
"connector": info.connector,
|
||||||
"identity": info.identity,
|
"identity": info.identity,
|
||||||
"connected": true,
|
"connected": info.connected,
|
||||||
"enabled": info.enabled && mapped.is_some(),
|
"internal": info.internal,
|
||||||
|
"requested_enabled": info.requested_enabled,
|
||||||
|
"applied_enabled": info.enabled,
|
||||||
|
"mirror_source": info.mirror_source,
|
||||||
|
"enabled": info.enabled,
|
||||||
"profile": info.profile,
|
"profile": info.profile,
|
||||||
|
"requested_profile": info.requested_profile,
|
||||||
|
"configuration_error": backend.output_configuration_error(),
|
||||||
|
"confirmation_pending": backend.confirmation_pending(),
|
||||||
"auto_refresh": info.auto_refresh,
|
"auto_refresh": info.auto_refresh,
|
||||||
"low_power": backend.low_power,
|
"low_power": backend.low_power,
|
||||||
"focused": id.is_some() && id == focused,
|
"focused": id.is_some() && id == focused,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ mod input;
|
|||||||
mod input_capture;
|
mod input_capture;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
|
mod monitor_identity;
|
||||||
|
mod output_policy;
|
||||||
|
mod output_transaction;
|
||||||
mod overview;
|
mod overview;
|
||||||
mod portal_session;
|
mod portal_session;
|
||||||
mod portal_shortcuts;
|
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);
|
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]
|
#[test]
|
||||||
fn runtime_validation_rejects_bad_edits_before_application() {
|
fn runtime_validation_rejects_bad_edits_before_application() {
|
||||||
for source in [
|
for source in [
|
||||||
|
|||||||
@@ -145,6 +145,18 @@ impl Lock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Ferese {
|
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) {
|
pub(crate) fn lock_input_activity(&mut self) {
|
||||||
if self.session_lock.activity(Instant::now()) {
|
if self.session_lock.activity(Instant::now()) {
|
||||||
crate::backends::direct::wake_locked_outputs(self);
|
crate::backends::direct::wake_locked_outputs(self);
|
||||||
@@ -162,7 +174,7 @@ impl Ferese {
|
|||||||
if let Some(token) = self.session_lock.idle_timer.take() {
|
if let Some(token) = self.session_lock.idle_timer.take() {
|
||||||
self.loop_handle.remove(token);
|
self.loop_handle.remove(token);
|
||||||
}
|
}
|
||||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
if !self.lock_outputs_protected() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +201,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_lock_idle(&mut self, now: Instant) {
|
fn update_lock_idle(&mut self, now: Instant) {
|
||||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
if !self.lock_outputs_protected() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fade_duration = self.animation_duration(Duration::from_millis(500));
|
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) {
|
pub(crate) fn confirm_lock_if_ready(&mut self) {
|
||||||
// With no outputs there is no visible content to protect. Re-evaluate
|
// With no outputs there is no visible content to protect. Re-evaluate
|
||||||
// here after acquisition and output removal as well as presentation.
|
// 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;
|
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]
|
#[test]
|
||||||
fn protected_fallback_frames_allow_idle_after_early_locker_crash() {
|
fn protected_fallback_frames_allow_idle_after_early_locker_crash() {
|
||||||
let output = Output::new(
|
let output = Output::new(
|
||||||
|
|||||||
@@ -263,6 +263,14 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn send_shell_snapshots(&mut self) {
|
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());
|
self.shell_resources.retain(|resource| resource.upgrade().is_ok());
|
||||||
if self.shell_resources.is_empty() {
|
if self.shell_resources.is_empty() {
|
||||||
self.last_shell_snapshot = None;
|
self.last_shell_snapshot = None;
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>) {
|
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() {
|
if let Some(outputs) = redraw_outputs.as_mut() {
|
||||||
outputs.extend(
|
outputs.extend(
|
||||||
self.space
|
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) {
|
pub fn register_output(&mut self, output: &Output, identity: String) {
|
||||||
if self.output_ids.is_empty() {
|
if self.output_ids.is_empty() {
|
||||||
self.reset_animation_clock();
|
self.reset_animation_clock();
|
||||||
@@ -20,20 +38,40 @@ impl Ferese {
|
|||||||
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let output_id = *self.output_identity_ids.entry(identity).or_insert_with(|| {
|
let output_id = self.ensure_output_identity(&identity);
|
||||||
let id = OutputId(self.next_output_id);
|
|
||||||
self.next_output_id = self.next_output_id.saturating_add(1);
|
|
||||||
id
|
|
||||||
});
|
|
||||||
let fallback_workspace = self
|
let fallback_workspace = self
|
||||||
.workspaces
|
.workspaces
|
||||||
.iter()
|
.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)
|
.map(|workspace| workspace.id)
|
||||||
.min_by_key(|id| id.0)
|
.min_by_key(|id| id.0)
|
||||||
.unwrap_or_else(|| self.workspaces.create_workspace());
|
.unwrap_or_else(|| self.workspaces.create_workspace());
|
||||||
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
|
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);
|
let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
|
||||||
|
|
||||||
match registration {
|
match registration {
|
||||||
@@ -41,6 +79,27 @@ impl Ferese {
|
|||||||
self.output_ids.insert(output.clone(), output_id);
|
self.output_ids.insert(output.clone(), output_id);
|
||||||
self.outputs_by_id.insert(output_id, output.clone());
|
self.outputs_by_id.insert(output_id, output.clone());
|
||||||
self.output_names.insert(output_id, output.name());
|
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();
|
self.restore_output_focus();
|
||||||
let visible = self.visible_workspace_ids();
|
let visible = self.visible_workspace_ids();
|
||||||
self.reconcile_workspaces(&visible);
|
self.reconcile_workspaces(&visible);
|
||||||
@@ -80,8 +139,35 @@ impl Ferese {
|
|||||||
self.confirm_lock_if_ready();
|
self.confirm_lock_if_ready();
|
||||||
self.refresh_lock_idle_policy();
|
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) {
|
match self.output_workspaces.disconnect(output_id) {
|
||||||
Ok(Some(target)) => {
|
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) {
|
if let Some(workspace) = self.output_workspaces.active_workspace(target) {
|
||||||
self.activate_output_workspace(target, workspace);
|
self.activate_output_workspace(target, workspace);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ struct EvacuationRecord {
|
|||||||
pub struct OutputWorkspaceMap {
|
pub struct OutputWorkspaceMap {
|
||||||
outputs: BTreeMap<OutputId, OutputState>,
|
outputs: BTreeMap<OutputId, OutputState>,
|
||||||
assignments: HashMap<WorkspaceId, OutputId>,
|
assignments: HashMap<WorkspaceId, OutputId>,
|
||||||
|
homes: HashMap<WorkspaceId, OutputId>,
|
||||||
revisions: HashMap<WorkspaceId, u64>,
|
revisions: HashMap<WorkspaceId, u64>,
|
||||||
evacuations: HashMap<OutputId, EvacuationRecord>,
|
evacuations: HashMap<OutputId, EvacuationRecord>,
|
||||||
focused: Option<OutputId>,
|
focused: Option<OutputId>,
|
||||||
|
orphaned_focus: Option<WorkspaceId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutputWorkspaceMap {
|
impl OutputWorkspaceMap {
|
||||||
@@ -118,6 +120,10 @@ impl OutputWorkspaceMap {
|
|||||||
self.assignments.get(&workspace).copied()
|
self.assignments.get(&workspace).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn home_output(&self, workspace: WorkspaceId) -> Option<OutputId> {
|
||||||
|
self.homes.get(&workspace).copied()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool {
|
pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool {
|
||||||
if self.outputs.values().any(|output| output.active == workspace) {
|
if self.outputs.values().any(|output| output.active == workspace) {
|
||||||
return false;
|
return false;
|
||||||
@@ -131,6 +137,10 @@ impl OutputWorkspaceMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.revisions.remove(&workspace);
|
self.revisions.remove(&workspace);
|
||||||
|
self.homes.remove(&workspace);
|
||||||
|
if self.orphaned_focus == Some(workspace) {
|
||||||
|
self.orphaned_focus = None;
|
||||||
|
}
|
||||||
self.evacuations.retain(|_, record| {
|
self.evacuations.retain(|_, record| {
|
||||||
record.workspaces.retain(|entry| entry.workspace != workspace);
|
record.workspaces.retain(|entry| entry.workspace != workspace);
|
||||||
if record.active == workspace {
|
if record.active == workspace {
|
||||||
@@ -150,6 +160,10 @@ impl OutputWorkspaceMap {
|
|||||||
self.outputs.keys().copied()
|
self.outputs.keys().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn geometry(&self, output: OutputId) -> Option<OutputGeometry> {
|
||||||
|
self.outputs.get(&output).map(|state| state.geometry)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) {
|
pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) {
|
||||||
if let Some(state) = self.outputs.get_mut(&output) {
|
if let Some(state) = self.outputs.get_mut(&output) {
|
||||||
state.geometry = geometry;
|
state.geometry = geometry;
|
||||||
@@ -170,15 +184,19 @@ impl OutputWorkspaceMap {
|
|||||||
let mut reclaimed = Vec::new();
|
let mut reclaimed = Vec::new();
|
||||||
if let Some(record) = &record {
|
if let Some(record) = &record {
|
||||||
for evacuated in &record.workspaces {
|
for evacuated in &record.workspaces {
|
||||||
let assignment_matches = self.assignments.get(&evacuated.workspace).copied() == evacuated.target;
|
let target = self
|
||||||
|
.assignments
|
||||||
|
.get(&evacuated.workspace)
|
||||||
|
.copied()
|
||||||
|
.or_else(|| evacuated.target.filter(|target| self.outputs.contains_key(target)));
|
||||||
|
let assignment_matches = self.homes.get(&evacuated.workspace) == Some(&output);
|
||||||
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
|
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
|
||||||
|
|
||||||
if assignment_matches && revision_matches {
|
if assignment_matches && revision_matches {
|
||||||
// Cleanup may have removed every other workspace while this
|
// Cleanup may have removed every other workspace while this
|
||||||
// monitor was disconnected. Never strand the donor output;
|
// monitor was disconnected. Never strand the donor output;
|
||||||
// the reconnecting monitor can use its fresh fallback.
|
// the reconnecting monitor can use its fresh fallback.
|
||||||
if evacuated
|
if target
|
||||||
.target
|
|
||||||
.and_then(|target| self.outputs.get(&target))
|
.and_then(|target| self.outputs.get(&target))
|
||||||
.is_some_and(|state| {
|
.is_some_and(|state| {
|
||||||
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
|
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
|
||||||
@@ -187,7 +205,7 @@ impl OutputWorkspaceMap {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(target) = evacuated.target
|
if let Some(target) = target
|
||||||
&& let Some(target_state) = self.outputs.get_mut(&target)
|
&& let Some(target_state) = self.outputs.get_mut(&target)
|
||||||
{
|
{
|
||||||
target_state.workspaces.remove(&evacuated.workspace);
|
target_state.workspaces.remove(&evacuated.workspace);
|
||||||
@@ -214,7 +232,7 @@ impl OutputWorkspaceMap {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|record| reclaimed.contains(&record.active).then_some(record.active))
|
.and_then(|record| reclaimed.contains(&record.active).then_some(record.active))
|
||||||
.or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0));
|
.or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0));
|
||||||
let active = match active {
|
let mut active = match active {
|
||||||
Some(active) => active,
|
Some(active) => active,
|
||||||
None => {
|
None => {
|
||||||
if let Some(owner) = self.assignments.get(&fallback_workspace).copied() {
|
if let Some(owner) = self.assignments.get(&fallback_workspace).copied() {
|
||||||
@@ -224,11 +242,33 @@ impl OutputWorkspaceMap {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.assignments.insert(fallback_workspace, output);
|
self.assignments.insert(fallback_workspace, output);
|
||||||
|
self.homes.insert(fallback_workspace, output);
|
||||||
reclaimed.push(fallback_workspace);
|
reclaimed.push(fallback_workspace);
|
||||||
fallback_workspace
|
fallback_workspace
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if self.outputs.is_empty() {
|
||||||
|
// A replacement may have to reuse the final CRTC, or every cable
|
||||||
|
// may be unplugged. Attach unowned evacuated workspaces without
|
||||||
|
// changing their homes, so every window remains reachable.
|
||||||
|
let orphaned = self
|
||||||
|
.homes
|
||||||
|
.keys()
|
||||||
|
.filter(|workspace| !self.assignments.contains_key(workspace))
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for workspace in orphaned {
|
||||||
|
self.assignments.insert(workspace, output);
|
||||||
|
reclaimed.push(workspace);
|
||||||
|
}
|
||||||
|
if let Some(focused) = self.orphaned_focus.take()
|
||||||
|
&& reclaimed.contains(&focused)
|
||||||
|
{
|
||||||
|
active = focused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.outputs.insert(
|
self.outputs.insert(
|
||||||
output,
|
output,
|
||||||
OutputState {
|
OutputState {
|
||||||
@@ -266,6 +306,9 @@ impl OutputWorkspaceMap {
|
|||||||
|
|
||||||
if removed_had_focus {
|
if removed_had_focus {
|
||||||
self.focused = target;
|
self.focused = target;
|
||||||
|
if target.is_none() {
|
||||||
|
self.orphaned_focus = Some(removed.active);
|
||||||
|
}
|
||||||
if let Some(target) = target {
|
if let Some(target) = target {
|
||||||
self.outputs
|
self.outputs
|
||||||
.get_mut(&target)
|
.get_mut(&target)
|
||||||
@@ -327,6 +370,7 @@ impl OutputWorkspaceMap {
|
|||||||
state.activate(workspace);
|
state.activate(workspace);
|
||||||
self.focused = Some(output);
|
self.focused = Some(output);
|
||||||
if reassigned {
|
if reassigned {
|
||||||
|
self.homes.insert(workspace, output);
|
||||||
self.bump_revision(workspace);
|
self.bump_revision(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +401,7 @@ impl OutputWorkspaceMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.assignments.insert(workspace, output);
|
self.assignments.insert(workspace, output);
|
||||||
|
self.homes.insert(workspace, output);
|
||||||
self.outputs
|
self.outputs
|
||||||
.get_mut(&output)
|
.get_mut(&output)
|
||||||
.expect("output was checked above")
|
.expect("output was checked above")
|
||||||
@@ -368,6 +413,48 @@ impl OutputWorkspaceMap {
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit reassignment changes the home, unlike merely focusing an evacuated workspace.
|
||||||
|
/// The caller supplies a fresh workspace if moving the donor's final workspace.
|
||||||
|
pub fn reassign_workspace(
|
||||||
|
&mut self,
|
||||||
|
output: OutputId,
|
||||||
|
workspace: WorkspaceId,
|
||||||
|
donor_fallback: WorkspaceId,
|
||||||
|
) -> Result<(), OutputError> {
|
||||||
|
if !self.outputs.contains_key(&output) {
|
||||||
|
return Err(OutputError::UnknownOutput(output));
|
||||||
|
}
|
||||||
|
if let Some(owner) = self.assignments.get(&workspace).copied()
|
||||||
|
&& owner != output
|
||||||
|
{
|
||||||
|
let donor = self.outputs.get_mut(&owner).expect("assigned owner is connected");
|
||||||
|
if donor.workspaces.len() == 1 {
|
||||||
|
if let Some(assigned) = self.assignments.get(&donor_fallback) {
|
||||||
|
return Err(OutputError::WorkspaceAlreadyAssigned {
|
||||||
|
workspace: donor_fallback,
|
||||||
|
output: *assigned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
donor.workspaces.insert(donor_fallback);
|
||||||
|
self.assignments.insert(donor_fallback, owner);
|
||||||
|
self.homes.insert(donor_fallback, owner);
|
||||||
|
}
|
||||||
|
donor.workspaces.remove(&workspace);
|
||||||
|
if donor.previous == Some(workspace) {
|
||||||
|
donor.previous = None;
|
||||||
|
}
|
||||||
|
if donor.active == workspace {
|
||||||
|
donor.active = *donor.workspaces.first().expect("donor retains a workspace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assignments.insert(workspace, output);
|
||||||
|
self.homes.insert(workspace, output);
|
||||||
|
self.outputs.get_mut(&output).unwrap().workspaces.insert(workspace);
|
||||||
|
self.bump_revision(workspace);
|
||||||
|
debug_assert!(self.validate());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> {
|
fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> {
|
||||||
if let Some(focused) = self.focused
|
if let Some(focused) = self.focused
|
||||||
&& focused != removed
|
&& focused != removed
|
||||||
@@ -705,4 +792,62 @@ mod tests {
|
|||||||
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
|
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
|
||||||
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn two_hop_evacuation_restores_original_home() {
|
||||||
|
let mut outputs = OutputWorkspaceMap::default();
|
||||||
|
for id in 1..=3 {
|
||||||
|
outputs
|
||||||
|
.connect(OutputId(id), geometry(id as i32 * 1920), WorkspaceId(id))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
outputs.focus_output(OutputId(2)).unwrap();
|
||||||
|
outputs.disconnect(OutputId(1)).unwrap();
|
||||||
|
outputs.disconnect(OutputId(2)).unwrap();
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
|
||||||
|
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
|
||||||
|
outputs.connect(OutputId(1), geometry(0), WorkspaceId(4)).unwrap();
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
|
||||||
|
assert!(outputs.active_workspace(OutputId(3)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_reassignment_after_evacuation_prevents_home_steal_back() {
|
||||||
|
let mut outputs = OutputWorkspaceMap::default();
|
||||||
|
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
|
||||||
|
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
|
||||||
|
outputs.disconnect(OutputId(1)).unwrap();
|
||||||
|
outputs
|
||||||
|
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(9))
|
||||||
|
.unwrap();
|
||||||
|
outputs.connect(OutputId(1), geometry(0), WorkspaceId(3)).unwrap();
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
|
||||||
|
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reassigning_last_workspace_supplies_donor_fallback() {
|
||||||
|
let mut outputs = OutputWorkspaceMap::default();
|
||||||
|
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
|
||||||
|
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
|
||||||
|
outputs
|
||||||
|
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(3))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(3)));
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn final_output_replacement_keeps_all_workspaces_reachable_without_changing_homes() {
|
||||||
|
let mut outputs = OutputWorkspaceMap::default();
|
||||||
|
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
|
||||||
|
outputs.assign_workspace(OutputId(1), WorkspaceId(2)).unwrap();
|
||||||
|
outputs.switch_workspace(OutputId(1), WorkspaceId(2)).unwrap();
|
||||||
|
outputs.disconnect(OutputId(1)).unwrap();
|
||||||
|
outputs.connect(OutputId(3), geometry(0), WorkspaceId(3)).unwrap();
|
||||||
|
assert_eq!(outputs.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
|
||||||
|
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
|
||||||
|
outputs.connect(OutputId(1), geometry(1920), WorkspaceId(4)).unwrap();
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
|
||||||
|
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+100
-12
@@ -445,14 +445,25 @@ window-rule app-id="dev.ferese.Settings" floating=#true
|
|||||||
|
|
||||||
## Displays
|
## Displays
|
||||||
|
|
||||||
`output-profile` has a required unique nonempty `name` and one or more
|
Each `output-profile` needs a unique, nonempty `name` and at least one `output`
|
||||||
`output-profile → output` entries. The first profile whose listed monitors
|
entry. A profile matches when every required monitor is connected and its
|
||||||
are connected wins. Find connector names and persistent identities using
|
optional `lid-closed` condition matches. The profile with the most required
|
||||||
`feresectl get-outputs`.
|
monitors wins; ties use config order. If none matches, Ferese extends across all
|
||||||
|
usable connected monitors. Find selectors with `feresectl outputs`.
|
||||||
|
|
||||||
|
| `output-profile` key | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | unique nonempty string | required |
|
||||||
|
| `layout` | `internal-only`, `external-only`, `extend`, `mirror` | `extend` |
|
||||||
|
| `lid-closed` | boolean | either lid state |
|
||||||
|
| `mirror-source` | selector of an enabled output in this profile | first enabled hardware key |
|
||||||
|
| `lid-policy` | `dock-or-suspend`, `ignore` | `dock-or-suspend` |
|
||||||
|
| `confirm-timeout` | seconds; `0` disables confirmation | `15` |
|
||||||
|
|
||||||
| `output-profile → output` key | Type | Default |
|
| `output-profile → output` key | Type | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `match` | nonempty connector or persistent identity string | required; unique within profile |
|
| `match` | nonempty connector or persistent identity string | required; unique within profile |
|
||||||
|
| `required` | boolean | `true`; set `false` for an optional monitor |
|
||||||
| `enabled` | boolean | `true` |
|
| `enabled` | boolean | `true` |
|
||||||
| `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode |
|
| `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode |
|
||||||
| `auto-refresh` | boolean | `false` |
|
| `auto-refresh` | boolean | `false` |
|
||||||
@@ -461,8 +472,68 @@ are connected wins. Find connector names and persistent identities using
|
|||||||
| `position` | `[integer, integer]` | Automatic horizontal placement |
|
| `position` | `[integer, integer]` | Automatic horizontal placement |
|
||||||
|
|
||||||
Transforms: `normal`, `rotate-90`, `rotate-180`, `rotate-270`, `flipped`,
|
Transforms: `normal`, `rotate-90`, `rotate-180`, `rotate-270`, `flipped`,
|
||||||
`flipped-90`, `flipped-180`, `flipped-270`. Unspecified connected displays stay
|
`flipped-90`, `flipped-180`, `flipped-270`. In extend and mirror profiles,
|
||||||
enabled with defaults. Disabling every usable output is rejected.
|
unlisted monitors stay enabled with defaults. Internal-only and external-only
|
||||||
|
filter by connector type. A layout that would disable the final usable display
|
||||||
|
falls back to a working display. Profiles that explicitly disable all their
|
||||||
|
listed outputs are invalid.
|
||||||
|
|
||||||
|
Monitor identities use EDID manufacturer, product/model and serial, rather than
|
||||||
|
a hash of the entire EDID. Without a usable serial, the identity includes the
|
||||||
|
GPU's sysfs hardware path and connector. For duplicate serials, Ferese adds the
|
||||||
|
same path and connector to distinguish the monitors. If both arrive together,
|
||||||
|
both identities include those details. If the second arrives later, the first
|
||||||
|
keeps its identity and only the second gets the path and connector. Ferese
|
||||||
|
remembers that choice for the compositor session. Without serials, Ferese
|
||||||
|
identifies monitors by port and cannot tell them apart if you swap their ports.
|
||||||
|
Connector names and old `drm-edid:` selectors still work. Ambiguous or
|
||||||
|
overlapping selectors are rejected during live reload. A disconnected identity
|
||||||
|
can remain in config; it prevents matching only when its entry has
|
||||||
|
`required=#true`.
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
output-profile "desk" layout="external-only" {
|
||||||
|
output "DP-1" mode="2560x1440@60" scale=1.25
|
||||||
|
output "eDP-1" required=#false enabled=#false
|
||||||
|
}
|
||||||
|
|
||||||
|
output-profile "mobile" layout="internal-only" {
|
||||||
|
output "eDP-1" scale=1.75
|
||||||
|
}
|
||||||
|
|
||||||
|
output-profile "presentation" layout="mirror" mirror-source="eDP-1" {
|
||||||
|
output "eDP-1" scale=1.75
|
||||||
|
output "HDMI-A-1" mode="1920x1080@60"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mirror uses the source's logical size, workspace, layers and pointer. Targets
|
||||||
|
own no workspaces or separate pointer region. Ferese fits the complete source
|
||||||
|
into each target's transformed physical mode, preserving aspect ratio and
|
||||||
|
letterboxing instead of cropping. Target scale does not resize the source
|
||||||
|
layout. Each target has its own hardware cadence; only the source releases
|
||||||
|
client frame callbacks and presentation feedback. Mirrored monitors are listed
|
||||||
|
by `outputs`, but expose one logical output to clients and the shell. If a required mirror target disconnects, Ferese selects another matching
|
||||||
|
profile or extends across all usable connected monitors. Leaving mirror
|
||||||
|
restores evacuated workspaces.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
feresectl outputs
|
||||||
|
feresectl output-profiles
|
||||||
|
feresectl output-profile presentation
|
||||||
|
feresectl output-confirm
|
||||||
|
feresectl output-profile auto
|
||||||
|
feresectl output-internal off
|
||||||
|
feresectl output-revert
|
||||||
|
```
|
||||||
|
|
||||||
|
A manual choice overrides automatic selection until the monitor set or lid
|
||||||
|
state changes. Reload keeps it only if its profile still exists and matches.
|
||||||
|
Manual changes revert after `confirm-timeout` seconds unless confirmed;
|
||||||
|
`output-revert` restores immediately. Reverting restores the last confirmed modes,
|
||||||
|
scales, transforms and positions, even if you make several changes before
|
||||||
|
confirming. The timeout still applies if the client that made the change exits. Automatic hotplug/lid changes never wait for confirmation.
|
||||||
|
Disabling the last usable internal panel is rejected.
|
||||||
|
|
||||||
```kdl
|
```kdl
|
||||||
output-profile name="docked" {
|
output-profile name="docked" {
|
||||||
@@ -486,9 +557,10 @@ changes. Unavailable battery data restores normal refresh. If no matching 60 Hz
|
|||||||
mode exists, normal refresh is retained. Rejected DRM mode changes retain the
|
mode exists, normal refresh is retained. Rejected DRM mode changes retain the
|
||||||
current output. A refresh-rate change can briefly blank the display (about one
|
current output. A refresh-rate change can briefly blank the display (about one
|
||||||
second on some panels), including automatic changes below 30%, recovery at 35%,
|
second on some panels), including automatic changes below 30%, recovery at 35%,
|
||||||
and restoration on AC. Session activation only re-evaluates the power policy;
|
and restoration on AC. Session activation refreshes lid and connector state
|
||||||
an unchanged policy does not trigger an additional rescan. Connector changes
|
before rendering; unchanged outputs retain their handles and workspaces.
|
||||||
continue to use the existing hotplug handling.
|
Hotplug bursts settle for 150 ms before one reconciliation. Ferese records changes that arrive while the session is inactive and applies
|
||||||
|
them on activation.
|
||||||
|
|
||||||
```kdl
|
```kdl
|
||||||
output-profile laptop {
|
output-profile laptop {
|
||||||
@@ -496,9 +568,25 @@ output-profile laptop {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
With a usable external display, closing the lid disables internal panels.
|
With a usable external display, closing the lid disables internal panels after
|
||||||
Workspaces move to a remaining display and return when their original output
|
external activation succeeds. Without one, Ferese asks logind to suspend,
|
||||||
returns. Ferese does not change the system's suspend policy.
|
respecting sleep inhibitors. `lid-policy="ignore"` disables this behavior for
|
||||||
|
the selected profile. Resume reads lid state from logind, with ACPI fallback,
|
||||||
|
before reconciling fresh hardware. Ferese releases its logind lid inhibitor on
|
||||||
|
session pause, so logind handles the lid while another session or TTY is active.
|
||||||
|
Workspaces keep their home output and return when it reconnects, unless you
|
||||||
|
reassigned or deleted them after evacuation. Floating
|
||||||
|
windows are remapped and clamped to the surviving display.
|
||||||
|
|
||||||
|
On atomic DRM, Ferese tests the complete configuration for each GPU before
|
||||||
|
changing it.
|
||||||
|
Smithay performs the real commits; logical changes follow successful hardware
|
||||||
|
application. Failed commits attempt restoration, and outputs that cannot be
|
||||||
|
restored are removed from logical state. Each GPU commits separately. If a later GPU fails, Ferese attempts to restore
|
||||||
|
the earlier GPUs. Legacy KMS lacks atomic
|
||||||
|
test-only validation. Hardware failures can still prevent every monitor from
|
||||||
|
working; Ferese tries preferred modes to recover a display instead of
|
||||||
|
intentionally turning off the last working one.
|
||||||
|
|
||||||
## Status controls
|
## Status controls
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -14,6 +14,7 @@ Keep another desktop available for direct-session recovery.
|
|||||||
|
|
||||||
## Test guides
|
## Test guides
|
||||||
|
|
||||||
|
- [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits.
|
||||||
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
|
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
|
||||||
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
|
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
|
||||||
- [Performance measurements](performance-baseline.md): sampling and historical results.
|
- [Performance measurements](performance-baseline.md): sampling and historical results.
|
||||||
@@ -88,8 +89,8 @@ have a three-second deadline so a stalled bus cannot block recovery indefinitely
|
|||||||
|
|
||||||
When a device fails, Ferese removes its output globals and retries with exponential
|
When a device fails, Ferese removes its output globals and retries with exponential
|
||||||
backoff capped at 32 seconds. Working devices remain usable. Known hardware can return
|
backoff capped at 32 seconds. Working devices remain usable. Known hardware can return
|
||||||
under a different `cardN` path. This does not expand support to previously
|
under a different `cardN` path. Reconciliation also discovers newly connected
|
||||||
unmanaged secondary GPUs. Connected-output reports mark a mode active only when
|
GPUs. Connected-output reports mark a mode active only when
|
||||||
output creation succeeded. Lock ownership is retained throughout recovery.
|
output creation succeeded. Lock ownership is retained throughout recovery.
|
||||||
|
|
||||||
Run the ordering and private D-Bus checks with:
|
Run the ordering and private D-Bus checks with:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Output management tests
|
||||||
|
|
||||||
|
Run these on a direct DRM session. Nested mode cannot exercise KMS commits,
|
||||||
|
physical hotplug, logind lid policy or cross-GPU restoration. Do not use nested
|
||||||
|
results as evidence that a driver accepts the atomic request.
|
||||||
|
|
||||||
|
Build and run the policy/state tests first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --release -p ferese -p ferese-core -p feresectl -- --test-threads=1
|
||||||
|
cargo clippy --release -p ferese -p ferese-core -p feresectl --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Record `feresectl outputs` and `feresectl output-profiles` before each change.
|
||||||
|
Check `identity`, `connected`, `requested_enabled`, `applied_enabled`, mode,
|
||||||
|
scale, transform, position, profile, mirror source and configuration error.
|
||||||
|
Manual changes normally need `feresectl output-confirm` within 15 seconds;
|
||||||
|
use `output-revert` to restore immediately. The timeout is profile-configurable.
|
||||||
|
|
||||||
|
## Hardware checks
|
||||||
|
|
||||||
|
1. Connect one external monitor, then a second. Check distinct IDs and identities,
|
||||||
|
most-specific profile selection and deterministic positions. Unplug/replug
|
||||||
|
each; serial identities should survive connector changes where unambiguous.
|
||||||
|
Check identical monitors with missing/duplicated serials on separate ports.
|
||||||
|
2. Select internal-only, external-only and extend profiles. Unaffected outputs
|
||||||
|
should keep their globals and workspaces. Try `output-internal off` undocked:
|
||||||
|
it must fail without disabling the panel. Close/open the lid docked, then
|
||||||
|
unplug the dock with the lid closed. Repeat docking while the lid is closed.
|
||||||
|
3. Undocked, close the lid. Check logind suspension. Repeat with a block sleep
|
||||||
|
inhibitor; Ferese must not bypass it. Set `lid-policy="ignore"` in the matching
|
||||||
|
profile and confirm lid closure does not trigger Ferese's suspend request.
|
||||||
|
4. Suspend or switch VT, change monitor topology while inactive, then resume.
|
||||||
|
Check that connector and lid state are current, the lock screen wakes and
|
||||||
|
frame callbacks continue. If a DRM device fails to reactivate, its outputs
|
||||||
|
must not appear active.
|
||||||
|
5. Mirror different resolutions, scales and rotations. The desktop, overview,
|
||||||
|
shell and pointer must match the source; letterboxing must preserve the full
|
||||||
|
scene. Check lock-screen mirroring too. Remove a target, then the source;
|
||||||
|
remaining windows must be reachable. Return to extend and verify each
|
||||||
|
monitor gets a workspace without duplicating ownership.
|
||||||
|
6. Move and resize floating windows near each edge. Remove their home output,
|
||||||
|
remove the evacuation destination, then reconnect the home. Windows must
|
||||||
|
remain visible throughout. Deleted workspaces must not return. If you explicitly
|
||||||
|
reassign a workspace, that must change its home; reconnecting the old monitor
|
||||||
|
must not move it back.
|
||||||
|
7. Make a manual switch without confirming and disconnect feresectl. Check that
|
||||||
|
timeout restores the actual old modes/positions. Repeat with timeout disabled.
|
||||||
|
Apply a second change before confirming: its timeout must still restore the
|
||||||
|
original confirmed configuration.
|
||||||
|
A connector or lid change must cancel the pending confirmation and use auto.
|
||||||
|
8. Reload duplicate profile names/selectors, invalid mode syntax, zero scale,
|
||||||
|
unknown transforms, invalid mirror sources and all-disabled profiles. Check
|
||||||
|
the error and verify the accepted config/output state remains active.
|
||||||
|
|
||||||
|
## Driver failure checks
|
||||||
|
|
||||||
|
Use VKMS or a dedicated DRM test seat with a fault-injection shim; avoid forcing
|
||||||
|
errors on the desktop being used to run the test. Inject failures during buffer allocation,
|
||||||
|
TEST_ONLY, a later real CRTC commit, output disable and rollback. The `output_transaction` unit tests cover those failures without hardware.
|
||||||
|
|
||||||
|
A TEST_ONLY failure must leave existing hardware and logical outputs unchanged.
|
||||||
|
A real-commit failure must restore earlier commits where possible. If restoration
|
||||||
|
fails, the unavailable output must lose its logical global/workspace ownership;
|
||||||
|
remaining windows must evacuate to a working output. With multiple GPUs, make the later GPU's commit fail and check that Ferese
|
||||||
|
attempts to restore the earlier devices. This
|
||||||
|
is not a cross-GPU atomic commit.
|
||||||
|
|
||||||
|
Check a mode change under load with a pending page flip. The old flip must not
|
||||||
|
release callbacks for the new frame. Verify ordinary and mirror source feedback
|
||||||
|
still reports actual presentation, while targets send no duplicate callbacks.
|
||||||
|
|
||||||
|
Capture compositor tracing around each failure. Include driver/GPU names and the
|
||||||
|
before/after `outputs` dumps when reporting results; a passing mock transaction
|
||||||
|
does not establish driver-level rollback behavior.
|
||||||
|
|
||||||
|
Lock and session ownership checks:
|
||||||
|
|
||||||
|
- In mirror mode, delay or fail target rendering while acquiring a session lock.
|
||||||
|
The lock must remain unconfirmed until every physical display has presented
|
||||||
|
protected content. Reconfigure a mirror and repeat; old presentation evidence
|
||||||
|
must not satisfy the new configuration.
|
||||||
|
- Switch to a TTY and check `systemd-inhibit --list`: Ferese must release its
|
||||||
|
`handle-lid-switch` inhibitor. Returning to Ferese should reacquire it.
|
||||||
|
- Replace a monitor on the same connector. `feresectl outputs` must report the
|
||||||
|
replacement identity, rather than the previous monitor's identity.
|
||||||
+44
-1
@@ -111,6 +111,19 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
|||||||
let command = args.next().ok_or_else(usage)?;
|
let command = args.next().ok_or_else(usage)?;
|
||||||
let positional = args.collect::<Vec<_>>();
|
let positional = args.collect::<Vec<_>>();
|
||||||
let payload = match command.as_str() {
|
let payload = match command.as_str() {
|
||||||
|
"output-profile" => {
|
||||||
|
exactly_one(&command, &positional, "profile name or auto")?;
|
||||||
|
json!({ "name": positional[0] })
|
||||||
|
}
|
||||||
|
"output-internal" => {
|
||||||
|
exactly_one(&command, &positional, "on or off")?;
|
||||||
|
let enabled = match positional[0].as_str() {
|
||||||
|
"on" => true,
|
||||||
|
"off" => false,
|
||||||
|
_ => return Err("output-internal requires on or off".into()),
|
||||||
|
};
|
||||||
|
json!({ "enabled": enabled })
|
||||||
|
}
|
||||||
"screenshot-window" => {
|
"screenshot-window" => {
|
||||||
exactly_one(&command, &positional, "window ID")?;
|
exactly_one(&command, &positional, "window ID")?;
|
||||||
let window = positional[0]
|
let window = positional[0]
|
||||||
@@ -156,6 +169,10 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
|||||||
| "get-windows"
|
| "get-windows"
|
||||||
| "get-workspaces"
|
| "get-workspaces"
|
||||||
| "get-outputs"
|
| "get-outputs"
|
||||||
|
| "outputs"
|
||||||
|
| "output-profiles"
|
||||||
|
| "output-confirm"
|
||||||
|
| "output-revert"
|
||||||
| "reload-config"
|
| "reload-config"
|
||||||
| "exit"
|
| "exit"
|
||||||
| "request-logout"
|
| "request-logout"
|
||||||
@@ -191,7 +208,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
"usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -272,6 +289,32 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_commands_have_consistent_arguments() {
|
||||||
|
for command in ["outputs", "output-profiles", "output-confirm", "output-revert"] {
|
||||||
|
assert_eq!(parse_args([command.into()]).unwrap(), (command.into(), json!({})));
|
||||||
|
assert!(parse_args([command.into(), "extra".into()]).is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["output-profile".into(), "auto".into()]).unwrap().1,
|
||||||
|
json!({"name": "auto"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["output-profile".into(), "desk".into()]).unwrap().1,
|
||||||
|
json!({"name": "desk"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["output-internal".into(), "off".into()]).unwrap().1,
|
||||||
|
json!({"enabled": false})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["output-internal".into(), "on".into()]).unwrap().1,
|
||||||
|
json!({"enabled": true})
|
||||||
|
);
|
||||||
|
assert!(parse_args(["output-internal".into(), "maybe".into()]).is_err());
|
||||||
|
assert!(parse_args(["output-profile".into()]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_or_missing_arguments() {
|
fn rejects_unknown_or_missing_arguments() {
|
||||||
assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));
|
assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));
|
||||||
|
|||||||
Reference in new issue
Block a user