diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 5530b92..e17a13f 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -246,7 +246,7 @@ impl DirectBackendState { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct ConnectedOutputInfo { pub connector: String, pub connected: bool, @@ -266,7 +266,7 @@ pub(crate) struct ConnectedOutputInfo { pub configured_position: Option<[i32; 2]>, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ConnectedModeInfo { pub width: u16, pub height: u16, diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 4b6a0fb..ffae305 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -14,6 +14,7 @@ use ferese_core::LayoutMode; use ferese_ipc::{Request, Response, VERSION}; pub(crate) mod activity_owner; +pub(crate) mod outputs; mod transport; use ferese_layout::Direction; use serde_json::{Value, json}; @@ -1620,15 +1621,41 @@ impl Ferese { pub(crate) fn ipc_event_snapshot(&self) -> ferese_ipc::events::Snapshot { let locked = self.session_lock.active(); - let mut outputs = self.outputs_json(); - if locked { - for output in outputs.as_array_mut().into_iter().flatten() { - if let Some(fields) = output.as_object_mut() { - fields.remove("workspace"); - fields.remove("focused"); + let outputs = if let Some(backend) = &self.direct_backend { + let state = outputs::State { + connected: &backend.connected_outputs, + focused: self.output_workspaces.focused_output(), + error: backend.output_configuration_error(), + confirmation_pending: backend.confirmation_pending(), + low_power: backend.low_power, + }; + let presentations = backend.connected_outputs.iter().map(|info| { + let mapped = self.output_by_identity(&info.identity); + let id = self.persistent_output_id(&info.identity); + outputs::Presentation { + id, + geometry: mapped.and_then(|output| self.space.output_geometry(output)), + workspace: id.and_then(|id| self.output_workspaces.active_workspace(id)), + scale: mapped + .map(|output| output.current_scale().fractional_scale()) + .unwrap_or(info.scale), + transform: mapped + .map(|output| transform_name(output.current_transform())) + .unwrap_or_else(|| configured_transform_name(info.transform)), } + }); + self.ipc_events + .outputs + .borrow_mut() + .get_or_update(state, presentations, locked, || self.outputs_json()) + } else { + // Nested outputs have no connector inventory or DRM mode lists. + let mut value = self.outputs_json(); + if locked { + outputs::redact(&mut value); } - } + Arc::new(value) + }; let windows = if locked { Vec::new() } else { diff --git a/compositor/src/ipc/outputs.rs b/compositor/src/ipc/outputs.rs new file mode 100644 index 0000000..e50ad39 --- /dev/null +++ b/compositor/src/ipc/outputs.rs @@ -0,0 +1,318 @@ +use std::sync::Arc; + +use ferese_core::{OutputId, WorkspaceId}; +use serde_json::Value; +use smithay::utils::{Logical, Rectangle}; + +use crate::backends::direct::ConnectedOutputInfo; + +/// Connector data and policy borrowed for an exact, allocation-free comparison. +pub(crate) struct State<'a> { + pub connected: &'a [ConnectedOutputInfo], + pub focused: Option, + pub error: Option<&'a str>, + pub confirmation_pending: bool, + pub low_power: bool, +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) struct Presentation { + pub id: Option, + pub geometry: Option>, + pub workspace: Option, + pub scale: f64, + pub transform: &'static str, +} + +struct Entry { + connected: Vec, + focused: Option, + error: Option, + confirmation_pending: bool, + low_power: bool, + presentations: Vec, + unlocked: Arc, + locked: Option>, +} + +#[derive(Default)] +pub(crate) struct Cache { + entry: Option, +} + +impl Cache { + pub(crate) fn clear(&mut self) { + self.entry = None; + } + + pub(crate) fn get_or_update( + &mut self, + state: State<'_>, + presentations: impl Iterator + Clone, + locked: bool, + build: impl FnOnce() -> Value, + ) -> Arc { + let unchanged = self.entry.as_ref().is_some_and(|entry| { + entry.connected == state.connected + && entry.focused == state.focused + && entry.error.as_deref() == state.error + && entry.confirmation_pending == state.confirmation_pending + && entry.low_power == state.low_power + && presentations + .clone() + .eq(entry.presentations.iter().copied()) + }); + if !unchanged { + self.entry = Some(Entry { + connected: state.connected.to_vec(), + focused: state.focused, + error: state.error.map(str::to_owned), + confirmation_pending: state.confirmation_pending, + low_power: state.low_power, + presentations: presentations.collect(), + unlocked: Arc::new(build()), + locked: None, + }); + } + let entry = self.entry.as_mut().expect("output snapshot was populated"); + if locked { + entry + .locked + .get_or_insert_with(|| { + let mut value = entry.unlocked.as_ref().clone(); + redact(&mut value); + Arc::new(value) + }) + .clone() + } else { + entry.unlocked.clone() + } + } +} + +pub(super) fn redact(value: &mut Value) { + for output in value.as_array_mut().into_iter().flatten() { + if let Some(fields) = output.as_object_mut() { + fields.remove("workspace"); + fields.remove("focused"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backends::direct::ConnectedModeInfo; + use crate::config::OutputTransform; + use serde_json::json; + + fn connector() -> ConnectedOutputInfo { + let mode = ConnectedModeInfo { + width: 3840, + height: 2160, + refresh_millihertz: 60_000, + preferred: true, + }; + ConnectedOutputInfo { + connector: "HDMI-A-1".into(), + connected: true, + identity: "test-monitor".into(), + internal: false, + requested_enabled: true, + mirror_source: None, + enabled: true, + profile: Some("desktop".into()), + requested_profile: None, + auto_refresh: true, + physical_size: Some((600, 340)), + current_mode: Some(mode), + available_modes: vec![mode], + scale: 1.75, + transform: OutputTransform::Normal, + configured_position: Some([0, 0]), + } + } + + fn state(connected: &[ConnectedOutputInfo]) -> State<'_> { + State { + connected, + focused: Some(OutputId(1)), + error: None, + confirmation_pending: false, + low_power: false, + } + } + + fn presentation() -> Presentation { + Presentation { + id: Some(OutputId(1)), + geometry: Some(Rectangle::new((0, 0).into(), (2195, 1235).into())), + workspace: Some(WorkspaceId(3)), + scale: 1.75, + transform: "normal", + } + } + + fn value() -> Value { + json!([{"id": 1, "workspace": 3, "focused": true, "scale": 1.75}]) + } + + #[test] + fn unchanged_outputs_reuse_storage_without_serializing_and_release_when_cleared() { + let connected = [connector()]; + let mut cache = Cache::default(); + let first = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + value, + ); + for _ in 0..1000 { + let next = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + || panic!("unchanged outputs must not rebuild JSON"), + ); + assert!(Arc::ptr_eq(&first, &next)); + } + cache.clear(); + let next = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + value, + ); + assert!(!Arc::ptr_eq(&first, &next)); + assert_eq!(first, next); + } + + #[test] + fn lock_redaction_is_cached_without_mutating_public_or_older_snapshots() { + let connected = [connector()]; + let mut cache = Cache::default(); + let public = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + value, + ); + let locked = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + true, + || panic!("lock redaction must reuse connector data"), + ); + assert_eq!(*locked, json!([{"id": 1, "scale": 1.75}])); + assert_eq!(*public, value()); + let again = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + true, + || panic!("repeated locked snapshots must not rebuild"), + ); + assert!(Arc::ptr_eq(&locked, &again)); + let unlocked = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + || panic!("unlock must reuse public data"), + ); + assert!(Arc::ptr_eq(&public, &unlocked)); + let changed = cache.get_or_update(state(&[]), [].into_iter(), true, || json!([])); + assert_eq!(*changed, json!([])); + assert_eq!(*locked, json!([{"id": 1, "scale": 1.75}])); + } + + #[test] + fn connector_inventory_and_every_reported_property_invalidate_the_snapshot() { + let changes: &[fn(&mut ConnectedOutputInfo)] = &[ + |info| info.connector.push('2'), + |info| info.connected = false, + |info| info.identity.push('2'), + |info| info.internal = true, + |info| info.requested_enabled = false, + |info| info.mirror_source = Some("DP-1".into()), + |info| info.enabled = false, + |info| info.profile = None, + |info| info.requested_profile = Some("mobile".into()), + |info| info.auto_refresh = false, + |info| info.physical_size = None, + |info| info.current_mode = None, + |info| info.available_modes[0].width = 1920, + |info| info.available_modes[0].height = 1080, + |info| info.available_modes[0].refresh_millihertz = 120_000, + |info| info.available_modes[0].preferred = false, + |info| info.available_modes.clear(), + |info| info.scale = 1.5, + |info| info.transform = OutputTransform::Rotate90, + |info| info.configured_position = None, + ]; + for (index, change) in changes.iter().enumerate() { + let mut cache = Cache::default(); + let mut connected = [connector()]; + let first = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + value, + ); + change(&mut connected[0]); + let next = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + || json!({"changed": index}), + ); + assert_eq!(*next, json!({"changed": index})); + assert!(!Arc::ptr_eq(&first, &next)); + } + let mut cache = Cache::default(); + let first = cache.get_or_update( + state(&[connector()]), + [presentation()].into_iter(), + false, + value, + ); + let next = cache.get_or_update(state(&[]), [].into_iter(), false, || json!([])); + assert_eq!(*next, json!([])); + assert_eq!(*first, value()); + } + + #[test] + fn focus_workspace_geometry_and_output_policy_changes_refresh_the_snapshot() { + let connected = [connector()]; + for index in 0..10 { + let mut cache = Cache::default(); + let first = cache.get_or_update( + state(&connected), + [presentation()].into_iter(), + false, + value, + ); + let mut state = state(&connected); + let mut presentation = presentation(); + match index { + 0 => state.focused = None, + 1 => state.error = Some("output rejected"), + 2 => state.confirmation_pending = true, + 3 => state.low_power = true, + 4 => presentation.id = None, + 5 => presentation.geometry = None, + 6 => presentation.geometry.as_mut().unwrap().loc.x += 100, + 7 => presentation.workspace = Some(WorkspaceId(4)), + 8 => presentation.scale = 1.25, + 9 => presentation.transform = "rotate_90", + _ => unreachable!(), + } + let next = cache.get_or_update( + state, + [presentation].into_iter(), + false, + || json!({"changed": index}), + ); + assert_eq!(*next, json!({"changed": index})); + assert!(!Arc::ptr_eq(&first, &next)); + } + } +} diff --git a/compositor/src/ipc_events.rs b/compositor/src/ipc_events.rs index d0351f4..968b90f 100644 --- a/compositor/src/ipc_events.rs +++ b/compositor/src/ipc_events.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use std::sync::mpsc::SyncSender; @@ -18,6 +19,7 @@ pub(crate) struct Subscribers { previous: Option, generation: u64, pub(crate) config_revision: u64, + pub(crate) outputs: RefCell, } impl Subscribers { @@ -43,6 +45,7 @@ impl Subscribers { self.subscribers.remove(&owner); if self.subscribers.is_empty() { self.previous = None; + self.outputs.get_mut().clear(); } } @@ -121,6 +124,9 @@ impl Subscribers { delivered }); self.previous = self.active().then_some(snapshot); + if !self.active() { + self.outputs.get_mut().clear(); + } } } @@ -246,7 +252,7 @@ mod tests { fn snapshot() -> Snapshot { Snapshot { - outputs: json!([]), + outputs: json!([]).into(), workspaces: json!([]), windows: Vec::new(), focus: json!(null), @@ -272,7 +278,7 @@ mod tests { )); hub.publish(state.clone()); assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); - state.outputs = json!([{"id": 2}]); + state.outputs = json!([{"id": 2}]).into(); state.workspaces = json!([{"output": 2}]); state.focus = json!({"output": 2}); hub.publish(state); @@ -356,7 +362,7 @@ mod tests { let mut state = snapshot(); hub.publish(state.clone()); receive.try_recv().unwrap(); - state.outputs = json!([{"id": 1}]); + state.outputs = json!([{"id": 1}]).into(); state.workspaces = json!([{"output": 1}]); state.windows = vec![ferese_ipc::events::Window { id: 7, diff --git a/crates/ferese-ipc/src/events.rs b/crates/ferese-ipc/src/events.rs index ec4526d..819de53 100644 --- a/crates/ferese-ipc/src/events.rs +++ b/crates/ferese-ipc/src/events.rs @@ -33,7 +33,7 @@ pub struct Window { /// A coherent logical desktop view. Desktop objects are redacted while locked. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Snapshot { - pub outputs: Value, + pub outputs: Arc, pub workspaces: Value, pub windows: Vec, pub focus: Value, @@ -80,9 +80,9 @@ impl Snapshot { #[must_use] pub fn changes_since(&self, previous: &Self) -> Vec { let mut changes = Vec::new(); - if self.outputs != previous.outputs { + if !Arc::ptr_eq(&self.outputs, &previous.outputs) && self.outputs != previous.outputs { changes.push(Change::OutputsChanged { - outputs: self.outputs.clone(), + outputs: self.outputs.as_ref().clone(), }); } if self.workspaces != previous.workspaces { @@ -123,6 +123,36 @@ impl Snapshot { mod tests { use super::*; + #[test] + fn shared_outputs_preserve_wire_fields_and_detect_value_changes() { + let outputs = serde_json::json!([{ + "id": 1, "connector": "HDMI-A-1", "scale": 1.75, + "available_modes": [{"width": 3840, "height": 2160, "preferred": true}], + "configuration_error": null + }]); + let wire = serde_json::json!({ + "outputs": outputs, "workspaces": [], "windows": [], "focus": null, + "config": {"revision": 0, "error": null}, "theme": null, + "lock": {"phase": "unlocked", "sleeping": false} + }); + let snapshot: Snapshot = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(&snapshot).unwrap(), wire); + let mut next = snapshot.clone(); + assert!(Arc::ptr_eq(&snapshot.outputs, &next.outputs)); + assert!(next.changes_since(&snapshot).is_empty()); + next.outputs = Arc::new(outputs.clone()); + assert!(next.changes_since(&snapshot).is_empty()); + next.outputs = serde_json::json!([]).into(); + let changes = next.changes_since(&snapshot); + assert_eq!( + serde_json::to_value(&changes).unwrap(), + serde_json::json!([ + {"type": "outputs_changed", "outputs": []} + ]) + ); + assert_eq!(*snapshot.outputs, outputs); + } + #[test] fn typed_windows_preserve_snapshot_and_replacement_wire_fields() { let windows = serde_json::json!([{ diff --git a/portal/src/permissions.rs b/portal/src/permissions.rs index a09ccc6..253fac0 100644 --- a/portal/src/permissions.rs +++ b/portal/src/permissions.rs @@ -527,7 +527,7 @@ mod tests { last: true, change: Change::Snapshot { desktop: Box::new(ferese_ipc::events::Snapshot { - outputs: serde_json::json!([]), + outputs: serde_json::json!([]).into(), workspaces: serde_json::json!([]), windows: vec![window(1, "one.desktop", true, 1)], focus: serde_json::json!({"window":1}),