Fix review findings across compositor and shell
This commit is contained in:
1 parent
5d9673aad3
commit
f7e5efb245
30 files changed
+1222
-181
No files matched your search
Generated
+1
@@ -1934,6 +1934,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"url",
|
||||
"wayland-client",
|
||||
"wayland-server",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ pub(crate) struct Engine {
|
||||
cached_snapshot: Option<Arc<Snapshot>>,
|
||||
publisher: Option<publication::Publisher>,
|
||||
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
|
||||
locked: bool,
|
||||
rates: BTreeMap<Owner, (u64, u16)>,
|
||||
windows: BTreeMap<Id, u64>,
|
||||
results: BTreeMap<Id, ferese_config::activities::ResultText>,
|
||||
@@ -55,6 +56,7 @@ impl Default for Engine {
|
||||
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
|
||||
.ok(),
|
||||
watchers: BTreeMap::new(),
|
||||
locked: false,
|
||||
rates: BTreeMap::new(),
|
||||
results: BTreeMap::new(),
|
||||
windows: BTreeMap::new(),
|
||||
@@ -159,7 +161,20 @@ impl Engine {
|
||||
pub fn remove_connection(&mut self, owner: u64) {
|
||||
self.watchers.remove(&owner);
|
||||
}
|
||||
pub fn set_locked(&mut self, locked: bool) {
|
||||
self.locked = locked;
|
||||
if locked {
|
||||
if let Some(publisher) = &self.publisher {
|
||||
publisher.revoke();
|
||||
}
|
||||
publication::locked(std::mem::take(&mut self.watchers).into_values().collect());
|
||||
}
|
||||
}
|
||||
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
|
||||
if self.locked {
|
||||
publication::locked(recipients);
|
||||
return;
|
||||
}
|
||||
if recipients.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +189,7 @@ impl Engine {
|
||||
self.deliver(vec![(request, sender)]);
|
||||
}
|
||||
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
|
||||
if since != self.revision {
|
||||
if self.locked || since != self.revision {
|
||||
self.get(request, sender);
|
||||
} else if self.watchers.len() < 32 {
|
||||
self.watchers.insert(connection, (request, sender));
|
||||
@@ -492,6 +507,32 @@ mod tests {
|
||||
template: Template::Progress,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn session_lock_cancels_pending_and_rejects_new_activity_reads() {
|
||||
let mut engine = Engine::default();
|
||||
let revision = engine.snapshot().revision;
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 42, revision, send);
|
||||
engine.set_locked(true);
|
||||
let reply = receive.recv().unwrap();
|
||||
assert_eq!(reply.id, 42);
|
||||
assert_eq!(reply.error.unwrap().code, "session_locked");
|
||||
assert!(reply.result.is_none());
|
||||
engine.changed();
|
||||
for since in [revision, engine.revision] {
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(2, 43, since, send);
|
||||
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
|
||||
}
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.get(44, send);
|
||||
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
|
||||
engine.set_locked(false);
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(3, 45, revision, send);
|
||||
assert!(receive.recv_timeout(Duration::from_secs(2)).unwrap().result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
|
||||
let mut engine = Engine::default();
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
//! Bounded serialization and delivery outside the compositor event loop.
|
||||
use ferese_ipc::{Response, activities::Snapshot};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
Arc, RwLock,
|
||||
mpsc::{self, SyncSender},
|
||||
};
|
||||
|
||||
type Recipient = (u64, SyncSender<Response>);
|
||||
struct Job {
|
||||
epoch: u64,
|
||||
snapshot: Arc<Snapshot>,
|
||||
recipients: Vec<Recipient>,
|
||||
}
|
||||
pub(super) struct Publisher(SyncSender<Job>);
|
||||
pub(super) struct Publisher(SyncSender<Job>, Arc<RwLock<u64>>);
|
||||
impl Publisher {
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
let (send, receive) = mpsc::sync_channel::<Job>(8);
|
||||
let epoch = Arc::new(RwLock::new(0));
|
||||
let worker_epoch = epoch.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-activity-snapshots".into())
|
||||
.spawn(move || {
|
||||
@@ -26,23 +29,47 @@ impl Publisher {
|
||||
cached = Some((job.snapshot.clone(), serde_json::json!(job.snapshot.as_ref())));
|
||||
}
|
||||
let value = &cached.as_ref().unwrap().1;
|
||||
for (id, sender) in job.recipients {
|
||||
// Client queues are bounded too. A slow/disconnected client
|
||||
// cannot block serialization or another subscriber.
|
||||
let _ = sender.try_send(Response::success(id, value.clone()));
|
||||
}
|
||||
// Lock acquisition revokes queued/in-flight serialization.
|
||||
// Hold the guard through delivery to order sends before revocation.
|
||||
deliver(job.epoch, &worker_epoch, job.recipients, value);
|
||||
}
|
||||
})?;
|
||||
Ok(Self(send))
|
||||
Ok(Self(send, epoch))
|
||||
}
|
||||
|
||||
pub fn revoke(&self) {
|
||||
let mut epoch = self.1.write().expect("activity publication epoch");
|
||||
*epoch = epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
|
||||
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
|
||||
let epoch = *self.1.read().expect("activity publication epoch");
|
||||
if let Err(error) = self.0.try_send(Job {
|
||||
epoch,
|
||||
snapshot,
|
||||
recipients,
|
||||
}) {
|
||||
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
|
||||
unavailable(job.recipients);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn deliver(epoch: u64, current: &RwLock<u64>, recipients: Vec<Recipient>, value: &serde_json::Value) {
|
||||
let current = current.read().expect("activity publication epoch");
|
||||
if epoch != *current {
|
||||
locked(recipients);
|
||||
return;
|
||||
}
|
||||
for (id, sender) in recipients {
|
||||
// A slow/disconnected client cannot block another subscriber.
|
||||
let _ = sender.try_send(Response::success(id, value.clone()));
|
||||
}
|
||||
}
|
||||
pub(super) fn locked(recipients: Vec<Recipient>) {
|
||||
for (id, sender) in recipients {
|
||||
let _ = sender.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||
}
|
||||
}
|
||||
pub(super) fn unavailable(recipients: Vec<Recipient>) {
|
||||
for (id, sender) in recipients {
|
||||
let _ = sender.try_send(Response::error(
|
||||
@@ -58,10 +85,29 @@ mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn lock_revokes_serialized_jobs_even_after_unlock() {
|
||||
let (send, _receive) = mpsc::sync_channel(1);
|
||||
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
|
||||
let job_epoch = *publisher.1.read().unwrap();
|
||||
publisher.revoke();
|
||||
let (reply, receive) = mpsc::sync_channel(1);
|
||||
deliver(
|
||||
job_epoch,
|
||||
&publisher.1,
|
||||
vec![(9, reply)],
|
||||
&serde_json::json!({"title": "private"}),
|
||||
);
|
||||
let response = receive.recv().unwrap();
|
||||
assert_eq!(response.id, 9);
|
||||
assert!(response.result.is_none());
|
||||
assert_eq!(response.error.unwrap().code, "session_locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
|
||||
let (send, _receive) = mpsc::sync_channel(0);
|
||||
let publisher = Publisher(send);
|
||||
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
|
||||
let (reply, receive) = mpsc::sync_channel(1);
|
||||
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
|
||||
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
|
||||
@@ -350,8 +350,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.topology
|
||||
.devices
|
||||
.insert(primary_path.clone(), topology::DeviceState::known(&primary_path));
|
||||
.observe_devices(&[(primary_path.clone(), topology::device_identity(&primary_path))]);
|
||||
lid::request_refresh(state, false);
|
||||
|
||||
event_loop
|
||||
|
||||
@@ -4,21 +4,21 @@ use std::path::PathBuf;
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Availability {
|
||||
Ready(DrmNode),
|
||||
Degraded(DrmNode),
|
||||
pub(super) enum Availability<Node = DrmNode> {
|
||||
Ready(Node),
|
||||
Degraded(Node),
|
||||
Missing,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(super) struct DeviceState {
|
||||
pub availability: Availability,
|
||||
pub(super) struct DeviceState<Node = DrmNode> {
|
||||
pub availability: Availability<Node>,
|
||||
identity: Option<PathBuf>,
|
||||
failures: u32,
|
||||
retry_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for DeviceState {
|
||||
impl<Node> Default for DeviceState<Node> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
availability: Availability::Missing,
|
||||
@@ -29,19 +29,12 @@ impl Default for DeviceState {
|
||||
}
|
||||
}
|
||||
|
||||
fn device_identity(path: &Path) -> Option<PathBuf> {
|
||||
pub(super) fn device_identity(path: &Path) -> Option<PathBuf> {
|
||||
// The parent hardware identity survives cardN renumbering after unplug.
|
||||
std::fs::canonicalize(Path::new("/sys/class/drm").join(path.file_name()?).join("device")).ok()
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
pub fn known(path: &Path) -> Self {
|
||||
Self {
|
||||
identity: device_identity(path),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Node> DeviceState<Node> {
|
||||
fn current_path<'a>(&self, original: &Path, observed: &'a [(PathBuf, Option<PathBuf>)]) -> Option<&'a Path> {
|
||||
observed
|
||||
.iter()
|
||||
@@ -58,7 +51,7 @@ impl DeviceState {
|
||||
self.retry_at = Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
|
||||
}
|
||||
|
||||
pub fn ready(&mut self, node: DrmNode) {
|
||||
pub fn ready(&mut self, node: Node) {
|
||||
self.availability = Availability::Ready(node);
|
||||
self.failures = 0;
|
||||
self.retry_at = None;
|
||||
@@ -69,12 +62,18 @@ impl DeviceState {
|
||||
self.failures = 0;
|
||||
self.retry_at = None;
|
||||
}
|
||||
|
||||
fn degraded(&mut self, node: Node, now: Instant) {
|
||||
self.failed(now);
|
||||
self.availability = Availability::Degraded(node);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Topology {
|
||||
pub devices: HashMap<PathBuf, DeviceState>,
|
||||
pub invalidated: HashSet<DrmNode>,
|
||||
pub(super) struct Topology<Node = DrmNode> {
|
||||
// Hardware paths are stable keys; use the card path only when sysfs identity
|
||||
// is unavailable. A reused card number must not replace another GPU's slot.
|
||||
pub devices: HashMap<PathBuf, DeviceState<Node>>,
|
||||
pub invalidated: HashSet<Node>,
|
||||
pub reconciling: bool,
|
||||
pub dirty: bool,
|
||||
pub layout_dirty: bool,
|
||||
@@ -84,7 +83,61 @@ pub(super) struct Topology {
|
||||
pub coalescer: crate::output_policy::Debouncer,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
impl<Node> Default for Topology<Node> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
devices: HashMap::new(),
|
||||
invalidated: HashSet::new(),
|
||||
reconciling: false,
|
||||
dirty: false,
|
||||
layout_dirty: false,
|
||||
remember_applied: false,
|
||||
retry_timer: None,
|
||||
settle_timer: None,
|
||||
coalescer: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node identity is opaque to reconciliation; tests use integers without opening
|
||||
// DRM devices or depending on which nodes happen to exist in the host's sysfs.
|
||||
impl<Node: Copy + Eq + std::hash::Hash> Topology<Node> {
|
||||
pub fn observe_devices(&mut self, observed: &[(PathBuf, Option<PathBuf>)]) {
|
||||
for (path, identity) in observed {
|
||||
let key = identity.as_ref().unwrap_or(path);
|
||||
let fallback = if identity.is_some() {
|
||||
self.devices.remove(path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.devices.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
// Preserve a slot first discovered before its sysfs identity was
|
||||
// available, including its live DRM resources and retry state.
|
||||
let mut slot = fallback.unwrap_or_default();
|
||||
slot.identity = identity.clone();
|
||||
self.devices.insert(key.clone(), slot);
|
||||
}
|
||||
}
|
||||
|
||||
fn retire_changed_devices(
|
||||
&mut self,
|
||||
observed: &[(PathBuf, Option<PathBuf>)],
|
||||
node_for_path: impl Fn(&Path) -> Option<Node>,
|
||||
) -> HashSet<Node> {
|
||||
let mut retired = HashSet::new();
|
||||
for (key, slot) in &mut self.devices {
|
||||
if let Availability::Ready(previous) | Availability::Degraded(previous) = slot.availability
|
||||
&& slot.current_path(key, observed).and_then(&node_for_path) != Some(previous)
|
||||
{
|
||||
retired.insert(previous);
|
||||
slot.missing();
|
||||
}
|
||||
}
|
||||
retired
|
||||
}
|
||||
|
||||
pub fn request(&mut self, active: bool) -> bool {
|
||||
self.dirty = true;
|
||||
active && !self.reconciling
|
||||
@@ -133,8 +186,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 mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
||||
let invalidated = std::mem::take(&mut backend.topology.invalidated);
|
||||
let mut invalidated = std::mem::take(&mut backend.topology.invalidated);
|
||||
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
||||
paths
|
||||
.into_iter()
|
||||
@@ -152,19 +204,16 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
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
|
||||
backend.topology.observe_devices(observed);
|
||||
// Retire every obsolete node before opening any replacement. Otherwise
|
||||
// a later missing slot could remove a new GPU that reused its dev_t.
|
||||
invalidated.extend(
|
||||
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());
|
||||
}
|
||||
}
|
||||
.retire_changed_devices(observed, |path| DrmNode::from_path(path).ok()),
|
||||
);
|
||||
}
|
||||
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
let mut ready = Vec::new();
|
||||
state.begin_desktop_transition();
|
||||
@@ -290,8 +339,8 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
}) {
|
||||
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());
|
||||
for (path, node) in &ready {
|
||||
slot_mut(state, path).degraded(*node, Instant::now());
|
||||
}
|
||||
} else {
|
||||
// Activate destinations before disabling former owners, including across GPUs.
|
||||
@@ -314,8 +363,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
entry.ready(*node);
|
||||
succeeded.push(*node);
|
||||
} else {
|
||||
entry.failed(Instant::now());
|
||||
entry.availability = Availability::Degraded(*node);
|
||||
entry.degraded(*node, Instant::now());
|
||||
// Device boundaries are not cross-GPU atomic. Compensate already
|
||||
// applied devices using their last actual hardware settings.
|
||||
if !known_good.outputs.is_empty() {
|
||||
@@ -482,9 +530,131 @@ pub(super) fn hotplug(state: &mut Ferese) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacement_gpu_at_the_same_card_path_gets_its_own_slot() {
|
||||
let card = PathBuf::from("/dev/dri/card0");
|
||||
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||
let node = 1;
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card.clone(), Some(a.clone()))]);
|
||||
topology.devices.get_mut(&a).unwrap().ready(node);
|
||||
// A policy failure keeps the device open, so it must retain ownership
|
||||
// of its node until replacement retirement runs.
|
||||
topology.devices.get_mut(&a).unwrap().degraded(node, Instant::now());
|
||||
let observed = [(card.clone(), Some(b.clone()))];
|
||||
topology.observe_devices(&observed);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&observed, |_| Some(node)),
|
||||
HashSet::from([node])
|
||||
);
|
||||
assert_eq!(topology.devices[&a].availability, Availability::Missing);
|
||||
assert_eq!(topology.devices[&a].current_path(&a, &observed), None);
|
||||
assert_eq!(topology.devices[&b].current_path(&b, &observed), Some(card.as_path()));
|
||||
topology.devices.get_mut(&b).unwrap().ready(node);
|
||||
assert!(topology.retire_changed_devices(&observed, |_| Some(node)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumbered_and_returning_gpus_keep_independent_slots() {
|
||||
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||
let card0 = PathBuf::from("/dev/dri/card0");
|
||||
let card2 = PathBuf::from("/dev/dri/card2");
|
||||
let node0 = 1;
|
||||
let node2 = 2;
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card0.clone(), Some(a.clone()))]);
|
||||
topology.devices.get_mut(&a).unwrap().ready(node0);
|
||||
let observed = [(card0.clone(), Some(b.clone())), (card2.clone(), Some(a.clone()))];
|
||||
for _ in 0..2 {
|
||||
topology.observe_devices(&observed);
|
||||
}
|
||||
assert_eq!(topology.devices.len(), 2);
|
||||
assert_eq!(topology.devices[&a].current_path(&a, &observed), Some(card2.as_path()));
|
||||
assert_eq!(topology.devices[&b].current_path(&b, &observed), Some(card0.as_path()));
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&observed, |path| Some(if path == card0 { node0 } else { node2 })),
|
||||
HashSet::from([node0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovering_sysfs_identity_preserves_the_existing_slot() {
|
||||
let card = PathBuf::from("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/gpu");
|
||||
let node = 1;
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card.clone(), None)]);
|
||||
topology.devices.get_mut(&card).unwrap().ready(node);
|
||||
topology.observe_devices(&[(card.clone(), Some(identity.clone()))]);
|
||||
assert_eq!(topology.devices.len(), 1);
|
||||
assert_eq!(topology.devices[&identity].availability, Availability::Ready(node));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_identity_loss_reconciles_through_the_card_path() {
|
||||
let card = PathBuf::from("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/gpu");
|
||||
let known = [(card.clone(), Some(identity.clone()))];
|
||||
let unknown = [(card.clone(), None)];
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&known);
|
||||
topology.devices.get_mut(&identity).unwrap().ready(1);
|
||||
|
||||
topology.observe_devices(&unknown);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&unknown, |_| Some(1)),
|
||||
HashSet::from([1])
|
||||
);
|
||||
assert_eq!(topology.devices[&identity].availability, Availability::Missing);
|
||||
assert_eq!(
|
||||
topology.devices[&card].current_path(&card, &unknown),
|
||||
Some(card.as_path())
|
||||
);
|
||||
// Reconciliation reopens the node using its card-path fallback.
|
||||
topology.devices.get_mut(&card).unwrap().ready(1);
|
||||
|
||||
topology.observe_devices(&known);
|
||||
assert_eq!(topology.devices.len(), 1);
|
||||
assert!(!topology.devices.contains_key(&card));
|
||||
assert_eq!(
|
||||
topology.devices[&identity].current_path(&identity, &known),
|
||||
Some(card.as_path())
|
||||
);
|
||||
// The reopened node is still valid and can be reused for this identity.
|
||||
assert!(topology.retire_changed_devices(&known, |_| Some(1)).is_empty());
|
||||
topology.devices.get_mut(&identity).unwrap().ready(1);
|
||||
assert!(topology.retire_changed_devices(&known, |_| Some(1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapping_device_numbers_retires_both_before_reopening_either() {
|
||||
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||
let card0 = PathBuf::from("/dev/dri/card0");
|
||||
let card1 = PathBuf::from("/dev/dri/card1");
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card0.clone(), Some(a.clone())), (card1.clone(), Some(b.clone()))]);
|
||||
topology.devices.get_mut(&a).unwrap().ready(0);
|
||||
topology.devices.get_mut(&b).unwrap().ready(1);
|
||||
let swapped = [(card0.clone(), Some(b.clone())), (card1.clone(), Some(a.clone()))];
|
||||
let node_for_path = |path: &Path| Some(u64::from(path == card1));
|
||||
topology.observe_devices(&swapped);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&swapped, node_for_path),
|
||||
HashSet::from([0, 1])
|
||||
);
|
||||
assert_eq!(topology.devices[&a].availability, Availability::Missing);
|
||||
assert_eq!(topology.devices[&b].availability, Availability::Missing);
|
||||
topology.devices.get_mut(&a).unwrap().ready(1);
|
||||
topology.devices.get_mut(&b).unwrap().ready(0);
|
||||
assert!(topology.retire_changed_devices(&swapped, node_for_path).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
|
||||
let mut topology = Topology::default();
|
||||
let mut topology = Topology::<u64>::default();
|
||||
assert!(!topology.request(false));
|
||||
assert!(!topology.request(false));
|
||||
assert!(topology.dirty);
|
||||
@@ -497,7 +667,7 @@ mod tests {
|
||||
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 {
|
||||
let mut slot = DeviceState::<u64> {
|
||||
identity: Some(identity.clone()),
|
||||
..DeviceState::default()
|
||||
};
|
||||
@@ -521,9 +691,9 @@ mod tests {
|
||||
#[test]
|
||||
fn unavailable_devices_retry_independently_with_capped_backoff() {
|
||||
let now = Instant::now();
|
||||
let mut topology = Topology::default();
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
|
||||
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
|
||||
let healthy = 1;
|
||||
let mut second = DeviceState::default();
|
||||
second.ready(healthy);
|
||||
topology.devices.insert("/dev/dri/card1".into(), second);
|
||||
|
||||
@@ -48,7 +48,12 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
|
||||
positive_animation_value(self.animations.speed, "speed")
|
||||
self.animations
|
||||
.validate_speed()
|
||||
.map_err(|_| ConfigError::AnimationValue {
|
||||
field: "speed",
|
||||
value: self.animations.speed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||
@@ -60,14 +65,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(ConfigError::AnimationValue { field, value })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn default_xkb_layout() -> String {
|
||||
"us".to_owned()
|
||||
}
|
||||
@@ -151,3 +148,16 @@ impl Default for TouchpadConfig {
|
||||
}
|
||||
|
||||
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compositor_speed_uses_shared_motion_bounds() {
|
||||
for speed in [1e-100, 0.009, 101.0, 1e100] {
|
||||
let config = Config::parse_source(&format!("animations {{ speed {speed:e}; }}")).unwrap();
|
||||
assert!(config.animation_speed().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,20 @@ pub(crate) struct Engine {
|
||||
snapshot: Snapshot,
|
||||
value: Value,
|
||||
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
|
||||
locked: bool,
|
||||
commands: Option<async_channel::Sender<Command>>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub(crate) fn set_locked(&mut self, locked: bool) {
|
||||
self.locked = locked;
|
||||
if locked {
|
||||
for (_, (id, response)) in self.waiters.drain() {
|
||||
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value(&self) -> Value {
|
||||
if self.value.is_null() {
|
||||
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
|
||||
@@ -75,7 +85,9 @@ impl Engine {
|
||||
}
|
||||
|
||||
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
||||
if since != self.snapshot.revision {
|
||||
if self.locked {
|
||||
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||
} else if since != self.snapshot.revision {
|
||||
let _ = response.try_send(Response::success(id, self.value()));
|
||||
} else {
|
||||
self.waiters.insert(owner, (id, response));
|
||||
|
||||
@@ -114,3 +114,28 @@ fn metadata_and_position_updates_do_not_recompute_idle_policy() {
|
||||
));
|
||||
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lock_cancels_pending_and_rejects_new_media_watches() {
|
||||
let mut engine = Engine::default();
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(1, 42, 0, send);
|
||||
engine.set_locked(true);
|
||||
let reply = receive.recv().unwrap();
|
||||
assert_eq!(reply.id, 42);
|
||||
assert!(reply.result.is_none());
|
||||
assert_eq!(reply.error.unwrap().code, "session_locked");
|
||||
engine.update(Snapshot {
|
||||
selected: Some(player("private", Playback::Playing).view),
|
||||
..Default::default()
|
||||
});
|
||||
for since in [0, engine.snapshot.revision] {
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(2, 43, since, send);
|
||||
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
|
||||
}
|
||||
engine.set_locked(false);
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(3, 44, 0, send);
|
||||
assert!(receive.recv().unwrap().result.is_some());
|
||||
}
|
||||
+12
-1
@@ -1308,7 +1308,7 @@ fn virtual_terminal(symbol: u32, ctrl: bool, alt: bool, direct: bool) -> Option<
|
||||
}
|
||||
|
||||
(ctrl && alt && (keysyms::KEY_F1..=keysyms::KEY_F12).contains(&symbol))
|
||||
.then_some((symbol - keysyms::KEY_F1 + 1) as i32)
|
||||
.then(|| (symbol - keysyms::KEY_F1 + 1) as i32)
|
||||
}
|
||||
|
||||
fn emergency_shortcut_escape(symbol: u32, ctrl: bool, alt: bool) -> bool {
|
||||
@@ -1394,6 +1394,17 @@ mod tests {
|
||||
assert_eq!(virtual_terminal(keysyms::KEY_F7, false, true, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_keys_never_evaluate_virtual_terminal_arithmetic() {
|
||||
for symbol in [0, keysyms::KEY_a, keysyms::KEY_Escape, keysyms::KEY_F12 + 1] {
|
||||
for ctrl in [false, true] {
|
||||
for alt in [false, true] {
|
||||
assert_eq!(virtual_terminal(symbol, ctrl, alt, true), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_virtual_terminal_keys_to_nested_clients() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -247,7 +247,13 @@ pub(crate) fn init(
|
||||
state.activities.get(call.request.id, call.response);
|
||||
}
|
||||
} else if call.request.command == "activity-watch" {
|
||||
if let Err(error) = validate_request(&call.request) {
|
||||
if state.session_lock.active() {
|
||||
let _ = call.response.try_send(Response::error(
|
||||
call.request.id,
|
||||
"session_locked",
|
||||
"IPC unavailable while session is locked",
|
||||
));
|
||||
} else if let Err(error) = validate_request(&call.request) {
|
||||
let _ = call
|
||||
.response
|
||||
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||
|
||||
@@ -274,6 +274,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallpaper_only_reload_applies_path_and_mode_changes() {
|
||||
if !crate::startup_tests::private_runtime("reload::tests::wallpaper_only_reload_applies_path_and_mode_changes")
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut events = calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let image_path = directory.path().join("wallpaper.png");
|
||||
image::RgbaImage::new(2, 2).save(&image_path).unwrap();
|
||||
for mode in ["fit", "fill"] {
|
||||
let source = format!("wallpaper {{ path \"{}\"; mode \"{mode}\"; }}", image_path.display());
|
||||
let (_, runtime, candidate) = crate::config::prepare(&source, directory.path()).unwrap();
|
||||
// Model a settled theme; changing only the wallpaper must still apply.
|
||||
state.theme_engine.live.theme = candidate.theme.clone();
|
||||
state.theme_engine.live.presented = candidate.theme;
|
||||
state.reload_config_source(source.clone()).unwrap();
|
||||
assert_eq!(state.wallpaper.configuration(), &runtime.wallpaper);
|
||||
let revision = state.ipc_events.config_revision;
|
||||
state.reload_config_source(source).unwrap();
|
||||
assert_eq!(state.ipc_events.config_revision, revision);
|
||||
}
|
||||
let candidate = crate::config::prepare("", directory.path()).unwrap().2;
|
||||
state.theme_engine.live.theme = candidate.theme.clone();
|
||||
state.theme_engine.live.presented = candidate.theme.clone();
|
||||
let wallpaper = crate::wallpaper::WallpaperConfig {
|
||||
path: None,
|
||||
..state.wallpaper.configuration().clone()
|
||||
};
|
||||
state.accept_theme(candidate, wallpaper.clone());
|
||||
assert_eq!(state.wallpaper.configuration(), &wallpaper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_keymap_reload_preserves_other_configuration() {
|
||||
if !crate::startup_tests::private_runtime("reload::tests::rejected_keymap_reload_preserves_other_configuration")
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut events = calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let before_xwayland = state.xwayland_config.enabled;
|
||||
let before_activities = state.activities.snapshot().revision;
|
||||
let before_layout = state.workspaces.active().layout.mode();
|
||||
let before_input = state.input_settings.clone();
|
||||
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
|
||||
runtime.xwayland.enabled = !before_xwayland;
|
||||
runtime.layout_mode = ferese_core::LayoutMode::Tree;
|
||||
// Exercise application failure after preparation, for example when an
|
||||
// XKB file disappears before the compositor installs the new keymap.
|
||||
runtime.input_settings.xkb_layout = "ferese_missing_keymap_for_reload_test".into();
|
||||
let error = state.apply_runtime_config(runtime, &serde_json::json!({})).unwrap_err();
|
||||
assert!(error.contains("keymap reload failed"), "{error}");
|
||||
assert_eq!(state.xwayland_config.enabled, before_xwayland);
|
||||
assert_eq!(state.activities.snapshot().revision, before_activities);
|
||||
assert_eq!(state.workspaces.active().layout.mode(), before_layout);
|
||||
assert_eq!(state.input_settings, before_input);
|
||||
|
||||
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
|
||||
runtime.xwayland.enabled = !before_xwayland;
|
||||
runtime.layout_mode = ferese_core::LayoutMode::Tree;
|
||||
state.apply_runtime_config(runtime, &serde_json::json!({})).unwrap();
|
||||
assert_eq!(state.xwayland_config.enabled, !before_xwayland);
|
||||
assert_eq!(state.workspaces.active().layout.mode(), ferese_core::LayoutMode::Tree);
|
||||
assert!(state.activities.snapshot().revision > before_activities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_reload_applies_once_and_rejects_invalid_configuration() {
|
||||
const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD";
|
||||
|
||||
@@ -400,7 +400,7 @@ pub(super) fn layer_elements(
|
||||
let Some(mode) = output.current_mode() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let output_crop = Rectangle::<i32, Physical>::from_size(mode.size);
|
||||
let output_crop = layer_output_crop(mode.size, output.current_transform());
|
||||
let layers = {
|
||||
let map = layer_map_for_output(output);
|
||||
|
||||
@@ -457,6 +457,10 @@ pub(super) fn layer_elements(
|
||||
elements
|
||||
}
|
||||
|
||||
fn layer_output_crop(size: Size<i32, Physical>, transform: Transform) -> Rectangle<i32, Physical> {
|
||||
Rectangle::from_size(transform.transform_size(size))
|
||||
}
|
||||
|
||||
pub(super) fn cursor_elements(
|
||||
state: &Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
@@ -527,3 +531,38 @@ pub(super) fn cursor_elements(
|
||||
CursorImageStatus::Hidden => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layer_crop_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_crop_keeps_bottom_edge_content_in_every_output_transform() {
|
||||
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||
|
||||
let mode = Size::<i32, Physical>::from((1920, 1080));
|
||||
for transform in [
|
||||
Transform::Normal,
|
||||
Transform::_90,
|
||||
Transform::_180,
|
||||
Transform::_270,
|
||||
Transform::Flipped,
|
||||
Transform::Flipped90,
|
||||
Transform::Flipped180,
|
||||
Transform::Flipped270,
|
||||
] {
|
||||
let size = transform.transform_size(mode);
|
||||
let geometry = Rectangle::new((0, size.h - 36).into(), (size.w, 36).into());
|
||||
let panel = SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
geometry,
|
||||
CommitCounter::default(),
|
||||
[1.0; 4],
|
||||
RenderElementKind::Unspecified,
|
||||
);
|
||||
let crop = CropRenderElement::from_element(panel, 1.0, layer_output_crop(mode, transform))
|
||||
.expect("bottom panel must remain visible");
|
||||
assert_eq!(crop.geometry(1.0.into()), geometry, "{transform:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,6 +434,8 @@ impl SessionLockHandler for Ferese {
|
||||
// Replacing an orphan never passes through Unlocked. Existing protected
|
||||
// presentation facts remain valid, but old surfaces must not own input.
|
||||
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
|
||||
self.activities.set_locked(true);
|
||||
self.media_engine.set_locked(true);
|
||||
self.set_binding_mode("default");
|
||||
self.capture_epoch = self.capture_epoch.wrapping_add(1);
|
||||
self.session_lock.surfaces.clear();
|
||||
@@ -484,6 +486,8 @@ impl SessionLockHandler for Ferese {
|
||||
}
|
||||
let sleeping = self.session_lock.sleeping;
|
||||
self.session_lock = Lock::default();
|
||||
self.activities.set_locked(false);
|
||||
self.media_engine.set_locked(false);
|
||||
self.refresh_idle_inhibition();
|
||||
if sleeping {
|
||||
crate::backends::direct::wake_locked_outputs(self);
|
||||
|
||||
@@ -369,6 +369,30 @@ fn locking_exits_binding_modes_and_retains_consumed_release_ownership() {
|
||||
assert_eq!(fixture.state.binding_modes.active(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquiring_lock_revokes_activity_and_media_subscriptions() {
|
||||
let Some(mut fixture) =
|
||||
Fixture::new("session_lock::tests::acquiring_lock_revokes_activity_and_media_subscriptions")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
fixture.output("pending");
|
||||
let revision = fixture.state.activities.snapshot().revision;
|
||||
let (activities, activity_reply) = std::sync::mpsc::sync_channel(1);
|
||||
let (media, media_reply) = std::sync::mpsc::sync_channel(1);
|
||||
fixture.state.activities.watch(1, 11, revision, activities);
|
||||
let media_revision = fixture.state.media_engine.value()["revision"].as_u64().unwrap();
|
||||
fixture.state.media_engine.watch(2, 22, media_revision, media);
|
||||
let _locker = fixture.acquire();
|
||||
assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_)));
|
||||
for (id, receiver) in [(11, activity_reply), (22, media_reply)] {
|
||||
let response = receiver.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(response.id, id);
|
||||
assert!(response.result.is_none());
|
||||
assert_eq!(response.error.unwrap().code, "session_locked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_confirms_without_outputs() {
|
||||
let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else {
|
||||
|
||||
+45
-34
@@ -878,15 +878,7 @@ impl Ferese {
|
||||
.as_ref()
|
||||
.is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key)))
|
||||
};
|
||||
// Desired configuration: reloads update it while endpoints stay
|
||||
// session-scoped, so a difference from the running service is reported
|
||||
// as `restart_required` rather than applied behind the user's back.
|
||||
self.xwayland_config = config.xwayland.clone();
|
||||
if changed(&["activities"]) {
|
||||
self.activities
|
||||
.configure(&config.activity_settings, config.activity_matcher.clone());
|
||||
}
|
||||
|
||||
let activities_changed = changed(&["activities"]);
|
||||
let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
|
||||
let bindings_changed = changed(&["bindings", "binding_modes", "commands"]);
|
||||
let input_changed = changed(&["input"]);
|
||||
@@ -901,6 +893,39 @@ impl Ferese {
|
||||
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
||||
}
|
||||
|
||||
// Stage layout changes before replacing the keymap: both can fail.
|
||||
// Everything below the successful keymap replacement must be infallible.
|
||||
let workspaces = if layout_changed {
|
||||
let mut workspaces = self.workspaces.clone();
|
||||
let bounds = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|workspace| {
|
||||
let output = self.output_workspaces.output_for_workspace(workspace.id);
|
||||
let rect = self
|
||||
.output_ids
|
||||
.iter()
|
||||
.find(|(_, id)| Some(**id) == output)
|
||||
.and_then(|(output, _)| self.output_bounds_for(output))
|
||||
.or_else(|| self.output_bounds())
|
||||
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
|
||||
(workspace.id, rect)
|
||||
})
|
||||
.collect();
|
||||
|
||||
workspaces
|
||||
.reconfigure_live(
|
||||
config.layout_mode,
|
||||
config.default_column_width,
|
||||
config.scrolling_focus_strategy,
|
||||
&bounds,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Some(workspaces)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|
||||
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
||||
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
||||
@@ -940,32 +965,18 @@ impl Ferese {
|
||||
|
||||
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
||||
let outputs_changed = self.output_profiles != config.output_profiles;
|
||||
if layout_changed {
|
||||
let bounds = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|workspace| {
|
||||
let output = self.output_workspaces.output_for_workspace(workspace.id);
|
||||
let rect = self
|
||||
.output_ids
|
||||
.iter()
|
||||
.find(|(_, id)| Some(**id) == output)
|
||||
.and_then(|(output, _)| self.output_bounds_for(output))
|
||||
.or_else(|| self.output_bounds())
|
||||
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
|
||||
(workspace.id, rect)
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.workspaces
|
||||
.reconfigure_live(
|
||||
config.layout_mode,
|
||||
config.default_column_width,
|
||||
config.scrolling_focus_strategy,
|
||||
&bounds,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some(workspaces) = workspaces {
|
||||
self.workspaces = workspaces;
|
||||
}
|
||||
// Desired configuration: reloads update it while endpoints stay
|
||||
// session-scoped, so a difference from the running service is reported
|
||||
// as `restart_required` rather than applied behind the user's back.
|
||||
self.xwayland_config = config.xwayland.clone();
|
||||
if activities_changed {
|
||||
self.activities
|
||||
.configure(&config.activity_settings, config.activity_matcher.clone());
|
||||
}
|
||||
|
||||
if layout_changed || self.gap_config != config.gap_config || panel_layout_changed {
|
||||
// Hidden layouts are resolved independently of presented geometry.
|
||||
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
|
||||
|
||||
@@ -425,7 +425,10 @@ impl Ferese {
|
||||
if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) {
|
||||
tracing::warn!(%error, "cannot arm appearance schedule");
|
||||
}
|
||||
if candidate.theme == self.theme_engine.live.theme && self.theme_engine.pending.is_none() {
|
||||
if candidate.theme == self.theme_engine.live.theme
|
||||
&& self.theme_engine.pending.is_none()
|
||||
&& self.wallpaper.configuration() == &wallpaper
|
||||
{
|
||||
let changed = self.theme_engine.live.mode != candidate.policy.mode
|
||||
|| self.theme_engine.live.warnings != candidate.warnings
|
||||
|| self.theme_engine.live.error.is_some()
|
||||
|
||||
@@ -273,40 +273,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `payload` with `MSG_TRUNC`, which makes the kernel deliver a short
|
||||
/// datagram and set `MSG_TRUNC` on the receiver.
|
||||
fn send_truncated_from_child(path: &Path, payload: &[u8]) {
|
||||
// SAFETY: the child performs only sendmsg and _exit, both
|
||||
// async-signal-safe, on freshly created descriptors.
|
||||
unsafe {
|
||||
let pid = libc::fork();
|
||||
assert!(pid >= 0, "fork must succeed");
|
||||
if pid == 0 {
|
||||
let fd = connect_datagram(path);
|
||||
if fd < 0 {
|
||||
libc::_exit(70);
|
||||
}
|
||||
let mut iov = libc::iovec {
|
||||
iov_base: payload.as_ptr().cast_mut().cast(),
|
||||
iov_len: payload.len(),
|
||||
};
|
||||
let mut message: libc::msghdr = std::mem::zeroed();
|
||||
message.msg_iov = std::ptr::from_mut(&mut iov);
|
||||
message.msg_iovlen = 1;
|
||||
// The kernel truncates the payload and reports MSG_TRUNC.
|
||||
message.msg_flags = libc::MSG_TRUNC;
|
||||
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
|
||||
libc::_exit(if sent >= 0 { 0 } else { 71 });
|
||||
}
|
||||
let mut status = 0;
|
||||
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
|
||||
assert!(
|
||||
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
|
||||
"child must send a truncated datagram"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `READY=1` from a child that also passes a descriptor.
|
||||
///
|
||||
/// The child stays alive so the receiver can name its PID, and the extra
|
||||
@@ -377,13 +343,23 @@ mod tests {
|
||||
fn a_truncated_notification_is_rejected_even_with_valid_credentials() {
|
||||
let runtime = private_runtime_dir("truncated");
|
||||
let socket = ReadinessSocket::create(&runtime, 21).unwrap();
|
||||
let path = socket.path().to_owned();
|
||||
|
||||
send_truncated_from_child(&path, b"READY=1");
|
||||
// Any PID is fine here: the point is the flag, not the credential.
|
||||
let sender = UnixDatagram::unbound().unwrap();
|
||||
sender.connect(socket.path()).unwrap();
|
||||
let mut payload = b"READY=1\nSTATUS=".to_vec();
|
||||
payload.resize(1024, b'x');
|
||||
// Both the whole message and its received prefix would otherwise pass
|
||||
// the parser. The kernel truncates it to the 512-byte receive buffer.
|
||||
assert_eq!(exact_line(&payload, b"READY=1"), Some(true));
|
||||
assert_eq!(exact_line(&payload[..512], b"READY=1"), Some(true));
|
||||
sender.send(&payload).unwrap();
|
||||
assert!(
|
||||
!recv_within(&socket, u32::MAX),
|
||||
"MSG_TRUNC must never satisfy readiness"
|
||||
!recv_within(&socket, std::process::id()),
|
||||
"an oversized message from the expected PID must be rejected"
|
||||
);
|
||||
sender.send(b"READY=1").unwrap();
|
||||
assert!(
|
||||
recv_within(&socket, std::process::id()),
|
||||
"valid credentials must still be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,21 @@ impl MotionSettings {
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<(), String> {
|
||||
if !self.speed.is_finite() || self.speed <= 0.0 {
|
||||
return Err("animation speed must be finite and positive".into());
|
||||
}
|
||||
self.validate_speed()?;
|
||||
self.spring_config()?;
|
||||
self.viewport_config()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bound both duration division and elapsed-time multiplication.
|
||||
pub fn validate_speed(self) -> Result<f64, String> {
|
||||
if self.speed.is_finite() && (0.01..=100.0).contains(&self.speed) {
|
||||
Ok(self.speed)
|
||||
} else {
|
||||
Err("animation speed must be between 0.01 and 100 (inclusive)".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spring_config(self) -> Result<SpringConfig, String> {
|
||||
self.spring.resolve(240.0)
|
||||
}
|
||||
@@ -108,6 +115,28 @@ impl SpringSettings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn speed_validation_rejects_extremes_and_accepts_safe_boundaries() {
|
||||
for speed in [f64::NAN, f64::INFINITY, 0.0, -1.0, 1e-100, 1e100, 0.009, 101.0] {
|
||||
let settings = MotionSettings {
|
||||
speed,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(settings.validate_speed().is_err());
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
for speed in [0.01, 0.5, 1.0, 100.0] {
|
||||
let settings = MotionSettings {
|
||||
speed,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(settings.validate_speed().unwrap(), speed);
|
||||
assert!(settings.validate().is_ok());
|
||||
assert!(!settings.duration(120.0).is_zero());
|
||||
assert!(!std::time::Duration::from_secs(60).mul_f64(speed).is_zero());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() {
|
||||
let settings = MotionSettings::default();
|
||||
|
||||
@@ -10,7 +10,7 @@ use ferese_layout::{
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct WorkspaceId(pub u64);
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Workspace {
|
||||
pub id: WorkspaceId,
|
||||
pub layout: WorkspaceLayout,
|
||||
@@ -382,7 +382,7 @@ impl From<LayoutError> for WorkspaceError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceSet {
|
||||
active: WorkspaceId,
|
||||
workspaces: HashMap<WorkspaceId, Workspace>,
|
||||
|
||||
@@ -68,7 +68,7 @@ The main spring responds more quickly than the viewport spring, allowing a windo
|
||||
| Focus emphasis response | 0.8 × main spring response time |
|
||||
| Shadow response | 1.2 × main spring response time |
|
||||
|
||||
Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Duration and speed must be finite and positive, and the resolved stiffness and damping must remain finite and positive too. If the configuration fails these checks, Ferese keeps the last accepted settings.
|
||||
Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Speed must be between 0.01 and 100, inclusive. Duration and the resolved stiffness and damping must be finite and positive. If the configuration fails these checks, Ferese keeps the last accepted settings.
|
||||
|
||||
The [Morph panel](panel-configuration.md#morph) uses the same spring solver,
|
||||
with its own response and the shared speed and reduced-motion settings. Content
|
||||
|
||||
+28
-2
@@ -20,7 +20,7 @@ fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Res
|
||||
let mut fields = fields.iter().collect::<Vec<_>>();
|
||||
fields.sort_unstable_by_key(|(key, _)| *key);
|
||||
for (key, value) in fields {
|
||||
write!(writer, "{:indent$}{key}:", "")?;
|
||||
write!(writer, "{:indent$}{}:", "", terminal_text(key))?;
|
||||
if value.is_object() || value.is_array() {
|
||||
writeln!(writer)?;
|
||||
write_plain(writer, value, indent + 2)?;
|
||||
@@ -45,11 +45,23 @@ fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Res
|
||||
fn scalar(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "none".into(),
|
||||
Value::String(text) => text.clone(),
|
||||
Value::String(text) => terminal_text(text),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_text(text: &str) -> String {
|
||||
let mut escaped = String::with_capacity(text.len());
|
||||
for character in text.chars() {
|
||||
if character.is_control() {
|
||||
escaped.extend(character.escape_default());
|
||||
} else {
|
||||
escaped.push(character);
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
/// Return false when a downstream consumer closes stdout.
|
||||
pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -> Result<bool, Box<dyn Error>> {
|
||||
let line = serde_json::to_vec(value)?;
|
||||
@@ -68,6 +80,20 @@ pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn plain_output_escapes_terminal_controls_in_keys_and_values() {
|
||||
let mut bytes = Vec::new();
|
||||
write_plain(
|
||||
&mut bytes,
|
||||
&serde_json::json!({"title\nspoof": "Terminal\u{1b}[2J\r\n\t\u{7}\u{9b} · 日本語"}),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(bytes).unwrap(),
|
||||
"title\\nspoof: Terminal\\u{1b}[2J\\r\\n\\t\\u{7}\\u{9b} · 日本語\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn plain_output_has_readable_fields_without_json_quotes() {
|
||||
let mut bytes = Vec::new();
|
||||
write_plain(
|
||||
|
||||
@@ -330,6 +330,7 @@ impl cosmic::Application for App {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::DragWindow => return self.core.drag(None),
|
||||
Message::ReadRenderScale(id) => {
|
||||
return cosmic::iced::window::scale_factor(id)
|
||||
.map(|scale| cosmic::Action::App(Message::RenderScale(scale)));
|
||||
@@ -1087,6 +1088,15 @@ mod tests {
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_drag_targets_the_main_window_and_handles_a_missing_window() {
|
||||
let mut app = app();
|
||||
app.core.set_main_window_id(None);
|
||||
assert_eq!(app.update(Message::DragWindow).units(), 0);
|
||||
app.core.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
|
||||
assert_eq!(app.update(Message::DragWindow).units(), 1);
|
||||
}
|
||||
|
||||
fn shortcuts(count: usize) -> App {
|
||||
let mut app = app();
|
||||
app.page = Page::Shortcuts;
|
||||
|
||||
@@ -33,3 +33,7 @@ image-codec = { package = "image", version = "0.25.10", default-features = false
|
||||
url = "2.5.8"
|
||||
base64 = "0.22.1"
|
||||
ureq = { version = "2.12.1", default-features = false, features = ["tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wayland-server.workspace = true
|
||||
ferese-protocols = { path = "../../crates/ferese-protocols", features = ["server"] }
|
||||
@@ -159,6 +159,7 @@ struct FereseShell {
|
||||
overview_active: bool,
|
||||
clock: String,
|
||||
outputs: Vec<OutputSurfaces>,
|
||||
effects_context: Option<std::rc::Rc<EffectsContext>>,
|
||||
notifications: notifications::Center,
|
||||
notification_surface: Option<notification_ui::NotificationSurface>,
|
||||
status_service: status::Service,
|
||||
@@ -348,6 +349,7 @@ impl cosmic::Application for FereseShell {
|
||||
overview_active: false,
|
||||
clock: clock::current_time(),
|
||||
outputs: Vec::new(),
|
||||
effects_context: None,
|
||||
};
|
||||
let wallpaper_task = match wallpaper {
|
||||
Some(receiver) => cosmic::task::future(async move {
|
||||
@@ -564,7 +566,7 @@ impl cosmic::Application for FereseShell {
|
||||
&& entry.id == id
|
||||
&& entry.effects.is_none()
|
||||
{
|
||||
match EffectsBinding::attach_role(&surface, None, 1.0) {
|
||||
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
Err(error) => eprintln!("ferese-shell: notification effects unavailable: {error}"),
|
||||
}
|
||||
@@ -573,7 +575,7 @@ impl cosmic::Application for FereseShell {
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.id == id
|
||||
{
|
||||
menu.prepare_surface(&surface);
|
||||
menu.prepare_surface(&mut self.effects_context, &surface);
|
||||
}
|
||||
}
|
||||
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
|
||||
@@ -779,7 +781,7 @@ impl cosmic::Application for FereseShell {
|
||||
count: self.notifications.unread(),
|
||||
dnd: value,
|
||||
});
|
||||
return Task::none();
|
||||
return self.sync_hub_activity();
|
||||
}
|
||||
status::Action::Notifications => {
|
||||
return Task::batch([self.close_menu(), self.toggle_notification_history()]);
|
||||
@@ -1194,6 +1196,7 @@ mod tests {
|
||||
bar_margin_horizontal: 0,
|
||||
hidden: false,
|
||||
}],
|
||||
effects_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1210,6 +1213,76 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnd_refreshes_activity_selection_without_waiting_for_another_event() {
|
||||
use cosmic::Application;
|
||||
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
|
||||
let mut shell = shell_with_measured_panel();
|
||||
shell.config.panels[0].style = ferese_panel::PanelStyle::Morph;
|
||||
shell.config.panels[0].center.groups[0]
|
||||
.items
|
||||
.push(ferese_panel::Item::new(
|
||||
"test-activities",
|
||||
ferese_panel::ItemKind::Activities,
|
||||
));
|
||||
shell.outputs[0]
|
||||
.runtime
|
||||
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
|
||||
shell.snapshot.outputs.push(OutputSnapshot {
|
||||
id: 1,
|
||||
name: "test".into(),
|
||||
active_workspace: 1,
|
||||
focused: true,
|
||||
});
|
||||
shell.notifications.ready = true;
|
||||
let mut store = Store::new(1);
|
||||
for n in 1..=2 {
|
||||
store
|
||||
.start(
|
||||
Owner { pid: n, birth: 1 },
|
||||
n.to_string(),
|
||||
Data {
|
||||
source: Source::Publisher,
|
||||
output: Some("test".into()),
|
||||
priority: 100,
|
||||
icon: "hammer".into(),
|
||||
title: format!("Task {n}"),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
shell.activities.snapshot = std::sync::Arc::new(ferese_ipc::activities::Snapshot {
|
||||
activities: store.entries().cloned().collect(),
|
||||
..Default::default()
|
||||
});
|
||||
shell.sync_activity_presentations();
|
||||
let selected = |shell: &FereseShell| {
|
||||
shell.outputs[0]
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.borrow()
|
||||
.activities
|
||||
.selection
|
||||
.visible
|
||||
.len()
|
||||
};
|
||||
assert_eq!(selected(&shell), 2);
|
||||
drop(shell.update(Message::Control(status::Action::Dnd(true))));
|
||||
assert_eq!(selected(&shell), 1);
|
||||
drop(shell.update(Message::Control(status::Action::Dnd(false))));
|
||||
assert_eq!(selected(&shell), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_preserves_the_last_presented_theme_without_a_control_connection() {
|
||||
use cosmic::Application;
|
||||
|
||||
@@ -375,6 +375,23 @@ impl<M> Motion<'_, M> {
|
||||
}
|
||||
|
||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
fn a11y_nodes(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
let mut nodes = self.content.as_widget().a11y_nodes(layout, tree, self.cursor(cursor));
|
||||
let offset = self.translation();
|
||||
for node in nodes.root_mut() {
|
||||
translate_accessible_node(node, offset);
|
||||
}
|
||||
for node in nodes.children_mut() {
|
||||
translate_accessible_node(node, offset);
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
@@ -479,6 +496,30 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
) {
|
||||
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &cosmic::Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
|
||||
let translation = translation + self.translation();
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_accessible_node(node: &mut iced_accessibility::A11yNode, offset: Vector) {
|
||||
if let Some(mut bounds) = node.node().bounds() {
|
||||
bounds.x0 += f64::from(offset.x);
|
||||
bounds.x1 += f64::from(offset.x);
|
||||
bounds.y0 += f64::from(offset.y);
|
||||
bounds.y1 += f64::from(offset.y);
|
||||
node.node_mut().set_bounds(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
struct CollectRegions(Vec<Rectangle>);
|
||||
@@ -527,6 +568,139 @@ impl widget::Operation for CollectRegions {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn motion_preserves_accessible_controls_and_translates_tooltip_overlays() {
|
||||
use super::*;
|
||||
use cosmic::iced::advanced::{clipboard, renderer::Headless};
|
||||
use cosmic::iced::{Font, Pixels, Point};
|
||||
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let viewport = Rectangle::with_size(Size::new(400., 300.));
|
||||
for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] {
|
||||
let make_content = || {
|
||||
// The toolkit tooltip itself does not expose an accessibility
|
||||
// tree. A sibling button verifies our wrapper's forwarding.
|
||||
cosmic::widget::row::with_capacity(2)
|
||||
.push(cosmic::widget::tooltip(
|
||||
cosmic::widget::Space::new().width(100).height(40),
|
||||
cosmic::widget::text("Tooltip"),
|
||||
cosmic::widget::tooltip::Position::Bottom,
|
||||
))
|
||||
.push(
|
||||
cosmic::widget::button::custom(cosmic::widget::text("Action"))
|
||||
.name("Accessible action")
|
||||
.on_press(())
|
||||
.width(100)
|
||||
.height(40),
|
||||
)
|
||||
};
|
||||
let mut plain: Element<'_, ()> = make_content().into();
|
||||
let mut plain_tree = widget::Tree::new(&plain);
|
||||
let plain_node = plain.as_widget_mut().layout(
|
||||
&mut plain_tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let plain_a11y =
|
||||
plain
|
||||
.as_widget()
|
||||
.a11y_nodes(Layout::new(&plain_node), &plain_tree, mouse::Cursor::Unavailable);
|
||||
assert!(!plain_a11y.root().is_empty());
|
||||
let mut wrapped = animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
|
||||
let mut tree = widget::Tree::new(&wrapped);
|
||||
let node =
|
||||
wrapped
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let offset = if edge == ferese_panel::Edge::Top { -2. } else { 2. };
|
||||
let accessible = wrapped
|
||||
.as_widget()
|
||||
.a11y_nodes(Layout::new(&node), &tree, mouse::Cursor::Unavailable);
|
||||
assert_eq!(accessible.root().len(), plain_a11y.root().len());
|
||||
for (actual, expected) in accessible
|
||||
.root()
|
||||
.iter()
|
||||
.chain(accessible.children())
|
||||
.zip(plain_a11y.root().iter().chain(plain_a11y.children()))
|
||||
{
|
||||
assert_eq!(actual.node().label(), expected.node().label());
|
||||
if let Some(bounds) = expected.node().bounds() {
|
||||
let actual = actual.node().bounds().unwrap();
|
||||
assert_eq!(actual.x0, bounds.x0);
|
||||
assert_eq!(actual.y0, bounds.y0 + offset);
|
||||
}
|
||||
}
|
||||
let cursor = mouse::Cursor::Available(Point::new(50., 20.));
|
||||
let event = Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: Point::new(50., 20.),
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
plain.as_widget_mut().update(
|
||||
&mut plain_tree,
|
||||
&event,
|
||||
Layout::new(&plain_node),
|
||||
cursor,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
wrapped.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
let mut plain_overlay = plain
|
||||
.as_widget_mut()
|
||||
.overlay(
|
||||
&mut plain_tree,
|
||||
Layout::new(&plain_node),
|
||||
&renderer,
|
||||
&viewport,
|
||||
Vector::new(40., 80.),
|
||||
)
|
||||
.expect("tooltip opens");
|
||||
let mut overlay = wrapped
|
||||
.as_widget_mut()
|
||||
.overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&viewport,
|
||||
Vector::new(40., 80.),
|
||||
)
|
||||
.expect("motion forwards tooltip");
|
||||
let plain_layout = plain_overlay.as_overlay_mut().layout(&renderer, viewport.size());
|
||||
let overlay_layout = overlay.as_overlay_mut().layout(&renderer, viewport.size());
|
||||
fn tooltip_bounds(node: &layout::Node) -> Rectangle {
|
||||
// Tooltip and row each return an overlay group; descend past
|
||||
// full-viewport group nodes to the positioned tooltip root.
|
||||
let mut node = node;
|
||||
while let [child] = node.children() {
|
||||
if node.bounds().position() != cosmic::iced::Point::ORIGIN {
|
||||
break;
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
node.bounds()
|
||||
}
|
||||
let plain_bounds = tooltip_bounds(&plain_layout);
|
||||
let bounds = tooltip_bounds(&overlay_layout);
|
||||
assert!(plain_bounds.y > 0.);
|
||||
assert_eq!(bounds.x, plain_bounds.x);
|
||||
assert_eq!(bounds.y, plain_bounds.y + offset as f32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_bounce_spring_finishes_popup_close() {
|
||||
let settings = super::Settings {
|
||||
|
||||
@@ -703,6 +703,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnd_immediately_removes_and_restores_a_persistent_bridged_notice() {
|
||||
use cosmic::Application;
|
||||
let mut app = crate::tests::shell_with_measured_panel();
|
||||
app.snapshot.outputs.push(crate::control::OutputSnapshot {
|
||||
id: 4,
|
||||
name: "test".into(),
|
||||
active_workspace: 1,
|
||||
focused: true,
|
||||
});
|
||||
let (center, _commands) = fixture();
|
||||
app.notifications = center;
|
||||
app.config.activities.sources.notification_bridge = true;
|
||||
app.notifications.configure_activity_bridge(true);
|
||||
let bar = app.outputs[0].bar;
|
||||
let mut persistent = notice(1);
|
||||
persistent.timeout = None;
|
||||
drop(app.update(crate::Message::NotificationEvent(Event::Notice(persistent))));
|
||||
assert!(app.activity_available(bar));
|
||||
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(true))));
|
||||
assert!(!app.activity_available(bar));
|
||||
assert!(app.notifications.entries[0].live, "DND must not dismiss the notice");
|
||||
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(false))));
|
||||
assert!(app.activity_available(bar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_creates_an_activity_and_removes_it_on_expiry_and_dismissal() {
|
||||
use cosmic::Application;
|
||||
|
||||
@@ -137,9 +137,13 @@ impl PopoverAnchor {
|
||||
}
|
||||
|
||||
impl OpenMenu {
|
||||
pub(super) fn prepare_surface(&mut self, surface: &wl_surface::WlSurface) {
|
||||
pub(super) fn prepare_surface(
|
||||
&mut self,
|
||||
context: &mut Option<std::rc::Rc<EffectsContext>>,
|
||||
surface: &wl_surface::WlSurface,
|
||||
) {
|
||||
if self.effects.is_none() {
|
||||
match EffectsBinding::attach_role(surface, None, 0.0) {
|
||||
match EffectsBinding::attach_role(context, surface, None, 0.0) {
|
||||
Ok(binding) => self.effects = Some(binding),
|
||||
Err(error) => eprintln!("ferese-shell: popover material unavailable: {error}"),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ use super::{
|
||||
wayland, window, wl_output, wl_registry, wl_surface,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
|
||||
let (anchor, top, bottom) = match edge {
|
||||
ferese_panel::Edge::Top => (Anchor::TOP, margin, 0),
|
||||
@@ -410,7 +413,7 @@ impl FereseShell {
|
||||
return self.sync_notification_surface();
|
||||
}
|
||||
wayland::PopupEvent::Configured { .. } => {
|
||||
menu.prepare_surface(&surface);
|
||||
menu.prepare_surface(&mut self.effects_context, &surface);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -438,7 +441,7 @@ impl FereseShell {
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.effects.is_none()
|
||||
{
|
||||
menu.prepare_surface(&surface);
|
||||
menu.prepare_surface(&mut self.effects_context, &surface);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -457,7 +460,7 @@ impl FereseShell {
|
||||
let entry = self.notification_surface.as_mut().unwrap();
|
||||
|
||||
if entry.effects.is_none() {
|
||||
match EffectsBinding::attach_role(&surface, None, 1.0) {
|
||||
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: notification effects unavailable: {error}")
|
||||
@@ -493,9 +496,9 @@ impl FereseShell {
|
||||
|
||||
let binding = if islands {
|
||||
// Wait for measured island bounds rather than briefly painting the full bar.
|
||||
EffectsBinding::attach_role(surface, None, 1.0)
|
||||
EffectsBinding::attach_role(&mut self.effects_context, surface, None, 1.0)
|
||||
} else {
|
||||
EffectsBinding::attach(surface, !hidden)
|
||||
EffectsBinding::attach(&mut self.effects_context, surface, !hidden)
|
||||
};
|
||||
|
||||
match binding {
|
||||
@@ -585,11 +588,28 @@ fn validate_region_counts(regions: usize, opacities: usize) -> Result<(), std::i
|
||||
|
||||
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
|
||||
|
||||
// Keep one registry on the shell connection: wl_registry has no destructor on
|
||||
// the protocol versions we support, so creating one per popup leaks objects.
|
||||
pub(super) struct EffectsContext {
|
||||
connection: Connection,
|
||||
manager: Option<FereseEffectsManagerV1>,
|
||||
_globals: wayland_client::globals::GlobalList,
|
||||
queue: EventQueue<EffectsState>,
|
||||
}
|
||||
|
||||
impl Drop for EffectsContext {
|
||||
fn drop(&mut self) {
|
||||
if let Some(manager) = &self.manager {
|
||||
manager.destroy();
|
||||
}
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct EffectsBinding {
|
||||
connection: Connection,
|
||||
_manager: FereseEffectsManagerV1,
|
||||
_context: std::rc::Rc<EffectsContext>,
|
||||
pub(super) surface: FereseSurfaceEffectsV1,
|
||||
_queue: EventQueue<EffectsState>,
|
||||
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
||||
opacity: std::cell::Cell<Option<u32>>,
|
||||
presentation: std::cell::RefCell<Option<EffectPresentation>>,
|
||||
@@ -597,8 +617,13 @@ pub(super) struct EffectsBinding {
|
||||
}
|
||||
|
||||
impl EffectsBinding {
|
||||
pub(super) fn attach(surface: &wl_surface::WlSurface, visible: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
pub(super) fn attach(
|
||||
context: &mut Option<std::rc::Rc<EffectsContext>>,
|
||||
surface: &wl_surface::WlSurface,
|
||||
visible: bool,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
Self::attach_role(
|
||||
context,
|
||||
surface,
|
||||
visible.then_some(ferese_surface_effects_v1::Role::Panel),
|
||||
if visible { 1.0 } else { 0.0 },
|
||||
@@ -606,6 +631,7 @@ impl EffectsBinding {
|
||||
}
|
||||
|
||||
pub(super) fn attach_role(
|
||||
context: &mut Option<std::rc::Rc<EffectsContext>>,
|
||||
surface: &wl_surface::WlSurface,
|
||||
role: Option<ferese_surface_effects_v1::Role>,
|
||||
opacity: f32,
|
||||
@@ -615,30 +641,50 @@ impl EffectsBinding {
|
||||
.upgrade()
|
||||
.ok_or("libcosmic Wayland connection is no longer alive")?;
|
||||
let connection = Connection::from_backend(backend);
|
||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||
let qh = queue.handle();
|
||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 6..=6, ())?;
|
||||
let effects = manager.get_surface_effects(surface, &qh, ());
|
||||
// Native handles can create distinct borrowed Backend wrappers around
|
||||
// the same wl_display. Backend equality compares wrappers, not displays.
|
||||
if context
|
||||
.as_ref()
|
||||
.is_none_or(|context| context.connection.backend().display_ptr() != connection.backend().display_ptr())
|
||||
{
|
||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||
// Cache the registry even when this compositor lacks our extension.
|
||||
let manager = globals
|
||||
.bind::<FereseEffectsManagerV1, _, _>(&queue.handle(), 6..=6, ())
|
||||
.ok();
|
||||
*context = Some(std::rc::Rc::new(EffectsContext {
|
||||
connection: connection.clone(),
|
||||
manager,
|
||||
_globals: globals,
|
||||
queue,
|
||||
}));
|
||||
}
|
||||
let context = context.as_ref().unwrap().clone();
|
||||
let effects = context
|
||||
.manager
|
||||
.as_ref()
|
||||
.ok_or("Ferese effects protocol unavailable")?
|
||||
.get_surface_effects(surface, &context.queue.handle(), ());
|
||||
|
||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
effects.set_opacity(opacity);
|
||||
if let Some(role) = role {
|
||||
effects.set_role(role);
|
||||
} else {
|
||||
effects.clear_role();
|
||||
}
|
||||
connection.flush()?;
|
||||
|
||||
Ok(Self {
|
||||
// Own the protocol object before any fallible operation, including flush.
|
||||
let binding = Self {
|
||||
connection,
|
||||
_manager: manager,
|
||||
_context: context,
|
||||
surface: effects,
|
||||
_queue: queue,
|
||||
regions: Default::default(),
|
||||
opacity: std::cell::Cell::new(Some(opacity)),
|
||||
presentation: Default::default(),
|
||||
panel_presentation: Default::default(),
|
||||
})
|
||||
};
|
||||
binding.surface.set_opacity(opacity);
|
||||
if let Some(role) = role {
|
||||
binding.surface.set_role(role);
|
||||
} else {
|
||||
binding.surface.clear_role();
|
||||
}
|
||||
binding.connection.flush()?;
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
pub(super) fn set_panel_presentation(
|
||||
@@ -744,6 +790,13 @@ impl EffectsBinding {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EffectsBinding {
|
||||
fn drop(&mut self) {
|
||||
self.surface.destroy();
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
}
|
||||
|
||||
struct EffectsState;
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use ferese_protocols::effects::v1::server::{
|
||||
ferese_effects_manager_v1 as manager, ferese_surface_effects_v1 as effects,
|
||||
};
|
||||
use wayland_server::protocol::{wl_compositor, wl_surface};
|
||||
use wayland_server::{Client, DataInit, Dispatch, Display, DisplayHandle, GlobalDispatch, New};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counts {
|
||||
managers: usize,
|
||||
manager_destroys: usize,
|
||||
surfaces: usize,
|
||||
effect_destroys: usize,
|
||||
}
|
||||
|
||||
struct Server(Arc<Mutex<Counts>>);
|
||||
|
||||
impl GlobalDispatch<wl_compositor::WlCompositor, ()> for Server {
|
||||
fn bind(
|
||||
_: &mut Self,
|
||||
_: &DisplayHandle,
|
||||
_: &Client,
|
||||
resource: New<wl_compositor::WlCompositor>,
|
||||
_: &(),
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
init.init(resource, ());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_compositor::WlCompositor, ()> for Server {
|
||||
fn request(
|
||||
_: &mut Self,
|
||||
_: &Client,
|
||||
_: &wl_compositor::WlCompositor,
|
||||
request: wl_compositor::Request,
|
||||
_: &(),
|
||||
_: &DisplayHandle,
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
if let wl_compositor::Request::CreateSurface { id } = request {
|
||||
init.init(id, ());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_surface::WlSurface, ()> for Server {
|
||||
fn request(
|
||||
_: &mut Self,
|
||||
_: &Client,
|
||||
_: &wl_surface::WlSurface,
|
||||
_: wl_surface::Request,
|
||||
_: &(),
|
||||
_: &DisplayHandle,
|
||||
_: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl GlobalDispatch<manager::FereseEffectsManagerV1, ()> for Server {
|
||||
fn bind(
|
||||
state: &mut Self,
|
||||
_: &DisplayHandle,
|
||||
_: &Client,
|
||||
resource: New<manager::FereseEffectsManagerV1>,
|
||||
_: &(),
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
state.0.lock().unwrap().managers += 1;
|
||||
init.init(resource, ());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<manager::FereseEffectsManagerV1, ()> for Server {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_: &Client,
|
||||
_: &manager::FereseEffectsManagerV1,
|
||||
request: manager::Request,
|
||||
_: &(),
|
||||
_: &DisplayHandle,
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
match request {
|
||||
manager::Request::GetSurfaceEffects { id, .. } => {
|
||||
state.0.lock().unwrap().surfaces += 1;
|
||||
init.init(id, ());
|
||||
}
|
||||
manager::Request::Destroy => state.0.lock().unwrap().manager_destroys += 1,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<effects::FereseSurfaceEffectsV1, ()> for Server {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_: &Client,
|
||||
_: &effects::FereseSurfaceEffectsV1,
|
||||
request: effects::Request,
|
||||
_: &(),
|
||||
_: &DisplayHandle,
|
||||
_: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
if matches!(request, effects::Request::Destroy) {
|
||||
state.0.lock().unwrap().effect_destroys += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wayland_client::delegate_noop!(super::EffectsState: ignore wayland_client::protocol::wl_compositor::WlCompositor);
|
||||
wayland_client::delegate_noop!(super::EffectsState: ignore wayland_client::protocol::wl_surface::WlSurface);
|
||||
|
||||
#[test]
|
||||
fn popup_effects_share_the_registry_and_destroy_every_protocol_object() {
|
||||
effects_lifecycle(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_effects_extension_reuses_the_registry_across_failed_attachments() {
|
||||
effects_lifecycle(false);
|
||||
}
|
||||
|
||||
fn effects_lifecycle(advertise: bool) {
|
||||
use wayland_client::protocol::wl_compositor::WlCompositor;
|
||||
let counts = Arc::new(Mutex::new(Counts::default()));
|
||||
let worker_counts = counts.clone();
|
||||
let (client, server) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let (stop, stopped) = mpsc::channel::<()>();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut display = Display::<Server>::new().unwrap();
|
||||
let mut handle = display.handle();
|
||||
handle.create_global::<Server, wl_compositor::WlCompositor, _>(1, ());
|
||||
if advertise {
|
||||
handle.create_global::<Server, manager::FereseEffectsManagerV1, _>(6, ());
|
||||
}
|
||||
handle.insert_client(server, Arc::new(())).unwrap();
|
||||
let mut state = Server(worker_counts);
|
||||
loop {
|
||||
display.dispatch_clients(&mut state).unwrap();
|
||||
display.flush_clients().unwrap();
|
||||
match stopped.recv_timeout(std::time::Duration::from_millis(1)) {
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let connection = wayland_client::Connection::from_socket(client).unwrap();
|
||||
let (globals, queue) = wayland_client::globals::registry_queue_init::<super::EffectsState>(&connection).unwrap();
|
||||
let compositor = globals.bind::<WlCompositor, _, _>(&queue.handle(), 1..=1, ()).unwrap();
|
||||
let mut context = None;
|
||||
let mut first = None;
|
||||
for _ in 0..20 {
|
||||
let surface = compositor.create_surface(&queue.handle(), ());
|
||||
use wayland_client::Proxy;
|
||||
// Match window::run: each native callback lends the same display through
|
||||
// a fresh borrowed backend. Neither wrapper owns the actual display.
|
||||
let borrowed = wayland_client::Connection::from_backend(unsafe {
|
||||
wayland_client::backend::Backend::from_foreign_display(connection.backend().display_ptr())
|
||||
});
|
||||
assert_ne!(borrowed, connection);
|
||||
let id = unsafe {
|
||||
wayland_client::backend::ObjectId::from_ptr(
|
||||
wayland_client::protocol::wl_surface::WlSurface::interface(),
|
||||
surface.id().as_ptr(),
|
||||
)
|
||||
}
|
||||
.unwrap();
|
||||
let borrowed_surface = wayland_client::protocol::wl_surface::WlSurface::from_id(&borrowed, id).unwrap();
|
||||
let binding = super::EffectsBinding::attach_role(&mut context, &borrowed_surface, None, 1.);
|
||||
assert_eq!(binding.is_ok(), advertise);
|
||||
if let Some(first) = &first {
|
||||
assert!(std::rc::Rc::ptr_eq(first, context.as_ref().unwrap()));
|
||||
} else {
|
||||
first = context.clone();
|
||||
}
|
||||
// Parent surface lifetime does not release the effects protocol object.
|
||||
surface.destroy();
|
||||
drop(binding);
|
||||
}
|
||||
connection.roundtrip().unwrap();
|
||||
{
|
||||
let counts = counts.lock().unwrap();
|
||||
assert_eq!(counts.managers, usize::from(advertise));
|
||||
assert_eq!(counts.surfaces, if advertise { 20 } else { 0 });
|
||||
assert_eq!(counts.effect_destroys, counts.surfaces);
|
||||
assert_eq!(counts.manager_destroys, 0);
|
||||
}
|
||||
drop(first);
|
||||
drop(context);
|
||||
connection.roundtrip().unwrap();
|
||||
assert_eq!(counts.lock().unwrap().manager_destroys, usize::from(advertise));
|
||||
drop(stop);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
@@ -290,7 +290,7 @@ impl FereseShell {
|
||||
return;
|
||||
};
|
||||
if entry.primary && entry.effects.is_none() {
|
||||
match EffectsBinding::attach_role(surface, None, 0.0) {
|
||||
match EffectsBinding::attach_role(&mut self.effects_context, surface, None, 0.0) {
|
||||
Ok(effects) => entry.effects = Some(effects),
|
||||
Err(error) => eprintln!("ferese-shell: system modal material unavailable: {error}"),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user