diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 6b8750a..700418a 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -7,11 +7,13 @@ use std::time::{Duration, Instant}; mod capture; mod lid; +mod mirror; mod output_power; mod planes; mod redraw; mod scheduling; mod topology; +mod transaction; use output_power::{OutputPower, Power}; use redraw::{AnimationFallback, SchedulingMetrics}; use scheduling::*; @@ -49,6 +51,8 @@ use topology::{Topology, reconcile_outputs}; use crate::Ferese; use crate::config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; use crate::metrics::RenderMetrics; +use crate::monitor_identity::{Identity, IdentityRegistry}; +use crate::output_policy::{DesiredOutputConfiguration, ManualOverride, Monitor}; use crate::render::{frame_effect_metrics, sampled_output_elements}; pub struct DirectBackendState { @@ -57,6 +61,16 @@ pub struct DirectBackendState { lid: lid::LidState, lid_reader: lid::Reader, topology: Topology, + identities: IdentityRegistry, + pub(crate) manual_outputs: ManualOverride, + desired_outputs: DesiredOutputConfiguration, + monitors: Vec, + output_error: Option, + applied_outputs: DesiredOutputConfiguration, + revert_outputs: Option, + confirmation: Option<(DesiredOutputConfiguration, ManualOverride)>, + confirmation_timer: Option, + confirmation_lid: bool, pub(crate) low_power: bool, devices: HashMap, input_devices: Vec, @@ -70,10 +84,42 @@ pub struct DirectBackendState { } impl DirectBackendState { + pub(crate) fn physical_outputs(&self) -> impl Iterator { + self.devices + .values() + .flat_map(|device| device.outputs.values().map(|output| &output.output)) + } + + pub(crate) fn output_configuration_error(&self) -> Option<&str> { + self.output_error.as_deref() + } + + pub(crate) fn confirmation_pending(&self) -> bool { + self.confirmation.is_some() + } + + pub(crate) fn reconciling(&self) -> bool { + self.topology.reconciling + } + fn can_render(&self) -> bool { self.active && !self.topology.reconciling && !self.lid.pending() } + pub(crate) fn scene_output(&self, state: &Ferese, output: &Output) -> Output { + let source = self + .devices + .values() + .flat_map(|device| device.outputs.values()) + .find(|candidate| &candidate.output == output) + .and_then(|candidate| candidate.mirror_source.as_ref()); + source + .and_then(|key| self.monitors.iter().find(|monitor| &monitor.key == key)) + .and_then(|monitor| state.output_by_identity(&monitor.identity)) + .cloned() + .unwrap_or_else(|| output.clone()) + } + pub(crate) fn dump_scheduling_metrics(&self) { self.scheduling_metrics.dump(); } @@ -144,9 +190,14 @@ impl DirectBackendState { #[derive(Clone, Debug)] pub(crate) struct ConnectedOutputInfo { pub connector: String, + pub connected: bool, pub identity: String, + pub internal: bool, + pub requested_enabled: bool, + pub mirror_source: Option, pub enabled: bool, pub profile: Option, + pub requested_profile: Option, pub auto_refresh: bool, pub physical_size: Option<(u32, u32)>, pub current_mode: Option, @@ -196,7 +247,13 @@ struct DirectOutput { mode: DrmMode, settings: OutputSettings, output: Output, - global: GlobalId, + global: Option, + identity: String, + mirror_source: Option, + mirror_texture: Option, + mirror_canvas: Option, + mirror_id: smithay::backend::renderer::element::Id, + mirror_commit: CommitCounter, surface: OutputCompositor, primary_commit: Option, capture_texture: Option, @@ -211,11 +268,14 @@ struct DirectOutput { lock_frame_pending: bool, } +#[derive(Clone)] struct OutputSelection { connector: connector::Info, crtc: crtc::Handle, mode: DrmMode, settings: OutputSettings, + identity: String, + mirror_source: Option, } struct OutputScan { @@ -248,6 +308,16 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() lid: lid::LidState::default(), lid_reader, topology: Topology::default(), + identities: IdentityRegistry::default(), + manual_outputs: ManualOverride::default(), + desired_outputs: DesiredOutputConfiguration::default(), + monitors: Vec::new(), + output_error: None, + applied_outputs: DesiredOutputConfiguration::default(), + revert_outputs: None, + confirmation: None, + confirmation_timer: None, + confirmation_lid: false, low_power: super::power::low_power(false, super::power::battery_percent()), devices: HashMap::new(), input_devices: Vec::new(), @@ -295,10 +365,15 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() if let Some(backend) = state.direct_backend.as_mut() { backend.active = false; backend.lid.pause(); + backend.lid_reader.set_active(false); if let Some(token) = backend.lid_reader.deadline.take() { handle.remove(token); } backend.topology.dirty = true; + backend.topology.coalescer.clear(); + if let Some(token) = backend.topology.settle_timer.take() { + handle.remove(token); + } if let Some(token) = backend.topology.retry_timer.take() { handle.remove(token); } @@ -339,6 +414,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() if let Some(backend) = state.direct_backend.as_mut() { backend.active = true; + backend.lid_reader.set_active(true); } let seat = state.seat.clone(); @@ -357,7 +433,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() backend.topology.invalidated.insert(node); } - reconcile_outputs(state, false); + topology::hotplug(state); })?; tracing::info!(seat = %seat_name, "initialized direct session input and device discovery"); @@ -460,6 +536,145 @@ pub(crate) fn reload_outputs(state: &mut Ferese) { reconcile_outputs(state, false); } +pub(crate) fn set_output_profile(state: &mut Ferese, name: &str) -> Result<(), String> { + let backend = state + .direct_backend + .as_mut() + .ok_or("monitor profiles require the direct DRM backend")?; + if !backend.active || backend.lid.pending() { + return Err("output session is inactive or refreshing lid state".into()); + } + if name != "auto" && !state.output_profiles.iter().any(|profile| profile.name == name) { + return Err(format!("unknown output profile {name:?}")); + } + let previous = backend.manual_outputs.clone(); + let known_good = backend.applied_outputs.clone(); + backend.manual_outputs.profile = (name != "auto").then(|| name.to_owned()); + backend.manual_outputs.internal = None; + let desired = crate::output_policy::select_profile( + &backend.monitors, + backend.lid.closed(), + &state.output_profiles, + &backend.manual_outputs, + ); + if name != "auto" && desired.profile.as_deref() != Some(name) { + backend.manual_outputs = previous; + return Err("profile does not match the connected monitors/lid".into()); + } + if let Err(error) = validate_live_outputs(state, &state.output_profiles) { + state.direct_backend.as_mut().unwrap().manual_outputs = previous; + return Err(error); + } + reconcile_outputs(state, false); + if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() { + state.direct_backend.as_mut().unwrap().manual_outputs = previous; + return Err(error); + } + if name != "auto" + && state + .direct_backend + .as_ref() + .unwrap() + .desired_outputs + .profile + .as_deref() + != Some(name) + { + state.direct_backend.as_mut().unwrap().manual_outputs = previous; + return Err("profile does not match the connected monitors/lid".into()); + } + arm_output_confirmation(state, known_good, previous); + Ok(()) +} + +pub(crate) fn set_internal_output(state: &mut Ferese, enabled: bool) -> Result<(), String> { + let backend = state + .direct_backend + .as_mut() + .ok_or("internal panel control requires the direct DRM backend")?; + if !backend.active || backend.lid.pending() { + return Err("output session is inactive or refreshing lid state".into()); + } + if !enabled + && !backend + .monitors + .iter() + .any(|monitor| !monitor.internal && monitor.usable) + { + return Err("cannot disable the last usable output".into()); + } + let previous = backend.manual_outputs.clone(); + let known_good = backend.applied_outputs.clone(); + backend.manual_outputs.internal = Some(enabled); + reconcile_outputs(state, false); + if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() { + state.direct_backend.as_mut().unwrap().manual_outputs = previous; + return Err(error); + } + arm_output_confirmation(state, known_good, previous); + Ok(()) +} + +fn confirmation_baseline(pending: Option, previous: T) -> T { + pending.unwrap_or(previous) +} + +fn arm_output_confirmation(state: &mut Ferese, previous: DesiredOutputConfiguration, manual: ManualOverride) { + let backend = state.direct_backend.as_mut().unwrap(); + if let Some(token) = backend.confirmation_timer.take() { + state.loop_handle.remove(token); + } + let timeout = backend + .desired_outputs + .profile + .as_ref() + .and_then(|name| state.output_profiles.iter().find(|profile| &profile.name == name)) + .map_or(15, |profile| profile.confirm_timeout); + let baseline = confirmation_baseline(backend.confirmation.take(), (previous, manual)); + if timeout == 0 || baseline.0.outputs.is_empty() { + return; + } + backend.confirmation = Some(baseline); + backend.confirmation_lid = backend.lid.closed(); + match state.loop_handle.insert_source( + Timer::from_duration(Duration::from_secs(timeout.into())), + |_, _, state| { + if let Some(backend) = state.direct_backend.as_mut() { + backend.confirmation_timer = None; + } + let _ = confirm_output_configuration(state, false); + TimeoutAction::Drop + }, + ) { + Ok(token) => state.direct_backend.as_mut().unwrap().confirmation_timer = Some(token), + Err(error) => { + tracing::warn!(%error, "could not arm monitor confirmation; reverting"); + let _ = confirm_output_configuration(state, false); + } + } +} + +pub(crate) fn confirm_output_configuration(state: &mut Ferese, confirm: bool) -> Result<(), String> { + let backend = state.direct_backend.as_mut().ok_or("direct backend unavailable")?; + if let Some(token) = backend.confirmation_timer.take() { + state.loop_handle.remove(token); + } + if let Some((previous, manual)) = backend.confirmation.take() + && !confirm + { + backend.revert_outputs = Some(crate::output_policy::restore_configuration( + &previous, + &backend.monitors, + )); + backend.manual_outputs = manual; + reconcile_outputs(state, false); + if let Some(error) = state.direct_backend.as_ref().unwrap().output_error.clone() { + return Err(error); + } + } + Ok(()) +} + pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile]) -> Result<(), String> { let Some(backend) = state.direct_backend.as_ref() else { return Ok(()); @@ -468,10 +683,34 @@ pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile]) // Syntax/config validation still runs; live probing waits for ownership. return Ok(()); } + for profile in profiles { + let mut matched = HashSet::new(); + for settings in &profile.outputs { + let matches = backend + .monitors + .iter() + .filter(|monitor| monitor.matches(&settings.matcher)) + .collect::>(); + if matches.len() > 1 || matches.iter().any(|monitor| !matched.insert(&monitor.key)) { + return Err(format!( + "profile {:?} contains ambiguous or overlapping monitor selectors", + profile.name + )); + } + } + } + let mut desired = crate::output_policy::select_profile( + &backend.monitors, + backend.lid.closed(), + profiles, + &backend.manual_outputs, + ); + resolve_output_positions(backend, &mut desired)?; let mut usable = 0; for device in backend.devices.values() { - let mut scan = select_outputs(&device.drm, profiles, backend.low_power).map_err(|e| e.to_string())?; - apply_lid_policy(&mut scan, backend.lid.closed()); + let scan = + select_outputs(&device.drm, &backend.monitors, &desired, backend.low_power).map_err(|e| e.to_string())?; + transaction::validate_device(device, &scan).map_err(|error| error.to_string())?; usable += scan.selections.len(); } if usable == 0 { @@ -830,9 +1069,10 @@ fn redraw(state: &mut Ferese, selected: Option<&[Output]>, source: &'static std: let affected = outputs .iter() .map(|(_, _, output)| { - selected.is_none_or(|selected| selected.contains(output)) - || state.output_has_animations(output) - || state.output_has_pending_visual_changes(output) + let scene = state.direct_backend.as_ref().unwrap().scene_output(state, output); + selected.is_none_or(|selected| selected.contains(output) || selected.contains(&scene)) + || state.output_has_animations(&scene) + || state.output_has_pending_visual_changes(&scene) }) .collect::>(); state.advance_animations(Instant::now()); @@ -889,30 +1129,7 @@ fn internal_connector(interface: connector::Interface) -> bool { ) } -fn apply_lid_policy(scan: &mut OutputScan, closed: bool) { - let external_available = scan - .selections - .iter() - .any(|selection| !internal_connector(selection.connector.interface())); - if !lid_hides_panel(closed, external_available) { - return; - } - let internal = scan - .selections - .iter() - .filter(|selection| internal_connector(selection.connector.interface())) - .map(|selection| selection.connector.to_string()) - .collect::>(); - scan.selections - .retain(|selection| !internal_connector(selection.connector.interface())); - for output in &mut scan.connected_outputs { - if internal.contains(&output.connector) { - output.enabled = false; - output.current_mode = None; - } - } -} - +#[cfg(test)] fn lid_hides_panel(closed: bool, external_available: bool) -> bool { closed && external_available } @@ -943,6 +1160,10 @@ fn render_output( .and_then(|backend| backend.devices.get(&node)) .and_then(|device| device.outputs.get(&crtc)) .map_or(0, |output| output.scheduler.missed_deadlines); + let scene_output = state.direct_backend.as_ref().map(|backend| { + let physical = &backend.devices[&node].outputs[&crtc].output; + backend.scene_output(state, physical) + }); let Some(mut device) = state .direct_backend .as_mut() @@ -968,21 +1189,32 @@ fn render_output( .scheduler .forecast_time(now, plan) .map_or(Duration::ZERO, |target| target.saturating_sub(now)); - let frame = state.sample_frame(&output.output, horizon); + let scene_output = scene_output.unwrap_or_else(|| output.output.clone()); + let frame = state.sample_frame(&scene_output, horizon); let rendered = { (|| -> Result> { state.process_dmabuf_imports(&mut device.renderer, Some(device.render_node)); - let elements = sampled_output_elements(state, &mut device.renderer, &output.output, true, &frame); + let elements = if output.mirror_source.is_some() { + mirror::elements(state, &mut device.renderer, &scene_output, &frame, &mut output)? + } else { + sampled_output_elements(state, &mut device.renderer, &output.output, true, &frame) + }; let effects = frame_effect_metrics(&elements, output.output.current_scale().fractional_scale()); let flags = planes::frame_flags( state.output_has_fullscreen_for_frame(&output.output, frame.overview.is_presenting()), frame.animating, state.session_lock.active(), ); - let result = - output - .surface - .render_frame(&mut device.renderer, &elements, [0.035, 0.04, 0.055, 1.0], flags)?; + let result = output.surface.render_frame( + &mut device.renderer, + &elements, + if output.mirror_source.is_some() { + [0.0, 0.0, 0.0, 1.0] + } else { + [0.035, 0.04, 0.055, 1.0] + }, + flags, + )?; if result.needs_sync() && let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element @@ -1033,16 +1265,22 @@ fn render_output( PrimaryPlaneElement::Element(_) => (Vec::new(), None), }; - let presentation = crate::presentation::take_output_feedback( - state, - &output.output, - &result.states, - Kind::Vsync | Kind::HwClock | Kind::HwCompletion, - ); + let presentation = if output.mirror_source.is_some() { + OutputPresentationFeedback::new(&output.output) + } else { + crate::presentation::take_output_feedback( + state, + &output.output, + &result.states, + Kind::Vsync | Kind::HwClock | Kind::HwCompletion, + ) + }; let visibility = result.states.clone(); drop(result); output.surface.queue_frame(presentation)?; - state.display_presentation.queued(&output.output, &visibility); + if output.mirror_source.is_none() { + state.display_presentation.queued(&output.output, &visibility); + } output.primary_commit = primary_commit; output .render_metrics @@ -1074,7 +1312,7 @@ fn render_output( device.outputs.insert(crtc, output); restore_device(state, node, device); - if !submitted { + if !submitted && scene_output == state.direct_backend.as_ref().unwrap().devices[&node].outputs[&crtc].output { schedule_frame_callbacks(state, node, crtc, plan.present_at); } @@ -1098,243 +1336,113 @@ fn direct_node_for_device(state: &Ferese, device_id: libc::dev_t) -> Option Result { - if state.direct_backend.as_ref().is_none_or(|backend| !backend.active) { - return Err(()); - } - let Some(mut device) = state - .direct_backend - .as_mut() - .and_then(|backend| backend.devices.remove(&node)) - else { + let Some(backend) = state.direct_backend.as_mut().filter(|backend| backend.active) else { return Err(()); }; - - let mut scan = match select_outputs( + let Some(mut device) = backend.devices.remove(&node) else { + return Err(()); + }; + let scan = select_outputs( &device.drm, - &state.output_profiles, - state.direct_backend.as_ref().is_some_and(|backend| backend.low_power), - ) { - Ok(scan) => scan, - Err(error) => { - tracing::error!(?node, %error, "failed to scan DRM connectors"); - restore_device(state, node, device); - return Err(()); - } - }; - - let lid_closed = state - .direct_backend - .as_ref() - .is_some_and(|backend| backend.lid.closed()); - apply_lid_policy(&mut scan, lid_closed); - device.connected_outputs = scan.connected_outputs; - let mut complete = true; - - let mut selections = scan - .selections - .into_iter() - .map(|selection| (selection.crtc, selection)) - .collect::>(); - let existing = device.outputs.keys().copied().collect::>(); - let mut deferred_removals = Vec::new(); - - for crtc in existing { - if let Some(selection) = selections.get(&crtc) - && let Some(output) = device.outputs.get_mut(&crtc) - && selection.connector.handle() == output.connector - && (selection.mode != output.mode || selection.settings != output.settings) - { - // Keep the wl_output/global and workspace ownership alive. A mode - // change is tested by DRM before updating client-visible geometry. - let result = if selection.mode != output.mode { - output.surface.use_mode(selection.mode) - } else { - Ok(()) - }; - - match result { - Ok(()) => { - let old_geometry = state.space.output_geometry(&output.output); - output.mode = selection.mode; - cancel_output_timers(&state.loop_handle, output); - output.settings = selection.settings.clone(); - let position = selection.settings.position.unwrap_or_else(|| { - state - .space - .output_geometry(&output.output) - .map(|g| [g.loc.x, g.loc.y]) - .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()), - ); - state.space.map_output(&output.output, (position[0], position[1])); - if let Some(id) = state.output_id(&output.output) - && let Some(g) = state.space.output_geometry(&output.output) - { - state.output_workspaces.update_geometry( - id, - ferese_core::OutputGeometry::new(g.loc.x, g.loc.y, g.size.w, g.size.h), - ); - if 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( - g.loc.x as f64, - g.loc.y as f64, - g.size.w as f64, - g.size.h as f64, - ), - ); - } - } - - output.surface.reset_buffer_ages(); - output.primary_commit = None; - output.capture_texture = None; - if let Some(backend) = state.direct_backend.as_mut() { - let clock = backend.presentation.entry((node, crtc)).or_default(); - clock.set_refresh(OutputMode::from(output.mode).refresh); - clock.reset_timing(); - } - } - - Err(error) => { - complete = false; - tracing::warn!(?crtc, %error, "display change failed; retaining current output") - } - } - - selections.remove(&crtc); - continue; - } - - let unchanged = device.outputs.get(&crtc).is_some_and(|output| { - selections.get(&crtc).is_some_and(|selection| { - selection.connector.handle() == output.connector - && selection.mode == output.mode - && selection.settings == output.settings - }) - }); - - if unchanged { - selections.remove(&crtc); - continue; - } - - // Add replacements on free CRTCs before removing vanished outputs so - // workspace evacuation always has a real destination. Reused CRTCs - // still need their old surface released first. - if !selections.contains_key(&crtc) { - deferred_removals.push(crtc); - continue; - } - - if let Some(mut output) = device.outputs.remove(&crtc) { - cancel_output_timers(&state.loop_handle, &mut output); - state.display_handle.disable_global::(output.global); - state.unregister_output(&output.output); - if let Some(backend) = state.direct_backend.as_mut() { - backend.presentation.remove(&(node, crtc)); - } - - tracing::info!(?node, ?crtc, "removed DRM output"); - } + &backend.monitors, + &backend.desired_outputs, + backend.low_power, + ); + let result = scan + .map_err(|error| error.to_string()) + .and_then(|scan| transaction::apply_device(state, node, &mut device, scan)); + if let Err(error) = &result { + tracing::warn!(?node, %error, "output transaction failed; restored known-good outputs where possible"); + state.direct_backend.as_mut().unwrap().output_error = Some(error.clone()); + transaction::update_applied_info(&mut device); } - - for (_, selection) in selections { - let crtc = selection.crtc; - let mode = selection.mode; - match create_direct_output(state, &mut device.drm, &device.gbm, &device.renderer, selection) { - Ok(output) => { - state - .direct_backend - .as_mut() - .expect("direct backend state remains initialized") - .presentation - .entry((node, crtc)) - .or_default() - .set_refresh(OutputMode::from(mode).refresh); - device.outputs.insert(crtc, output); - } - - Err(error) => { - complete = false; - tracing::error!(?node, ?crtc, %error, "failed to add DRM output"); - } - } - } - - for crtc in deferred_removals { - let keep_internal = device.outputs.get(&crtc).is_some_and(|output| output.internal) - && lid_closed - && device.outputs.get(&crtc).is_some_and(|output| { - device - .connected_outputs - .iter() - .any(|info| info.connector == output.output.name()) - }) - && !device.outputs.values().any(|output| !output.internal); - if keep_internal { - tracing::warn!(?crtc, "keeping laptop panel enabled: external output activation failed"); - if let Some(output) = device.outputs.get(&crtc) - && let Some(info) = device - .connected_outputs - .iter_mut() - .find(|info| info.connector == output.output.name()) - { - info.enabled = true; - info.current_mode = Some(connected_mode_info(output.mode)); - } - - continue; - } - - if let Some(mut output) = device.outputs.remove(&crtc) { - cancel_output_timers(&state.loop_handle, &mut output); - state.display_handle.disable_global::(output.global); - state.unregister_output(&output.output); - if let Some(backend) = state.direct_backend.as_mut() { - backend.presentation.remove(&(node, crtc)); - } - } - } - - { - // Connector detection is not proof that its requested mode was applied. - for info in &mut device.connected_outputs { - info.enabled = false; - info.current_mode = None; - } - - for output in device.outputs.values() { - if let Some(info) = device - .connected_outputs - .iter_mut() - .find(|info| info.connector == output.output.name()) - { - info.enabled = true; - info.current_mode = Some(connected_mode_info(output.mode)); - info.auto_refresh = output.settings.auto_refresh; - info.scale = output.settings.scale; - info.transform = output.settings.transform; - info.configured_position = output.settings.position; - } - } - } - restore_device(state, node, device); - Ok(complete) + Ok(result.is_ok()) +} + +fn refresh_connected_info(state: &mut Ferese) { + let backend = state.direct_backend.as_mut().unwrap(); + for device in backend.devices.values_mut() { + if let Ok(scan) = select_outputs( + &device.drm, + &backend.monitors, + &backend.desired_outputs, + backend.low_power, + ) { + device.connected_outputs = scan.connected_outputs; + } + transaction::update_applied_info(device); + for info in &mut device.connected_outputs { + info.profile = backend.applied_outputs.profile.clone(); + } + } + let mut inventory = backend + .devices + .values() + .flat_map(|device| device.connected_outputs.clone()) + .collect::>(); + for previous in &backend.connected_outputs { + if !inventory.iter().any(|output| output.identity == previous.identity) { + let mut missing = previous.clone(); + missing.connected = false; + missing.enabled = false; + missing.requested_enabled = false; + missing.current_mode = None; + inventory.push(missing); + } + } + let identities = inventory + .iter() + .map(|output| output.identity.clone()) + .collect::>(); + backend.connected_outputs = inventory; + for identity in identities { + state.ensure_output_identity(&identity); + } +} + +fn remember_applied_configuration(state: &mut Ferese) { + let backend = state.direct_backend.as_mut().unwrap(); + let mut applied = backend.desired_outputs.clone(); + for desired in &mut applied.outputs { + let monitor = backend.monitors.iter().find(|monitor| monitor.key == desired.key); + let output = monitor.and_then(|monitor| { + backend + .devices + .values() + .flat_map(|device| device.outputs.values()) + .find(|output| output.identity == monitor.identity) + }); + desired.settings.enabled = output.is_some(); + if let Some(output) = output { + desired.settings = output.settings.clone(); + let (width, height) = output.mode.size(); + desired.settings.mode = Some(OutputModeRequest { + width, + height, + refresh_millihertz: Some(OutputMode::from(output.mode).refresh as u32), + }); + let position = output.output.current_location(); + desired.settings.position = Some([position.x, position.y]); + desired.mirror_source = output.mirror_source.clone(); + } + } + backend.applied_outputs = applied; +} + +fn validate_desired_outputs(state: &Ferese) -> Result<(), String> { + let backend = state.direct_backend.as_ref().ok_or("direct backend unavailable")?; + for device in backend.devices.values() { + let scan = select_outputs( + &device.drm, + &backend.monitors, + &backend.desired_outputs, + backend.low_power, + ) + .map_err(|error| error.to_string())?; + transaction::validate_device(device, &scan).map_err(|error| error.to_string())?; + } + Ok(()) } fn remove_device(state: &mut Ferese, node: DrmNode) { @@ -1355,7 +1463,9 @@ fn remove_device(state: &mut Ferese, node: DrmNode) { state.render.forget_context(&context); for (crtc, mut output) in device.outputs { cancel_output_timers(&state.loop_handle, &mut output); - state.display_handle.disable_global::(output.global); + if let Some(global) = output.global.take() { + state.display_handle.disable_global::(global); + } state.unregister_output(&output.output); if let Some(backend) = state.direct_backend.as_mut() { backend.presentation.remove(&(node, crtc)); @@ -1403,7 +1513,8 @@ fn animation_fallbacks(state: &mut Ferese) -> Vec<(DrmNode, crtc::Handle, Instan .direct_backend .as_ref() .is_some_and(|backend| backend.can_render()); - let animating = enabled && state.output_has_animations(&identity); + let scene = state.direct_backend.as_ref().unwrap().scene_output(state, &identity); + let animating = enabled && state.output_has_animations(&scene); let backend = state.direct_backend.as_mut().unwrap(); let output = backend.devices.get_mut(&node).unwrap().outputs.get_mut(&crtc).unwrap(); // Preserve recovery of a requested final frame if another output @@ -1515,7 +1626,7 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl return; }; - if output.callback_timer.is_some() || output.frame_pending { + if output.mirror_source.is_some() || output.callback_timer.is_some() || output.frame_pending { return; } @@ -1630,7 +1741,12 @@ fn send_frame_callbacks(state: &mut Ferese, output: &Output) { } } -fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) -> io::Result { +fn select_outputs( + drm: &DrmDevice, + monitors: &[Monitor], + desired: &DesiredOutputConfiguration, + low_power: bool, +) -> io::Result { let resources = drm.resource_handles()?; let connected = resources .connectors() @@ -1640,36 +1756,24 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) .into_iter() .filter(|connector| connector.state() == connector::State::Connected) .collect::>(); - let identities = connected - .iter() - .map(|connector| connector_identity(drm, connector)) - .collect::>(); - let active_profile = profiles.iter().find(|profile| { - profile.outputs.iter().all(|settings| { - connected - .iter() - .zip(&identities) - .any(|(connector, identity)| output_matches(settings, connector, identity)) - }) - }); - if let Some(profile) = active_profile { - tracing::info!(profile = %profile.name, "selected output profile"); - } - + let device_key = drm_device_key(drm); let mut selections = Vec::new(); let mut connected_outputs = Vec::new(); let mut used_crtcs = HashSet::new(); - for (connector, identity) in connected.into_iter().zip(identities) { - let settings = active_profile - .and_then(|profile| { - profile - .outputs - .iter() - .find(|settings| output_matches(settings, &connector, &identity)) - }) - .cloned() - .unwrap_or_else(|| default_output_settings(connector.to_string())); + for connector in connected { + let key = format!("{device_key}/{}", connector); + let monitor = monitors + .iter() + .find(|monitor| monitor.key == key) + .ok_or_else(|| io::Error::other("connector topology changed during reconciliation"))?; + let desired_output = desired + .outputs + .iter() + .find(|output| output.key == key) + .ok_or_else(|| io::Error::other("connector missing from desired configuration"))?; + let identity = monitor.identity.clone(); + let settings = desired_output.settings.clone(); let selected_mode = settings .enabled .then(|| { @@ -1691,11 +1795,41 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) } }) .flatten(); + if settings.enabled && selected_mode.is_none() && settings.mode.is_some() { + return Err(io::Error::other(format!( + "requested mode for {} is unavailable", + connector + ))); + } + if let Some(mode) = selected_mode { + let size = output_transform(settings.transform).transform_size(OutputMode::from(mode).size); + let width = (size.w as f64 / settings.scale).round(); + let height = (size.h as f64 / settings.scale).round(); + if width < 1.0 || height < 1.0 || width > i32::MAX as f64 || height > i32::MAX as f64 { + return Err(io::Error::other(format!( + "scale for {} creates invalid logical geometry", + connector + ))); + } + if let Some([x, y]) = settings.position + && (x.checked_add(width as i32).is_none() || y.checked_add(height as i32).is_none()) + { + return Err(io::Error::other(format!( + "position for {} overflows logical geometry", + connector + ))); + } + } connected_outputs.push(ConnectedOutputInfo { connector: connector.to_string(), + connected: true, identity: identity.clone(), + internal: monitor.internal, + requested_enabled: settings.enabled, + mirror_source: desired_output.mirror_source.clone(), enabled: settings.enabled, - profile: active_profile.map(|profile| profile.name.clone()), + profile: desired.profile.clone(), + requested_profile: desired.profile.clone(), auto_refresh: settings.auto_refresh, physical_size: connector.size(), current_mode: selected_mode.map(connected_mode_info), @@ -1731,6 +1865,8 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) crtc, mode, settings, + identity, + mirror_source: desired_output.mirror_source.clone(), }); } } @@ -1752,22 +1888,6 @@ fn connected_mode_info(mode: DrmMode) -> ConnectedModeInfo { } } -fn output_matches(settings: &OutputSettings, connector: &connector::Info, identity: &str) -> bool { - settings.matcher == connector.to_string() || settings.matcher == identity -} - -fn default_output_settings(matcher: String) -> OutputSettings { - OutputSettings { - auto_refresh: false, - matcher, - enabled: true, - mode: None, - scale: 1.0, - transform: OutputTransform::Normal, - position: None, - } -} - fn select_mode(connector: &connector::Info, requested: Option) -> Option { let fallback = || { connector @@ -1785,25 +1905,14 @@ fn select_mode(connector: &connector::Info, requested: Option .modes() .iter() .filter(|mode| mode.size() == (requested.width, requested.height)); - let selected = if let Some(refresh) = requested.refresh_millihertz { + if let Some(refresh) = requested.refresh_millihertz { matching .min_by_key(|mode| OutputMode::from(**mode).refresh.unsigned_abs().abs_diff(refresh)) .filter(|mode| OutputMode::from(**mode).refresh.unsigned_abs().abs_diff(refresh) <= 1_000) .copied() } else { matching.max_by_key(|mode| OutputMode::from(**mode).refresh).copied() - }; - if selected.is_none() { - tracing::warn!( - output = %connector, - width = requested.width, - height = requested.height, - refresh_millihertz = ?requested.refresh_millihertz, - "requested output mode is unavailable; using preferred mode" - ); } - - selected.or_else(fallback) } fn create_direct_output( @@ -1812,14 +1921,16 @@ fn create_direct_output( gbm: &GbmDevice, renderer: &GlesRenderer, selection: OutputSelection, + publish: bool, ) -> Result> { let OutputSelection { connector, crtc, mode, settings, + identity, + mirror_source, } = selection; - let identity = connector_identity(drm, &connector); let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?; let allocator = GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT); let node = DrmNode::from_file(gbm.as_fd())?; @@ -1844,7 +1955,14 @@ fn create_direct_output( )?; // Publish only after DRM/GBM creation succeeds; failed activation must not // leave a phantom output/workspace that could receive evacuated windows. - let (output, global) = create_output(state, &connector, mode, identity, &settings); + let (output, global) = create_output( + state, + &connector, + mode, + identity.clone(), + &settings, + publish && mirror_source.is_none(), + ); surface.set_output_mode_source((&output).into()); let render_metrics = RenderMetrics::from_environment(output.name()); @@ -1856,6 +1974,12 @@ fn create_direct_output( settings: settings.clone(), output, global, + identity, + mirror_source, + mirror_texture: None, + mirror_canvas: None, + mirror_id: smithay::backend::renderer::element::Id::new(), + mirror_commit: CommitCounter::default(), surface, primary_commit: None, capture_texture: None, @@ -1877,21 +2001,35 @@ fn create_output( mode: DrmMode, identity: String, settings: &OutputSettings, -) -> (Output, GlobalId) { + publish: bool, +) -> (Output, Option) { let name = connector.to_string(); let physical_size = connector.size().unwrap_or((0, 0)); + let edid_identity = identity + .strip_prefix("edid:") + .map(|value| value.split(':').collect::>()); let output = Output::new( name, PhysicalProperties { size: (physical_size.0 as i32, physical_size.1 as i32).into(), subpixel: Subpixel::from(connector.subpixel()), - make: "Unknown".into(), - model: "Unknown".into(), + make: edid_identity + .as_ref() + .and_then(|parts| parts.first()) + .copied() + .unwrap_or("Unknown") + .into(), + model: edid_identity + .as_ref() + .and_then(|parts| parts.get(1)) + .copied() + .unwrap_or("Unknown") + .into(), }, ); let output_mode = OutputMode::from(mode); - let global = output.create_global::(&state.display_handle); + let global = publish.then(|| output.create_global::(&state.display_handle)); for mode in connector.modes().iter().copied().map(OutputMode::from) { output.add_mode(mode); } @@ -1912,8 +2050,10 @@ fn create_output( Some(Scale::Fractional(settings.scale)), Some((position[0], position[1]).into()), ); - state.space.map_output(&output, (position[0], position[1])); - state.register_output(&output, identity); + if publish { + state.space.map_output(&output, (position[0], position[1])); + state.register_output(&output, identity); + } (output, global) } @@ -1930,8 +2070,8 @@ fn output_transform(transform: OutputTransform) -> Transform { } } -fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String { - let edid = drm.get_properties(connector.handle()).ok().and_then(|properties| { +fn connector_edid(drm: &DrmDevice, connector: &connector::Info) -> Option> { + drm.get_properties(connector.handle()).ok().and_then(|properties| { properties.iter().find_map(|(handle, raw)| { let info = drm.get_property(*handle).ok()?; if info.name().to_bytes() != b"EDID" { @@ -1942,12 +2082,141 @@ fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String { _ => None, } }) - }); + }) +} - match edid { - Some(edid) if !edid.is_empty() => format!("drm-edid:{:016x}", stable_hash(&edid)), - _ => format!("drm:{connector}"), +fn drm_device_key(drm: &DrmDevice) -> String { + let node = DrmNode::from_file(drm.as_fd()).expect("DRM descriptor has a device identity"); + let path = node + .dev_path() + .and_then(|path| { + path.file_name() + .map(|name| Path::new("/sys/class/drm").join(name).join("device")) + }) + .and_then(|path| std::fs::canonicalize(path).ok()); + path.map_or_else( + || format!("gpu-{}", node.dev_id()), + |path| path.to_string_lossy().into_owned(), + ) +} + +fn resolve_output_positions( + backend: &DirectBackendState, + desired: &mut DesiredOutputConfiguration, +) -> Result<(), String> { + let mut placements = Vec::new(); + for device in backend.devices.values() { + let scan = select_outputs(&device.drm, &backend.monitors, desired, backend.low_power) + .map_err(|error| error.to_string())?; + for selection in scan.selections { + let size = + output_transform(selection.settings.transform).transform_size(OutputMode::from(selection.mode).size); + let key = format!("{}/{}", drm_device_key(&device.drm), selection.connector); + let previous = device + .outputs + .values() + .find(|output| output.identity == selection.identity) + .filter(|output| output.mirror_source.is_none()) + .map(|output| { + let position = output.output.current_location(); + [position.x, position.y] + }); + placements.push(crate::output_policy::Placement { + key, + size: ( + (size.w as f64 / selection.settings.scale).round() as i32, + (size.h as f64 / selection.settings.scale).round() as i32, + ), + previous, + }); + } } + crate::output_policy::arrange_positions(desired, &placements) +} + +fn refresh_monitor_policy(state: &mut Ferese) -> Result<(), String> { + let backend = state.direct_backend.as_mut().ok_or("direct backend unavailable")?; + let mut discovered = Vec::new(); + for device in backend.devices.values() { + let drm = &device.drm; + let key = drm_device_key(drm); + for handle in drm.resource_handles().map_err(|error| error.to_string())?.connectors() { + let connector = drm.get_connector(*handle, true).map_err(|error| error.to_string())?; + if connector.state() != connector::State::Connected { + continue; + } + let edid = connector_edid(drm, &connector); + discovered.push((format!("{key}/{}", connector), connector, edid)); + } + } + discovered.sort_by(|a, b| a.0.cmp(&b.0)); + let identities = backend.identities.resolve( + &discovered + .iter() + .map(|(key, _, edid)| (key.clone(), edid.as_deref().and_then(Identity::from_edid))) + .collect::>(), + ); + backend.monitors = discovered + .into_iter() + .zip(identities) + .map(|((key, connector, edid), identity)| { + let mut aliases = vec![format!("drm:{connector}")]; + if let Some(edid) = edid { + aliases.push(format!("drm-edid:{:016x}", stable_hash(&edid))); + } + Monitor { + key, + identity, + connector: connector.to_string(), + aliases, + internal: internal_connector(connector.interface()), + usable: !connector.modes().is_empty(), + } + }) + .collect(); + if backend.confirmation.is_some() { + let previous_keys = backend + .applied_outputs + .outputs + .iter() + .map(|output| &output.key) + .collect::>(); + let current_keys = backend + .monitors + .iter() + .map(|monitor| &monitor.key) + .collect::>(); + if previous_keys != current_keys || backend.confirmation_lid != backend.lid.closed() { + backend.confirmation = None; + if let Some(token) = backend.confirmation_timer.take() { + state.loop_handle.remove(token); + } + } + } + let previous_override = backend.manual_outputs.profile.clone(); + backend + .manual_outputs + .observe(&backend.monitors, backend.lid.closed(), &state.output_profiles); + if previous_override != backend.manual_outputs.profile { + backend.confirmation = None; + if let Some(token) = backend.confirmation_timer.take() { + state.loop_handle.remove(token); + } + } + let mut desired = crate::output_policy::select_profile( + &backend.monitors, + backend.lid.closed(), + &state.output_profiles, + &backend.manual_outputs, + ); + resolve_output_positions(backend, &mut desired)?; + tracing::debug!(profile = ?desired.profile, changed = ?crate::output_policy::changed_outputs(&backend.desired_outputs, &desired), "selected monitor configuration"); + if let Some(previous) = backend.revert_outputs.take() { + desired = crate::output_policy::restore_configuration(&previous, &backend.monitors); + resolve_output_positions(backend, &mut desired)?; + } + backend.desired_outputs = desired; + Ok(()) } fn stable_hash(bytes: &[u8]) -> u64 { @@ -2009,6 +2278,20 @@ mod tests { use super::*; + #[test] + fn repeated_unconfirmed_changes_keep_original_rollback_and_manual_state() { + let a = ("confirmed A", "automatic"); + let b = confirmation_baseline(None, a); + let c = confirmation_baseline(Some(b), ("unconfirmed B", "profile B")); + assert_eq!(c, a); + let d = confirmation_baseline(Some(c), ("unconfirmed C", "profile C")); + assert_eq!(d, a); + assert_eq!( + confirmation_baseline(None, ("confirmed C", "profile C")), + ("confirmed C", "profile C") + ); + } + #[test] fn lid_only_hides_panels_with_a_usable_external_output() { assert!(lid_hides_panel(true, true)); diff --git a/compositor/src/backends/direct/capture.rs b/compositor/src/backends/direct/capture.rs index 94d4c4b..f9b833f 100644 --- a/compositor/src/backends/direct/capture.rs +++ b/compositor/src/backends/direct/capture.rs @@ -30,7 +30,7 @@ pub(super) fn capture_output( Ok(()) } -fn render_capture<'a, E: RenderElement>( +pub(super) fn render_capture<'a, E: RenderElement>( renderer: &mut GlesRenderer, texture: &'a mut Option, output: &Output, diff --git a/compositor/src/backends/direct/lid.rs b/compositor/src/backends/direct/lid.rs index 1ebcc8b..95a7a7c 100644 --- a/compositor/src/backends/direct/lid.rs +++ b/compositor/src/backends/direct/lid.rs @@ -1,5 +1,5 @@ //! Resume reads are asynchronous; newer switch events win over old replies. -use std::sync::mpsc; +use std::sync::{Arc, Mutex, mpsc}; use super::*; @@ -63,8 +63,51 @@ impl LidState { } } +struct InhibitorLease { + active: bool, + generation: u64, + descriptor: Option, + suspend: bool, +} + +impl Default for InhibitorLease { + fn default() -> Self { + Self { + active: false, + generation: 0, + descriptor: None, + suspend: false, + } + } +} + +impl InhibitorLease { + fn activate(&mut self, active: bool) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.active = active; + self.suspend = false; + // Closing here releases logind immediately, independently of worker progress. + self.descriptor = None; + self.generation + } + + fn accept(&mut self, generation: u64, descriptor: T) { + if self.active && self.generation == generation { + self.descriptor = Some(descriptor); + } + } +} + +enum PolicyRequest { + Activate(u64), + Suspend, +} + pub(super) struct Reader { requests: mpsc::Sender, + suspend: mpsc::Sender, + lease: Arc>>, + suspend_requested: bool, pub deadline: Option, } @@ -99,7 +142,94 @@ fn read_acpi() -> Option { } 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> { + let (suspend, suspend_requests) = mpsc::channel::(); + 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::()); + 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::()) + .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::(); let (replies, events) = smithay::reexports::calloop::channel::channel(); state.loop_handle.insert_source(events, |event, _, state| { @@ -126,10 +256,15 @@ impl Reader { } })?; - Ok(Self { + let mut reader = Self { requests, + suspend, + lease, + suspend_requested: false, deadline: None, - }) + }; + reader.set_active(true); + Ok(reader) } } @@ -152,6 +287,8 @@ pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) { cancel_output_timers(&state.loop_handle, output); } } + backend.lid_reader.suspend_requested = false; + backend.lid_reader.lease.lock().unwrap().suspend = false; let probe = backend.lid.begin(reactivate); let sent = backend.lid_reader.requests.send(probe).is_ok(); let timer = state @@ -191,6 +328,7 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option) { // Waking a locked session may request redraws. Hold them until the fresh // device/connector snapshot has been reconciled. + backend.lid_reader.suspend_requested = false; backend.topology.reconciling = true; state.reset_animation_clock(); state.lock_input_activity(); @@ -198,10 +336,40 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option) { reconcile_outputs(state, reactivate); } +/// Only logind requests suspend. Inactive events wait for fresh resume topology. +pub(super) fn apply_suspend_policy(state: &mut Ferese) { + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + let requested = backend.active && backend.desired_outputs.suspend && backend.lid.closed(); + backend.lid_reader.lease.lock().unwrap().suspend = requested; + if backend.lid_reader.suspend_requested != requested { + backend.lid_reader.suspend_requested = requested; + let _ = backend.lid_reader.suspend.send(PolicyRequest::Suspend); + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn pause_releases_inhibitor_and_rejects_delayed_acquisition() { + let mut lease = InhibitorLease::default(); + let first = lease.activate(true); + lease.accept(first, "fd"); + assert_eq!(lease.descriptor, Some("fd")); + lease.activate(false); + assert_eq!(lease.descriptor, None); + lease.accept(first, "late fd"); + assert_eq!(lease.descriptor, None); + let next = lease.activate(true); + lease.accept(first, "old session fd"); + assert_eq!(lease.descriptor, None); + lease.accept(next, "new session fd"); + assert_eq!(lease.descriptor, Some("new session fd")); + } + #[test] #[ignore = "requires dbus-daemon and private test sockets"] fn logind_reads_are_fresh_without_property_change_signals() { diff --git a/compositor/src/backends/direct/mirror.rs b/compositor/src/backends/direct/mirror.rs new file mode 100644 index 0000000..e1a0a96 --- /dev/null +++ b/compositor/src/backends/direct/mirror.rs @@ -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 { + 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, 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, + 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::::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 = [(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:?}"); + } + } +} diff --git a/compositor/src/backends/direct/topology.rs b/compositor/src/backends/direct/topology.rs index 5cfe1a2..26637ac 100644 --- a/compositor/src/backends/direct/topology.rs +++ b/compositor/src/backends/direct/topology.rs @@ -78,6 +78,8 @@ pub(super) struct Topology { pub reconciling: bool, pub dirty: bool, pub retry_timer: Option, + pub settle_timer: Option, + pub coalescer: crate::output_policy::Debouncer, } impl Topology { @@ -93,73 +95,6 @@ impl Topology { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() { - let mut topology = Topology::default(); - assert!(!topology.request(false)); - assert!(!topology.request(false)); - assert!(topology.dirty); - assert!(topology.request(true)); - topology.reconciling = true; - assert!(!topology.request(true)); - } - - #[test] - fn replug_matches_hardware_even_when_card_numbers_change() { - let original = Path::new("/dev/dri/card0"); - let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0"); - let mut slot = DeviceState { - identity: Some(identity.clone()), - ..DeviceState::default() - }; - slot.failed(Instant::now()); - slot.missing(); - let observed = vec![ - ( - PathBuf::from("/dev/dri/card0"), - Some(PathBuf::from("/sys/devices/other-gpu")), - ), - (PathBuf::from("/dev/dri/card2"), Some(identity)), - ]; - assert_eq!( - slot.current_path(original, &observed), - Some(Path::new("/dev/dri/card2")) - ); - assert!(slot.current_path(original, &observed[..1]).is_none()); - assert!(slot.current_path(original, &[]).is_none()); - } - - #[test] - fn unavailable_devices_retry_independently_with_capped_backoff() { - let now = Instant::now(); - let mut topology = Topology::default(); - topology.devices.insert("/dev/dri/card0".into(), DeviceState::default()); - let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap(); - let mut second = DeviceState::default(); - second.ready(healthy); - topology.devices.insert("/dev/dri/card1".into(), second); - let device = topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap(); - device.failed(now); - assert_eq!(device.retry_at, Some(now + Duration::from_secs(1))); - for _ in 0..20 { - device.failed(now); - } - assert_eq!(device.retry_at, Some(now + Duration::from_secs(32))); - assert_eq!( - topology.devices[Path::new("/dev/dri/card1")].availability, - Availability::Ready(healthy) - ); - assert!(topology.retry_deadline(false).is_none()); - assert_eq!(topology.retry_deadline(true), Some(now + Duration::from_secs(32))); - topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap().missing(); - assert!(topology.retry_deadline(true).is_none()); - } -} - fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState { state .direct_backend @@ -180,8 +115,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { return; } + if let Some(token) = backend.topology.settle_timer.take() { + state.loop_handle.remove(token); + } + backend.topology.coalescer.clear(); backend.topology.reconciling = true; backend.topology.dirty = false; + backend.output_error = None; if reactivate { backend .presentation @@ -191,7 +131,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { if let Some(token) = backend.topology.retry_timer.take() { state.loop_handle.remove(token); } - let paths = backend.topology.devices.keys().cloned().collect::>(); + let mut paths = backend.topology.devices.keys().cloned().collect::>(); let invalidated = std::mem::take(&mut backend.topology.invalidated); let observed = all_gpus(backend.session.seat()).map(|paths| { paths @@ -209,6 +149,23 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { backend.low_power = automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent()); + if let Ok(observed) = &observed { + for (path, identity) in observed { + if !backend + .topology + .devices + .values() + .any(|slot| slot.identity == *identity && identity.is_some()) + && !backend.topology.devices.contains_key(path) + { + backend.topology.devices.insert(path.clone(), DeviceState::known(path)); + paths.push(path.clone()); + } + } + } + paths.sort(); + let mut ready = Vec::new(); + for node in invalidated { remove_device(state, node); } @@ -301,7 +258,9 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { .remove(&crtc) .unwrap(); cancel_output_timers(&state.loop_handle, &mut output); - state.display_handle.disable_global::(output.global); + if let Some(global) = output.global.take() { + state.display_handle.disable_global::(global); + } state.unregister_output(&output.output); state .direct_backend @@ -311,28 +270,65 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { .remove(&(node, crtc)); } - let complete = rescan_device(state, node); - if complete.is_err() { - remove_device(state, node); + ready.push((path, node)); + } + + let known_good = state.direct_backend.as_ref().unwrap().applied_outputs.clone(); + if let Err(error) = refresh_monitor_policy(state).and_then(|()| { + transaction::retire_disconnected(state); + validate_desired_outputs(state) + }) { + tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs"); + state.direct_backend.as_mut().unwrap().output_error = Some(error); + for (path, _) in &ready { + slot_mut(state, path).failed(Instant::now()); } - let entry = slot_mut(state, &path); - if complete == Ok(true) { - entry.ready(node); - } else { - entry.failed(Instant::now()); - // A partial connector failure must not take down working outputs. - if complete == Ok(false) { - entry.availability = Availability::Degraded(node); + } else { + // Activate destinations before disabling former owners, including across GPUs. + ready.sort_by_key(|(_, node)| { + let backend = state.direct_backend.as_ref().unwrap(); + let prefix = format!("{}/", drm_device_key(&backend.devices[node].drm)); + let desired = backend + .desired_outputs + .outputs + .iter() + .filter(|output| output.settings.enabled && output.key.starts_with(&prefix)); + let logical_sources = desired.clone().filter(|output| output.mirror_source.is_none()).count(); + std::cmp::Reverse((logical_sources, desired.count())) + }); + let mut succeeded = Vec::new(); + for (path, node) in &ready { + let complete = rescan_device(state, *node); + let entry = slot_mut(state, path); + if complete == Ok(true) { + entry.ready(*node); + succeeded.push(*node); + } else { + entry.failed(Instant::now()); + entry.availability = Availability::Degraded(*node); + // Device boundaries are not cross-GPU atomic. Compensate already + // applied devices using their last actual hardware settings. + if !known_good.outputs.is_empty() { + let backend = state.direct_backend.as_mut().unwrap(); + let requested = backend.desired_outputs.clone(); + backend.desired_outputs = + crate::output_policy::restore_configuration(&known_good, &backend.monitors); + for previous in succeeded { + let _ = rescan_device(state, previous); + } + state.direct_backend.as_mut().unwrap().desired_outputs = requested; + } + break; } } + if state.direct_backend.as_ref().unwrap().output_error.is_none() { + remember_applied_configuration(state); + } } + transaction::recover_last_output(state); + refresh_connected_info(state); let backend = state.direct_backend.as_mut().unwrap(); - backend.connected_outputs = backend - .devices - .values() - .flat_map(|device| device.connected_outputs.clone()) - .collect(); backend.topology.reconciling = false; let retry = backend.topology.retry_deadline(backend.active); if let Some(deadline) = retry { @@ -355,4 +351,110 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) { state.restore_output_focus(); state.relayout(); render_all(state); + lid::apply_suspend_policy(state); +} + +const SETTLE: Duration = Duration::from_millis(150); + +/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer. +pub(super) fn hotplug(state: &mut Ferese) { + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + backend.topology.dirty = true; + if let Some(token) = backend.topology.settle_timer.take() { + state.loop_handle.remove(token); + } + let deadline = backend + .topology + .coalescer + .request(monotonic_now(), backend.active && !backend.lid.pending()); + if deadline.is_none() { + return; + } + match state + .loop_handle + .insert_source(Timer::from_duration(SETTLE), |_, _, state| { + if let Some(backend) = state.direct_backend.as_mut() { + backend.topology.settle_timer = None; + if !backend.topology.coalescer.take_due(monotonic_now()) { + return TimeoutAction::Drop; + } + } + reconcile_outputs(state, false); + TimeoutAction::Drop + }) { + Ok(token) => state.direct_backend.as_mut().unwrap().topology.settle_timer = Some(token), + Err(error) => { + tracing::warn!(%error, "could not debounce output events"); + reconcile_outputs(state, false); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() { + let mut topology = Topology::default(); + assert!(!topology.request(false)); + assert!(!topology.request(false)); + assert!(topology.dirty); + assert!(topology.request(true)); + topology.reconciling = true; + assert!(!topology.request(true)); + } + + #[test] + fn replug_matches_hardware_even_when_card_numbers_change() { + let original = Path::new("/dev/dri/card0"); + let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0"); + let mut slot = DeviceState { + identity: Some(identity.clone()), + ..DeviceState::default() + }; + slot.failed(Instant::now()); + slot.missing(); + let observed = vec![ + ( + PathBuf::from("/dev/dri/card0"), + Some(PathBuf::from("/sys/devices/other-gpu")), + ), + (PathBuf::from("/dev/dri/card2"), Some(identity)), + ]; + assert_eq!( + slot.current_path(original, &observed), + Some(Path::new("/dev/dri/card2")) + ); + assert!(slot.current_path(original, &observed[..1]).is_none()); + assert!(slot.current_path(original, &[]).is_none()); + } + + #[test] + fn unavailable_devices_retry_independently_with_capped_backoff() { + let now = Instant::now(); + let mut topology = Topology::default(); + topology.devices.insert("/dev/dri/card0".into(), DeviceState::default()); + let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap(); + let mut second = DeviceState::default(); + second.ready(healthy); + topology.devices.insert("/dev/dri/card1".into(), second); + let device = topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap(); + device.failed(now); + assert_eq!(device.retry_at, Some(now + Duration::from_secs(1))); + for _ in 0..20 { + device.failed(now); + } + assert_eq!(device.retry_at, Some(now + Duration::from_secs(32))); + assert_eq!( + topology.devices[Path::new("/dev/dri/card1")].availability, + Availability::Ready(healthy) + ); + assert!(topology.retry_deadline(false).is_none()); + assert_eq!(topology.retry_deadline(true), Some(now + Duration::from_secs(32))); + topology.devices.get_mut(Path::new("/dev/dri/card0")).unwrap().missing(); + assert!(topology.retry_deadline(true).is_none()); + } } diff --git a/compositor/src/backends/direct/transaction.rs b/compositor/src/backends/direct/transaction.rs new file mode 100644 index 0000000..9d81419 --- /dev/null +++ b/compositor/src/backends/direct/transaction.rs @@ -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(drm: &D, handle: H, name: &str) -> io::Result { + 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( + 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> { + 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, +} + +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> { + 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::>(); + let desired_connectors = scan + .selections + .iter() + .map(|selection| selection.connector.handle()) + .collect::>(); + 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> { + 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 = 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::(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, + staged: HashMap, + replaced: HashSet, +} + +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::>(); + 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::>(); + 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::>(); + 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::>(); + 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::(&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::>(); + 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::>(); + 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::>(); + 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; +} diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 8b77aa8..0c4cea5 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -20,7 +20,7 @@ use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy}; pub use input_motion::InputSettings; use input_motion::*; use outputs::*; -pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; +pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; use serde::Deserialize; use smithay::input::keyboard::{Keycode, keysyms, xkb}; @@ -1049,9 +1049,15 @@ mod tests { config.output_profiles().unwrap(), vec![OutputProfile { name: "docked".to_owned(), + layout: OutputLayout::Extend, + confirm_timeout: 15, + lid_policy: LidPolicy::DockOrSuspend, + lid_closed: None, + mirror_source: None, outputs: vec![ OutputSettings { auto_refresh: false, + required: true, matcher: "HDMI-A-1".to_owned(), enabled: true, mode: Some(OutputModeRequest { @@ -1065,6 +1071,7 @@ mod tests { }, OutputSettings { auto_refresh: false, + required: true, matcher: "eDP-1".to_owned(), enabled: false, mode: None, @@ -1077,6 +1084,29 @@ mod tests { ); } + #[test] + fn output_profile_metadata_and_invalid_mirrors() { + let config = parse( + "output-profile desk layout=\"mirror\" mirror-source=\"eDP-1\" lid-closed=#false confirm-timeout=0 lid-policy=\"ignore\" { output eDP-1; output DP-1 required=#false; }", + ); + let profile = &config.output_profiles().unwrap()[0]; + assert_eq!(profile.layout, OutputLayout::Mirror); + assert_eq!(profile.lid_closed, Some(false)); + assert_eq!(profile.confirm_timeout, 0); + assert_eq!(profile.lid_policy, LidPolicy::Ignore); + assert!(!profile.outputs[1].required); + for source in [ + "output-profile a layout=\"mirror\" { output eDP-1; }", + "output-profile a layout=\"extend\" mirror-source=\"eDP-1\" { output eDP-1; }", + "output-profile a layout=\"mirror\" mirror-source=\"DP-2\" { output eDP-1; output DP-1; }", + "output-profile a { output eDP-1 enabled=#false; }", + "output-profile a { output eDP-1; }\noutput-profile a { output DP-1; }", + ] { + assert!(parse(source).output_profiles().is_err(), "{source}"); + } + assert!(Config::parse_source("output-profile a layout=\"docked\" { output eDP-1; }").is_err()); + } + #[test] fn automatic_refresh_is_opt_in_and_rejects_wrong_types() { let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }"); diff --git a/compositor/src/config/outputs.rs b/compositor/src/config/outputs.rs index 4c4fce9..3426cba 100644 --- a/compositor/src/config/outputs.rs +++ b/compositor/src/config/outputs.rs @@ -43,6 +43,7 @@ impl Config { Ok(OutputSettings { auto_refresh: output.auto_refresh, + required: output.required, matcher: matcher.to_owned(), enabled: output.enabled, mode: output @@ -58,9 +59,38 @@ impl Config { }) .collect::, ConfigError>>()?; + if !outputs.iter().any(|output| output.enabled) { + return Err(ConfigError::OutputProfile(format!( + "profile {name:?} disables every output" + ))); + } + if profile.layout != OutputLayout::Mirror && profile.mirror_source.is_some() { + return Err(ConfigError::OutputProfile(format!( + "profile {name:?}: mirror-source requires layout=\"mirror\"" + ))); + } + if let Some(source) = &profile.mirror_source + && !outputs.iter().any(|output| &output.matcher == source && output.enabled) + { + return Err(ConfigError::OutputProfile(format!( + "profile {name:?}: mirror source must name an enabled output" + ))); + } + if profile.layout == OutputLayout::Mirror && outputs.iter().filter(|output| output.enabled).count() < 2 + { + return Err(ConfigError::OutputProfile(format!( + "profile {name:?}: mirror needs at least two enabled outputs" + ))); + } + Ok(OutputProfile { name: name.to_owned(), outputs, + layout: profile.layout, + confirm_timeout: profile.confirm_timeout, + lid_policy: profile.lid_policy, + lid_closed: profile.lid_closed, + mirror_source: profile.mirror_source.clone(), }) }) .collect() @@ -112,11 +142,17 @@ pub(super) const fn default_output_scale() -> f64 { pub struct OutputProfile { pub name: String, pub outputs: Vec, + pub layout: OutputLayout, + pub confirm_timeout: u32, + pub lid_policy: LidPolicy, + pub lid_closed: Option, + pub mirror_source: Option, } #[derive(Clone, Debug, PartialEq)] pub struct OutputSettings { pub auto_refresh: bool, + pub required: bool, pub matcher: String, pub enabled: bool, pub mode: Option, @@ -125,6 +161,18 @@ pub struct OutputSettings { pub position: Option<[i32; 2]>, } +impl OutputSettings { + /// Selectors and requiredness affect matching, not the applied output state. + pub(crate) fn same_configuration(&self, other: &Self) -> bool { + self.enabled == other.enabled + && self.mode == other.mode + && self.auto_refresh == other.auto_refresh + && self.scale == other.scale + && self.transform == other.transform + && self.position == other.position + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OutputModeRequest { pub width: u16, @@ -152,10 +200,40 @@ pub enum OutputTransform { Flipped270, } +#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LidPolicy { + #[default] + DockOrSuspend, + Ignore, +} + +const fn default_confirm_timeout() -> u32 { + 15 +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum OutputLayout { + InternalOnly, + ExternalOnly, + #[default] + Extend, + Mirror, +} + #[derive(Clone, Debug, Deserialize)] pub(super) struct OutputProfileConfig { pub(super) name: String, #[serde(default)] + pub(super) layout: OutputLayout, + #[serde(default = "default_confirm_timeout")] + pub(super) confirm_timeout: u32, + #[serde(default)] + pub(super) lid_policy: LidPolicy, + pub(super) lid_closed: Option, + pub(super) mirror_source: Option, + #[serde(default)] pub(super) outputs: Vec, } @@ -163,6 +241,8 @@ pub(super) struct OutputProfileConfig { pub(super) struct OutputConfig { #[serde(default)] pub(super) auto_refresh: bool, + #[serde(default = "default_true")] + pub(super) required: bool, #[serde(rename = "match")] pub(super) matcher: String, #[serde(default = "default_true")] diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 7955ea9..ffa0a65 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -694,7 +694,40 @@ impl Ferese { return Ok(json!(self.has_client_surfaces(pid))); } "get-workspaces" => return Ok(self.workspaces_json()), - "get-outputs" => return Ok(self.outputs_json()), + "outputs" | "get-outputs" => return Ok(self.outputs_json()), + "output-profiles" => { + return Ok(json!({ + "confirmation_pending": self.direct_backend.as_ref().is_some_and(|backend| backend.confirmation_pending()), + "manual_profile": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.profile.as_ref()), + "manual_internal": self.direct_backend.as_ref().and_then(|backend| backend.manual_outputs.internal), + "profiles": self.output_profiles.iter().map(|profile| json!({ + "name": profile.name, "layout": profile.layout, + "confirm_timeout": profile.confirm_timeout, "lid_policy": profile.lid_policy, "lid_closed": profile.lid_closed, "mirror_source": profile.mirror_source, + "outputs": profile.outputs.iter().map(|output| json!({"selector": output.matcher, "required": output.required, "enabled": output.enabled})).collect::>() + })).collect::>() + })); + } + "output-confirm" | "output-revert" => { + crate::backends::direct::confirm_output_configuration(self, command == "output-confirm") + .map_err(|error| CommandError::new("output_configuration_failed", error))?; + return Ok(self.outputs_json()); + } + "output-profile" => { + let name = args["name"] + .as_str() + .ok_or_else(|| CommandError::new("invalid_arguments", "output-profile requires a name or auto"))?; + crate::backends::direct::set_output_profile(self, name) + .map_err(|error| CommandError::new("output_configuration_failed", error))?; + return Ok(self.outputs_json()); + } + "output-internal" => { + let enabled = args["enabled"] + .as_bool() + .ok_or_else(|| CommandError::new("invalid_arguments", "output-internal requires on or off"))?; + crate::backends::direct::set_internal_output(self, enabled) + .map_err(|error| CommandError::new("output_configuration_failed", error))?; + return Ok(self.outputs_json()); + } _ => { return Err(CommandError::new( "unknown_command", @@ -1026,8 +1059,8 @@ impl Ferese { .connected_outputs .iter() .map(|info| { - let mapped = self.space.outputs().find(|output| output.name() == info.connector); - let id = mapped.and_then(|output| self.output_id(output)); + let mapped = self.output_by_identity(&info.identity); + let id = self.persistent_output_id(&info.identity); let geometry = mapped.and_then(|output| self.space.output_geometry(output)); let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id)); let position = geometry @@ -1066,9 +1099,16 @@ impl Ferese { "name": info.connector, "connector": info.connector, "identity": info.identity, - "connected": true, - "enabled": info.enabled && mapped.is_some(), + "connected": info.connected, + "internal": info.internal, + "requested_enabled": info.requested_enabled, + "applied_enabled": info.enabled, + "mirror_source": info.mirror_source, + "enabled": info.enabled, "profile": info.profile, + "requested_profile": info.requested_profile, + "configuration_error": backend.output_configuration_error(), + "confirmation_pending": backend.confirmation_pending(), "auto_refresh": info.auto_refresh, "low_power": backend.low_power, "focused": id.is_some() && id == focused, diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 8d0885b..703a1b6 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -16,6 +16,9 @@ mod input; mod input_capture; mod ipc; mod metrics; +mod monitor_identity; +mod output_policy; +mod output_transaction; mod overview; mod portal_session; mod portal_shortcuts; diff --git a/compositor/src/monitor_identity.rs b/compositor/src/monitor_identity.rs new file mode 100644 index 0000000..4a2c927 --- /dev/null +++ b/compositor/src/monitor_identity.rs @@ -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, +} + +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 { + 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, + /// Retains the first monitor's identity if a later collision is discovered. + known: HashMap, +} + +impl IdentityRegistry { + pub fn resolve(&mut self, monitors: &[(String, Option)]) -> Vec { + 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::>(); + 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()); + } +} diff --git a/compositor/src/output_policy.rs b/compositor/src/output_policy.rs new file mode 100644 index 0000000..2c26ed5 --- /dev/null +++ b/compositor/src/output_policy.rs @@ -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, + 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, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct DesiredOutputConfiguration { + pub profile: Option, + pub outputs: Vec, + 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, + pub internal: Option, + topology: Option<(BTreeSet, 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::>(); + 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::>(); + 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, +} + +impl Debouncer { + pub fn request(&mut self, now: std::time::Duration, active: bool) -> Option { + 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::>(); + 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))); + } +} diff --git a/compositor/src/output_transaction.rs b/compositor/src/output_transaction.rs new file mode 100644 index 0000000..cb703f7 --- /dev/null +++ b/compositor/src/output_transaction.rs @@ -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 { + pub error: String, + pub unavailable: Vec, +} + +/// 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( + driver: &mut T, + changes: &[T::Key], + removals: &[T::Key], +) -> Result<(), Failure> { + 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, + previous: BTreeSet, + 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)); + } +} diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index 54f33ca..ee16e25 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -250,6 +250,47 @@ mod tests { assert!(!state.inactive_dim.enabled); } + #[test] + fn invalid_monitor_reload_keeps_last_good_runtime_configuration() { + const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + use std::os::unix::fs::PermissionsExt; + let directory = Directory::new(); + std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "reload::tests::invalid_monitor_reload_keeps_last_good_runtime_configuration", + "--nocapture", + ]) + .env(CHILD, "1") + .env("XDG_RUNTIME_DIR", &directory.0) + .env("XDG_CONFIG_HOME", &directory.0) + .env_remove("FERESE_SOCKET") + .output() + .unwrap(); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + return; + } + let directory = Directory::new(); + let source = "output-profile mobile { output eDP-1 scale=1.75; }"; + let (_, runtime, _) = crate::theme::prepare(source, &directory.0).unwrap(); + let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let display = smithay::reexports::wayland_server::Display::new().unwrap(); + let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap(); + state.config_source = Some(source.into()); + let accepted = state.output_profiles.clone(); + for invalid in [ + "output-profile bad { output eDP-1 scale=0; }", + "output-profile bad layout=\"mirror\" { output eDP-1; }", + "output-profile mobile { output eDP-1; }\noutput-profile mobile { output DP-1; }", + ] { + assert!(state.reload_config_source(invalid.into()).is_err()); + assert_eq!(state.output_profiles, accepted); + assert_eq!(state.config_source.as_deref(), Some(source)); + } + } + #[test] fn runtime_validation_rejects_bad_edits_before_application() { for source in [ diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 702f98a..d870109 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -145,6 +145,18 @@ impl Lock { } impl Ferese { + fn lock_outputs_protected(&self) -> bool { + if let Some(backend) = self.direct_backend.as_ref() { + // Reconciliation temporarily removes devices while staging changes. + // Never interpret that partial inventory as protected displays. + if backend.reconciling() { + return false; + } + return self.session_lock.ready_for_idle(backend.physical_outputs()); + } + self.session_lock.ready_for_idle(self.space.outputs()) + } + pub(crate) fn lock_input_activity(&mut self) { if self.session_lock.activity(Instant::now()) { crate::backends::direct::wake_locked_outputs(self); @@ -162,7 +174,7 @@ impl Ferese { if let Some(token) = self.session_lock.idle_timer.take() { self.loop_handle.remove(token); } - if !self.session_lock.ready_for_idle(self.space.outputs()) { + if !self.lock_outputs_protected() { return; } @@ -189,7 +201,7 @@ impl Ferese { } fn update_lock_idle(&mut self, now: Instant) { - if !self.session_lock.ready_for_idle(self.space.outputs()) { + if !self.lock_outputs_protected() { return; } let fade_duration = self.animation_duration(Duration::from_millis(500)); @@ -257,7 +269,7 @@ impl Ferese { pub(crate) fn confirm_lock_if_ready(&mut self) { // With no outputs there is no visible content to protect. Re-evaluate // here after acquisition and output removal as well as presentation. - if !self.session_lock.ready_for_idle(self.space.outputs()) { + if !self.lock_outputs_protected() { return; } @@ -629,6 +641,42 @@ mod tests { ); } + #[test] + fn mirror_must_present_protected_frame_before_lock_is_ready() { + let make_output = |name: &str| { + Output::new( + name.into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ) + }; + let source = make_output("source"); + let mirror = make_output("mirror"); + let mut lock = Lock { + lifecycle: Lifecycle::Orphaned, + ..Lock::default() + }; + lock.presented.insert(source.clone()); + let displays = [&source, &mirror]; + assert!( + !lock.ready_for_idle(displays.into_iter()), + "failed mirror rendering must block confirmation" + ); + lock.presented.insert(mirror.clone()); + assert!(lock.ready_for_idle(displays.into_iter())); + lock.output_added(&mirror); + assert!( + !lock.ready_for_idle(displays.into_iter()), + "reconfiguration invalidates physical protection" + ); + lock.output_removed(&mirror); + assert!(lock.ready_for_idle(std::iter::once(&source))); + } + #[test] fn protected_fallback_frames_allow_idle_after_early_locker_crash() { let output = Output::new( diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 5fa1ece..3c1a682 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -263,6 +263,14 @@ impl Ferese { } pub(crate) fn send_shell_snapshots(&mut self) { + if self + .direct_backend + .as_ref() + .is_some_and(|backend| backend.reconciling()) + { + return; + } + self.shell_resources.retain(|resource| resource.upgrade().is_ok()); if self.shell_resources.is_empty() { self.last_shell_snapshot = None; diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 8af3626..c249199 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -70,6 +70,14 @@ impl Ferese { } fn relayout_outputs(&mut self, mut redraw_outputs: Option>) { + if self + .direct_backend + .as_ref() + .is_some_and(|backend| backend.reconciling()) + { + return; + } + if let Some(outputs) = redraw_outputs.as_mut() { outputs.extend( self.space diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index 763bd4b..2027bd8 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -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 { + self.output_identity_ids.get(identity).copied() + } + + pub(crate) fn ensure_output_identity(&mut self, identity: &str) -> OutputId { + *self.output_identity_ids.entry(identity.to_owned()).or_insert_with(|| { + let id = OutputId(self.next_output_id); + self.next_output_id = self.next_output_id.checked_add(1).expect("output ID space exhausted"); + id + }) + } + pub fn register_output(&mut self, output: &Output, identity: String) { if self.output_ids.is_empty() { self.reset_animation_clock(); @@ -20,20 +38,40 @@ impl Ferese { tracing::error!(output = %output.name(), "cannot register an unmapped output"); return; }; - let output_id = *self.output_identity_ids.entry(identity).or_insert_with(|| { - let id = OutputId(self.next_output_id); - self.next_output_id = self.next_output_id.saturating_add(1); - id - }); + let output_id = self.ensure_output_identity(&identity); let fallback_workspace = self .workspaces .iter() - .filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none()) + .filter(|workspace| { + self.output_workspaces.output_for_workspace(workspace.id).is_none() + && self + .output_workspaces + .home_output(workspace.id) + .is_none_or(|home| home == output_id) + }) .map(|workspace| workspace.id) .min_by_key(|id| id.0) .unwrap_or_else(|| self.workspaces.create_workspace()); let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); + let returning_floats = self + .windows + .ids() + .values() + .filter_map(|id| { + let workspace = self.workspaces.workspace_for_window(*id)?; + if self.output_workspaces.home_output(workspace) != Some(output_id) { + return None; + } + let owner = self.output_workspaces.output_for_workspace(workspace)?; + let old = self.output_workspaces.geometry(owner)?; + let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else { + return None; + }; + Some((*id, workspace, rect, old)) + }) + .collect::>(); + let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace); match registration { @@ -41,6 +79,27 @@ impl Ferese { self.output_ids.insert(output.clone(), output_id); self.outputs_by_id.insert(output_id, output.clone()); self.output_names.insert(output_id, output.name()); + for (id, workspace, rect, old) in returning_floats { + if self.output_workspaces.output_for_workspace(workspace) == Some(output_id) { + let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64); + let new = Rect::new( + geometry.x as f64, + geometry.y as f64, + geometry.width as f64, + geometry.height as f64, + ); + let _ = self + .workspaces + .set_floating_rect(id, moved_floating_rect(rect, old, new)); + } + } + if self + .direct_backend + .as_ref() + .is_some_and(|backend| backend.reconciling()) + { + return; + } self.restore_output_focus(); let visible = self.visible_workspace_ids(); self.reconcile_workspaces(&visible); @@ -80,8 +139,35 @@ impl Ferese { self.confirm_lock_if_ready(); self.refresh_lock_idle_policy(); + let old_geometry = self.output_workspaces.geometry(output_id); + let evacuated = self + .output_workspaces + .assigned_workspaces(output_id) + .collect::>(); match self.output_workspaces.disconnect(output_id) { Ok(Some(target)) => { + if let (Some(old), Some(new)) = (old_geometry, self.output_workspaces.geometry(target)) { + let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64); + let new = Rect::new(new.x as f64, new.y as f64, new.width as f64, new.height as f64); + let floats = self + .windows + .ids() + .values() + .filter_map(|id| { + let workspace = self.workspaces.workspace_for_window(*id)?; + if !evacuated.contains(&workspace) { + return None; + } + match self.workspaces.placement(*id)? { + WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))), + _ => None, + } + }) + .collect::>(); + for (id, rect) in floats { + let _ = self.workspaces.set_floating_rect(id, rect); + } + } if let Some(workspace) = self.output_workspaces.active_workspace(target) { self.activate_output_workspace(target, workspace); } diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index 1f26f20..feb525a 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -89,9 +89,11 @@ struct EvacuationRecord { pub struct OutputWorkspaceMap { outputs: BTreeMap, assignments: HashMap, + homes: HashMap, revisions: HashMap, evacuations: HashMap, focused: Option, + orphaned_focus: Option, } impl OutputWorkspaceMap { @@ -118,6 +120,10 @@ impl OutputWorkspaceMap { self.assignments.get(&workspace).copied() } + pub fn home_output(&self, workspace: WorkspaceId) -> Option { + self.homes.get(&workspace).copied() + } + pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool { if self.outputs.values().any(|output| output.active == workspace) { return false; @@ -131,6 +137,10 @@ impl OutputWorkspaceMap { } } self.revisions.remove(&workspace); + self.homes.remove(&workspace); + if self.orphaned_focus == Some(workspace) { + self.orphaned_focus = None; + } self.evacuations.retain(|_, record| { record.workspaces.retain(|entry| entry.workspace != workspace); if record.active == workspace { @@ -150,6 +160,10 @@ impl OutputWorkspaceMap { self.outputs.keys().copied() } + pub fn geometry(&self, output: OutputId) -> Option { + self.outputs.get(&output).map(|state| state.geometry) + } + pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) { if let Some(state) = self.outputs.get_mut(&output) { state.geometry = geometry; @@ -170,15 +184,19 @@ impl OutputWorkspaceMap { let mut reclaimed = Vec::new(); if let Some(record) = &record { 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; if assignment_matches && revision_matches { // Cleanup may have removed every other workspace while this // monitor was disconnected. Never strand the donor output; // the reconnecting monitor can use its fresh fallback. - if evacuated - .target + if target .and_then(|target| self.outputs.get(&target)) .is_some_and(|state| { state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace) @@ -187,7 +205,7 @@ impl OutputWorkspaceMap { continue; } - if let Some(target) = evacuated.target + if let Some(target) = target && let Some(target_state) = self.outputs.get_mut(&target) { target_state.workspaces.remove(&evacuated.workspace); @@ -214,7 +232,7 @@ impl OutputWorkspaceMap { .as_ref() .and_then(|record| reclaimed.contains(&record.active).then_some(record.active)) .or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0)); - let active = match active { + let mut active = match active { Some(active) => active, None => { if let Some(owner) = self.assignments.get(&fallback_workspace).copied() { @@ -224,11 +242,33 @@ impl OutputWorkspaceMap { }); } self.assignments.insert(fallback_workspace, output); + self.homes.insert(fallback_workspace, output); reclaimed.push(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::>(); + 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( output, OutputState { @@ -266,6 +306,9 @@ impl OutputWorkspaceMap { if removed_had_focus { self.focused = target; + if target.is_none() { + self.orphaned_focus = Some(removed.active); + } if let Some(target) = target { self.outputs .get_mut(&target) @@ -327,6 +370,7 @@ impl OutputWorkspaceMap { state.activate(workspace); self.focused = Some(output); if reassigned { + self.homes.insert(workspace, output); self.bump_revision(workspace); } @@ -357,6 +401,7 @@ impl OutputWorkspaceMap { } self.assignments.insert(workspace, output); + self.homes.insert(workspace, output); self.outputs .get_mut(&output) .expect("output was checked above") @@ -368,6 +413,48 @@ impl OutputWorkspaceMap { 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 { if let Some(focused) = self.focused && focused != removed @@ -705,4 +792,62 @@ mod tests { assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(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))); + } } diff --git a/docs/configuration.md b/docs/configuration.md index 7fddafe..36a31dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -445,14 +445,25 @@ window-rule app-id="dev.ferese.Settings" floating=#true ## Displays -`output-profile` has a required unique nonempty `name` and one or more -`output-profile → output` entries. The first profile whose listed monitors -are connected wins. Find connector names and persistent identities using -`feresectl get-outputs`. +Each `output-profile` needs a unique, nonempty `name` and at least one `output` +entry. A profile matches when every required monitor is connected and its +optional `lid-closed` condition matches. The profile with the most required +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 | | --- | --- | --- | | `match` | nonempty connector or persistent identity string | required; unique within profile | +| `required` | boolean | `true`; set `false` for an optional monitor | | `enabled` | boolean | `true` | | `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode | | `auto-refresh` | boolean | `false` | @@ -461,8 +472,68 @@ are connected wins. Find connector names and persistent identities using | `position` | `[integer, integer]` | Automatic horizontal placement | Transforms: `normal`, `rotate-90`, `rotate-180`, `rotate-270`, `flipped`, -`flipped-90`, `flipped-180`, `flipped-270`. Unspecified connected displays stay -enabled with defaults. Disabling every usable output is rejected. +`flipped-90`, `flipped-180`, `flipped-270`. In extend and mirror profiles, +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 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 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%, -and restoration on AC. Session activation only re-evaluates the power policy; -an unchanged policy does not trigger an additional rescan. Connector changes -continue to use the existing hotplug handling. +and restoration on AC. Session activation refreshes lid and connector state +before rendering; unchanged outputs retain their handles and workspaces. +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 output-profile laptop { @@ -496,9 +568,25 @@ output-profile laptop { } ``` -With a usable external display, closing the lid disables internal panels. -Workspaces move to a remaining display and return when their original output -returns. Ferese does not change the system's suspend policy. +With a usable external display, closing the lid disables internal panels after +external activation succeeds. Without one, Ferese asks logind to suspend, +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 diff --git a/docs/development.md b/docs/development.md index 93ad39c..669c016 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,6 +14,7 @@ Keep another desktop available for direct-session recovery. ## 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. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. - [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 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 -unmanaged secondary GPUs. Connected-output reports mark a mode active only when +under a different `cardN` path. Reconciliation also discovers newly connected +GPUs. Connected-output reports mark a mode active only when output creation succeeded. Lock ownership is retained throughout recovery. Run the ordering and private D-Bus checks with: diff --git a/docs/output-management-testing.md b/docs/output-management-testing.md new file mode 100644 index 0000000..b362531 --- /dev/null +++ b/docs/output-management-testing.md @@ -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. diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 118ffed..6fa1770 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -111,6 +111,19 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), let command = args.next().ok_or_else(usage)?; let positional = args.collect::>(); 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" => { exactly_one(&command, &positional, "window ID")?; let window = positional[0] @@ -156,6 +169,10 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), | "get-windows" | "get-workspaces" | "get-outputs" + | "outputs" + | "output-profiles" + | "output-confirm" + | "output-revert" | "reload-config" | "exit" | "request-logout" @@ -191,7 +208,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[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] fn rejects_unknown_or_missing_arguments() { assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));