Add Live Activities as configurable bar items (#30)

* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-12 22:52:25 +01:00
1 parent 7a7df0106c
commit 6b0d903ad0
107 files changed
+9340 -291

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-activities = { path = "../crates/ferese-activities" }
ferese-panel = { path = "../crates/ferese-panel" }
ferese-shape.workspace = true
ferese-config = { path = "../crates/ferese-config" }
+771
View File
@@ -0,0 +1,771 @@
//! Session broker: verified process identities, one-shot policy deadlines and
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
mod publication;
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
use ferese_ipc::{
Response,
activities::{Publish, Snapshot},
};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub(crate) struct Rules {
pub enabled: bool,
pub matcher: ferese_config::activities::Matcher,
}
pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
pub(crate) struct Engine {
pub store: Store,
pub rules: SharedRules,
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
process_enabled: bool,
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
process_watcher: Option<crate::activities_process::Watcher>,
pub timer: Option<crate::ipc::DeadlineTimer<crate::Ferese>>,
revision: u64,
cached_snapshot: Option<Arc<Snapshot>>,
publisher: Option<publication::Publisher>,
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
rates: BTreeMap<Owner, (u64, u16)>,
windows: BTreeMap<Id, u64>,
results: BTreeMap<Id, ferese_config::activities::ResultText>,
}
impl Default for Engine {
fn default() -> Self {
let settings = ferese_config::activities::Settings::default();
Self {
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
rules: Arc::new(RwLock::new(Arc::new(Rules {
enabled: false,
matcher: ferese_config::activities::Matcher::new(&settings).expect("built-in rules"),
}))),
monitor: None,
process_enabled: false,
process_sender: None,
process_watcher: None,
timer: None,
revision: 1,
cached_snapshot: None,
publisher: publication::Publisher::new()
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
.ok(),
watchers: BTreeMap::new(),
rates: BTreeMap::new(),
results: BTreeMap::new(),
windows: BTreeMap::new(),
}
}
}
impl Engine {
pub fn detect_process(
&mut self,
owner: Owner,
matched: ferese_config::activities::Matched,
output: Option<String>,
fd: &std::os::fd::OwnedFd,
) {
if crate::ipc::activity_owner::exited(fd) {
return;
}
// A dispatched detection is not an admission acknowledgement. Retry
// starts after rejection, and refresh existing records without restarting
// their eligibility delay or recency boost.
if self.resolve(owner, "process", None).is_ok() {
let _ = self.publish(
owner,
Publish::Update {
local: "process".into(),
handle: None,
value: None,
lease_ms: Some(5_000),
},
None,
None,
);
} else {
let _ = self.publish(
owner,
Publish::Hook {
local: "process".into(),
command: String::new(),
cwd: String::new(),
},
Some(matched),
output,
);
}
}
pub fn configure(
&mut self,
settings: &ferese_config::activities::Settings,
matcher: ferese_config::activities::Matcher,
) {
self.store.limits = settings.limits();
self.process_enabled = settings.sources.process_watch;
*self.rules.write().expect("activity rules") = Arc::new(Rules {
enabled: settings.sources.shell_hook,
matcher,
});
self.refresh_process_source();
self.changed();
}
pub fn attach_process_source(
&mut self,
sender: smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>,
) {
self.process_sender = Some(sender);
self.refresh_process_source();
}
fn refresh_process_source(&mut self) {
if !self.process_enabled {
self.process_watcher = None;
} else if self.process_watcher.is_none()
&& let Some(sender) = &self.process_sender
{
match crate::activities_process::Watcher::start(self.rules.clone(), sender.clone()) {
Ok(watcher) => self.process_watcher = Some(watcher),
Err(error) => tracing::warn!(%error, "could not start optional activity process source"),
}
}
}
pub fn snapshot(&mut self) -> Arc<Snapshot> {
if let Some(snapshot) = &self.cached_snapshot {
return snapshot.clone();
}
let snapshot = Arc::new(Snapshot {
version: ferese_ipc::activities::VERSION,
revision: self.revision,
now_ms: ferese_ipc::activities::now_ms(),
limits: self.store.limits,
activities: self.store.entries().cloned().collect(),
windows: self
.windows
.iter()
.map(|(activity, window)| ferese_ipc::activities::ActivityWindow {
activity: activity.clone(),
window: *window,
})
.collect(),
});
self.cached_snapshot = Some(snapshot.clone());
snapshot
}
pub fn remove_connection(&mut self, owner: u64) {
self.watchers.remove(&owner);
}
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
if recipients.is_empty() {
return;
}
let snapshot = self.snapshot();
if let Some(publisher) = &self.publisher {
publisher.send(snapshot, recipients);
} else {
publication::unavailable(recipients);
}
}
pub fn get(&mut self, request: u64, sender: SyncSender<Response>) {
self.deliver(vec![(request, sender)]);
}
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
if since != self.revision {
self.get(request, sender);
} else if self.watchers.len() < 32 {
self.watchers.insert(connection, (request, sender));
} else {
let _ = sender.try_send(Response::error(
request,
"subscriber_limit",
"Activity subscriber limit",
));
}
}
pub fn owner_exit(&mut self, owner: Owner) {
self.store.owner_exit(owner);
self.rates.remove(&owner);
self.changed();
}
pub fn changed(&mut self) {
self.revision = self.revision.saturating_add(1);
self.cached_snapshot = None;
self.results
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
self.windows
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
let recipients = std::mem::take(&mut self.watchers).into_values().collect();
self.deliver(recipients);
self.schedule();
}
pub fn tick(&mut self) {
self.store.advance(ferese_ipc::activities::now_ms());
self.changed();
}
pub fn schedule(&self) {
if let Some(monitor) = &self.monitor {
monitor.retain(
&self
.store
.entries()
.filter(|entry| entry.state == State::Running)
.map(|entry| entry.id.owner)
.collect(),
);
}
if let Some(timer) = &self.timer {
let now = ferese_ipc::activities::now_ms();
timer.update(
self.store
.next_deadline(now)
.map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(now))),
);
}
}
pub fn publish_verified(
&mut self,
verified: crate::ipc::activity_owner::VerifiedOwner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
window: Option<u64>,
) -> Result<serde_json::Value, String> {
// Admission and publication run in one compositor dispatch. A queued
// request owns its pidfd, but is not yet subject to monitor pruning.
let owner = self
.monitor
.as_ref()
.ok_or("Activity owner monitor unavailable")?
.register(verified)
.map_err(|error| error.to_string())?;
let result = self
.publish_from(owner, publish, matched, output, window)
.map_err(str::to_owned);
self.schedule();
result
}
pub fn publish(
&mut self,
owner: Owner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
) -> Result<serde_json::Value, &'static str> {
self.publish_from(owner, publish, matched, output, None)
}
fn publish_from(
&mut self,
owner: Owner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
window: Option<u64>,
) -> Result<serde_json::Value, &'static str> {
let started = match &publish {
Publish::Start { local, .. } | Publish::Run { local, .. } | Publish::Hook { local, .. } => {
Some(local.clone())
}
_ => None,
};
let now = ferese_ipc::activities::now_ms();
if matches!(publish, Publish::Hook { .. }) && matched.is_none() {
return Ok(json!({"matched":false}));
}
self.rates.retain(|_, (at, _)| now.saturating_sub(*at) < 60_000);
if self.rates.len() >= 128 && !self.rates.contains_key(&owner) {
return Err("owner publication quota exceeded");
}
let rate = self.rates.entry(owner).or_insert((now, 0));
if now.saturating_sub(rate.0) >= 1_000 {
*rate = (now, 0);
}
// Each connection waits for acknowledgement; excess replaceable updates
// are explicitly rejected so terminal events retain capacity.
if (matches!(publish, Publish::Update { .. }) && rate.1 >= 30)
|| (matches!(
publish,
Publish::Start { .. } | Publish::Hook { .. } | Publish::Run { .. }
) && rate.1 >= 10)
{
return Err("publication rate exceeded");
}
rate.1 = rate.1.saturating_add(1);
// Expiration is a change even when the following publication is rejected.
// Notify subscribers before a store mutation can silently sweep leases.
if self.store.advance(now) {
self.changed();
}
let result = match publish {
Publish::Start {
local,
mut data,
lease_ms,
} => {
if data.output.is_none() {
data.output = output;
}
let id = self
.store
.start(owner, local, data, now, 0, lease_ms, true, true, None)?;
json!({"handle":id})
}
Publish::Run { local, title, .. } => {
if let Some(matched) = matched {
let data = Data {
source: if local == "process" {
Source::Process
} else {
Source::Command
},
output,
priority: matched.rule.priority,
icon: matched.rule.icon.clone(),
title: title.unwrap_or(matched.title),
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self.store.start(
owner,
local,
data,
now,
matched.rule.min_duration_ms,
None,
true,
matched.rule.flash_on_fail,
matched.group,
)?;
self.results.insert(id.clone(), matched.rule.result);
json!({"handle":id,"progress":matched.rule.progress})
} else {
let data = Data {
source: Source::Command,
output,
priority: 100,
icon: "ferese".into(),
title: title.unwrap_or_else(|| "Command".into()),
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self.store.start(owner, local, data, now, 0, None, true, true, None)?;
json!({"handle":id,"progress":"indeterminate"})
}
}
Publish::Hook { local, .. } => {
let Some(matched) = matched else {
return Ok(json!({"matched":false}));
};
let data = Data {
source: if local == "process" {
Source::Process
} else {
Source::Command
},
output,
priority: matched.rule.priority,
icon: matched.rule.icon.clone(),
title: matched.title,
value: Value::Indeterminate,
template: Template::Progress,
};
let lease = (local == "process").then_some(5_000);
let id = self.store.start(
owner,
local,
data,
now,
matched.rule.min_duration_ms,
lease,
true,
matched.rule.flash_on_fail,
matched.group,
)?;
self.results.insert(id.clone(), matched.rule.result);
json!({"matched":true,"handle":id,"progress":matched.rule.progress})
}
Publish::Update {
local,
handle,
value,
lease_ms,
} => {
let id = self.resolve(owner, &local, handle)?;
self.store.update(owner, &id, value, lease_ms, now)?;
json!({})
}
Publish::End {
local,
handle,
result,
message,
} => {
let id = self.resolve(owner, &local, handle)?;
let message = message.or_else(|| {
self.results.get(&id).map(|text| {
if result == State::Done {
text.ok.clone()
} else {
text.fail.clone()
}
})
});
self.store.finish(owner, &id, result, message, now)?;
json!({})
}
Publish::Cancel { local, handle } => {
let id = self.resolve(owner, &local, handle)?;
self.store.finish(owner, &id, State::Cancelled, None, now)?;
json!({})
}
};
if let Some(local) = started
&& let Some(id) = self.store.resolve(owner, &local)
&& let Some(window) = window
{
self.windows.insert(id.clone(), window);
}
self.changed();
Ok(result)
}
fn resolve(&self, owner: Owner, local: &str, handle: Option<Id>) -> Result<Id, &'static str> {
match handle {
Some(id) if id.local == local => Ok(id),
Some(_) => Err("handle does not match local ID"),
None => self
.store
.resolve(owner, local)
.cloned()
.ok_or("unknown owner-scoped activity"),
}
}
}
impl crate::Ferese {
pub(crate) fn activity_window(&self, owner: &crate::ipc::activity_owner::VerifiedOwner) -> Option<u64> {
use smithay::reexports::wayland_server::Resource;
let mut ancestors = owner.ancestor_pids().peekable();
ancestors.peek()?;
let windows: Vec<_> = self
.windows
.records()
.filter_map(|(id, record)| {
let client = record.surface.as_ref()?.client()?;
let pid = u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
Some((pid, id.0))
})
.collect();
origin_window(ancestors, &windows)
}
}
fn origin_window(ancestors: impl Iterator<Item = u32>, windows: &[(u32, u64)]) -> Option<u64> {
for pid in ancestors {
let mut matches = windows.iter().filter(|(client, _)| *client == pid);
if let Some((_, window)) = matches.next() {
// A terminal server may own several windows. Never guess from focus.
return matches.next().is_none().then_some(*window);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn owner() -> Owner {
Owner { pid: 12, birth: 3 }
}
fn data() -> Data {
Data {
source: Source::Publisher,
output: None,
priority: 255,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
}
}
#[test]
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
let mut engine = Engine::default();
let id = engine
.store
.start(
owner(),
"expired".into(),
data(),
ferese_ipc::activities::now_ms().saturating_sub(5_000),
0,
Some(1_000),
true,
true,
None,
)
.unwrap();
let before = engine.snapshot();
assert_eq!(before.activities.len(), 1);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 11, before.revision, sender);
assert!(
engine
.publish(
owner(),
Publish::Update {
local: "expired".into(),
handle: Some(id),
value: None,
lease_ms: None,
},
None,
None
)
.is_err()
);
assert!(engine.snapshot().activities.is_empty());
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
let snapshot: Snapshot = serde_json::from_value(response.result.unwrap()).unwrap();
assert!(snapshot.activities.is_empty());
assert!(snapshot.revision > before.revision);
assert_eq!(before.activities.len(), 1);
}
#[test]
fn snapshots_are_lazy_shared_and_immutable_across_publications() {
let mut engine = Engine::default();
engine.changed();
assert!(engine.cached_snapshot.is_none(), "no subscriber needs a snapshot");
let before = engine.snapshot();
assert!(Arc::ptr_eq(&before, &engine.snapshot()));
let (first, first_reply) = std::sync::mpsc::sync_channel(1);
let (second, second_reply) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 11, before.revision, first);
engine.watch(2, 22, before.revision, second);
engine
.publish(
owner(),
Publish::Start {
local: "build".into(),
data: data(),
lease_ms: None,
},
None,
None,
)
.unwrap();
let after = engine.snapshot();
assert!(!Arc::ptr_eq(&before, &after));
assert!(before.activities.is_empty());
assert_eq!(after.activities.len(), 1);
for (id, reply) in [(11, first_reply), (22, second_reply)] {
let reply = reply.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(reply.id, id);
let snapshot: Snapshot = serde_json::from_value(reply.result.unwrap()).unwrap();
assert_eq!(snapshot.revision, after.revision);
assert_eq!(snapshot.activities[0].id, after.activities[0].id);
}
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.watch(3, 33, before.revision, sender);
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap().id, 33);
assert!(Arc::ptr_eq(&after, &engine.snapshot()));
}
#[test]
fn process_that_exits_before_detection_dispatch_is_not_published() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let (owner, fd, _) = crate::ipc::activity_owner::inspect(child.id()).unwrap();
let matched = ferese_config::activities::Matcher::new(&Default::default())
.unwrap()
.matches("cargo test", "/tmp", &[])
.unwrap();
child.kill().unwrap();
child.wait().unwrap();
let mut engine = Engine::default();
engine.detect_process(owner, matched, None, &fd);
assert_eq!(engine.store.entries().count(), 0);
}
#[test]
fn rejected_process_detection_is_retried_without_restarting_admitted_records() {
let mut engine = Engine::default();
let (owner, fd, _) = crate::ipc::activity_owner::inspect(std::process::id()).unwrap();
let settings = ferese_config::activities::Settings::default();
let matched = ferese_config::activities::Matcher::new(&settings)
.unwrap()
.matches("cargo test", "/tmp/test", &[])
.unwrap();
engine.store.limits.max_total = 0;
engine.detect_process(owner, matched.clone(), None, &fd);
assert_eq!(engine.store.entries().count(), 0);
engine.store.limits.max_total = 128;
engine.detect_process(owner, matched.clone(), None, &fd);
let admitted = engine.store.entries().next().unwrap().clone();
engine.detect_process(owner, matched, None, &fd);
let refreshed = engine.store.entries().next().unwrap();
assert_eq!(refreshed.id, admitted.id);
assert_eq!(refreshed.eligible_at, admitted.eligible_at);
assert_eq!(engine.store.entries().count(), 1);
}
#[test]
fn terminal_matching_uses_nearest_unique_ancestor_and_never_guesses() {
let ancestry = [400, 300, 200, 100];
assert_eq!(origin_window(ancestry.into_iter(), &[(200, 7), (100, 8)]), Some(7));
assert_eq!(
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
None
);
assert_eq!(origin_window(ancestry.into_iter(), &[(900, 7)]), None);
assert_eq!(origin_window([].into_iter(), &[(200, 7)]), None);
}
#[test]
fn activity_window_survives_updates_and_results_then_expires() {
let mut engine = Engine::default();
engine.store.limits.start_delay_ms = 0;
engine
.publish_from(
owner(),
Publish::Run {
local: "build".into(),
title: Some("Build".into()),
command: String::new(),
cwd: String::new(),
},
None,
None,
Some(42),
)
.unwrap();
let id = engine.store.entries().next().unwrap().id.clone();
assert_eq!(engine.snapshot().windows[0].window, 42);
engine
.publish(
owner(),
Publish::Update {
local: "build".into(),
handle: Some(id.clone()),
value: Some(Value::Progress(0.5)),
lease_ms: None,
},
None,
None,
)
.unwrap();
assert_eq!(engine.snapshot().windows[0].window, 42);
engine
.publish(
owner(),
Publish::End {
local: "build".into(),
handle: Some(id.clone()),
result: State::Done,
message: None,
},
None,
None,
)
.unwrap();
engine.owner_exit(owner());
assert_eq!(engine.snapshot().windows[0].activity, id);
engine.store.advance(ferese_ipc::activities::now_ms() + 2_000);
engine.changed();
assert!(engine.snapshot().windows.is_empty());
}
#[test]
fn one_shot_disconnect_does_not_own_lifetime_and_rate_limit_preserves_completion() {
let mut engine = Engine::default();
let result = engine
.publish(
owner(),
Publish::Start {
local: "build".into(),
data: data(),
lease_ms: None,
},
None,
Some("first".into()),
)
.unwrap();
let id: Id = serde_json::from_value(result["handle"].clone()).unwrap();
engine.remove_connection(1);
assert_eq!(engine.store.entries().count(), 1);
for _ in 0..40 {
let _ = engine.publish(
owner(),
Publish::Update {
local: "build".into(),
handle: Some(id.clone()),
value: Some(Value::Progress(0.5)),
lease_ms: None,
},
None,
Some("other".into()),
);
}
assert_eq!(
engine.store.entries().next().unwrap().data.output.as_deref(),
Some("first")
);
engine
.publish(
owner(),
Publish::End {
local: "build".into(),
handle: Some(id),
result: State::Done,
message: None,
},
None,
None,
)
.unwrap();
assert_eq!(
engine.store.entries().count(),
0,
"completion before delay never flashes"
);
}
#[test]
fn rule_is_pinned_across_atomic_reload_and_no_raw_metadata_is_published() {
let settings = ferese_config::activities::Settings::default();
let matched = ferese_config::activities::Matcher::new(&settings)
.unwrap()
.matches("cargo test --secret", "/secret/cwd", &[])
.unwrap();
let mut engine = Engine::default();
engine
.publish(
owner(),
Publish::Run {
local: "build".into(),
title: None,
command: String::new(),
cwd: String::new(),
},
Some(matched),
Some("first".into()),
)
.unwrap();
let before = engine.store.entries().next().unwrap().clone();
let mut replacement = settings.clone();
replacement.start_delay_ms = 5_000;
replacement.rules[0].title = "Replacement".into();
engine.configure(
&replacement,
ferese_config::activities::Matcher::new(&replacement).unwrap(),
);
let after = engine.store.entries().next().unwrap();
assert_eq!(after.data.title, "cargo test");
assert_eq!(after.eligible_at, before.eligible_at);
let serialized = serde_json::to_string(engine.snapshot().as_ref()).unwrap();
assert!(!serialized.contains("secret"));
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Bounded serialization and delivery outside the compositor event loop.
use ferese_ipc::{Response, activities::Snapshot};
use std::sync::{
Arc,
mpsc::{self, SyncSender},
};
type Recipient = (u64, SyncSender<Response>);
struct Job {
snapshot: Arc<Snapshot>,
recipients: Vec<Recipient>,
}
pub(super) struct Publisher(SyncSender<Job>);
impl Publisher {
pub fn new() -> std::io::Result<Self> {
let (send, receive) = mpsc::sync_channel::<Job>(8);
std::thread::Builder::new()
.name("ferese-activity-snapshots".into())
.spawn(move || {
let mut cached: Option<(Arc<Snapshot>, serde_json::Value)> = None;
while let Ok(job) = receive.recv() {
if !cached
.as_ref()
.is_some_and(|(snapshot, _)| Arc::ptr_eq(snapshot, &job.snapshot))
{
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()));
}
}
})?;
Ok(Self(send))
}
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
unavailable(job.recipients);
}
}
}
pub(super) fn unavailable(recipients: Vec<Recipient>) {
for (id, sender) in recipients {
let _ = sender.try_send(Response::error(
id,
"activity_busy",
"Activity snapshot delivery unavailable; retry",
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[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 (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();
assert_eq!(response.id, 7);
assert_eq!(response.error.unwrap().code, "activity_busy");
let publisher = Publisher::new().unwrap();
let (slow, _slow_receive) = mpsc::sync_channel(1);
slow.send(Response::success(1, serde_json::json!({}))).unwrap();
let (fast, fast_receive) = mpsc::sync_channel(1);
publisher.send(Arc::new(Snapshot::default()), vec![(2, slow), (3, fast)]);
assert_eq!(fast_receive.recv_timeout(Duration::from_secs(1)).unwrap().id, 3);
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Explicitly enabled fallback. Bounded /proc sampling and regex work stay on
//! one worker; disappearance emits owner exit, never an inferred exit status.
use crate::ipc::IpcEvent;
use ferese_activities::Owner;
use smithay::reexports::calloop::channel;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
pub(crate) struct Watcher(Arc<AtomicBool>);
impl Drop for Watcher {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
impl Watcher {
pub fn start(
rules: crate::activities::SharedRules,
sender: channel::SyncSender<IpcEvent>,
) -> std::io::Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = stop.clone();
std::thread::Builder::new()
.name("ferese-activity-processes".into())
.spawn(move || {
let mut observed =
BTreeMap::<Owner, (Arc<std::os::fd::OwnedFd>, ferese_config::activities::Matched)>::new();
while !worker_stop.load(Ordering::Acquire) {
let rules = rules.read().expect("activity rules").clone();
let dead: Vec<_> = observed
.iter()
.filter_map(|(owner, (fd, _))| crate::ipc::activity_owner::exited(fd).then_some(*owner))
.collect();
for owner in dead {
observed.remove(&owner);
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
return;
}
}
// Reconcile every detection: admission may have failed or its lease expired.
for (owner, (fd, matched)) in &observed {
let _ = sender.try_send(IpcEvent::ActivityDetected(
*owner,
Box::new(matched.clone()),
fd.clone(),
));
}
if let Ok(processes) = std::fs::read_dir("/proc") {
for process in processes.take(4_096).flatten() {
if worker_stop.load(Ordering::Acquire) || observed.len() >= 128 {
break;
}
let Some(pid) = process.file_name().to_str().and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok((owner, fd, parents)) = crate::ipc::activity_owner::inspect(pid) else {
continue;
};
if observed.contains_key(&owner) || parents.iter().any(|parent| parent == "feresectl") {
continue;
}
let Ok(file) = std::fs::File::open(process.path().join("cmdline")) else {
continue;
};
let mut bytes = Vec::new();
if file.take(4_097).read_to_end(&mut bytes).is_err() || bytes.len() > 4_096 {
continue;
}
let Ok(command) = String::from_utf8(bytes) else {
continue;
};
let command = command.trim_end_matches('\0').replace('\0', " ");
let Ok(cwd) = std::fs::read_link(process.path().join("cwd")) else {
continue;
};
let cwd = cwd.to_string_lossy();
let Some(matched) = rules.matcher.matches(&command, &cwd, &parents) else {
continue;
};
let fd = Arc::new(fd);
// Keep the inspected process identity alive through dispatch.
// No raw command, cwd or environment crosses the dispatcher.
if sender
.try_send(IpcEvent::ActivityDetected(owner, Box::new(matched.clone()), fd.clone()))
.is_ok()
{
observed.insert(owner, (fd, matched));
}
}
}
std::thread::sleep(Duration::from_secs(2));
}
})?;
Ok(Self(stop))
}
}
+6
View File
@@ -33,6 +33,8 @@ use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
activities: ferese_config::activities::Settings,
#[serde(default)]
panels: Option<Vec<ferese_panel::Panel>>,
#[serde(default)]
@@ -118,6 +120,7 @@ impl Config {
if let Some(panels) = &self.panels {
ferese_panel::validate(panels).map_err(ConfigError::Binding)?;
}
self.activities.validate().map_err(ConfigError::Binding)?;
self.notifications.validate().map_err(ConfigError::Binding)?;
self.xwayland.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart {
@@ -128,6 +131,9 @@ impl Config {
let input_settings = self.input_settings()?;
let bindings = self.bindings(&input_settings)?;
Ok(crate::RuntimeConfig {
activity_settings: self.activities.clone(),
activity_matcher: ferese_config::activities::Matcher::new(&self.activities)
.map_err(ConfigError::Binding)?,
lock_idle: self.lock_screen.validate()?,
idle_inhibit: self.idle_inhibit,
autostart: self.autostart.clone(),
+201 -13
View File
@@ -13,6 +13,7 @@ use std::{fs, io, thread};
use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION};
pub(crate) mod activity_owner;
mod transport;
use ferese_layout::Direction;
use serde_json::{Value, json};
@@ -40,8 +41,13 @@ const _: () = assert!(
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
#[derive(Debug)]
struct IpcCall {
pub(crate) struct IpcCall {
native_portal: bool,
activity: Option<(
activity_owner::VerifiedOwner,
ferese_ipc::activities::Publish,
Option<ferese_config::activities::Matched>,
)>,
owner: u64,
request: Request,
response: SyncSender<Response>,
@@ -49,9 +55,15 @@ struct IpcCall {
}
#[derive(Debug)]
enum IpcEvent {
Call(IpcCall),
pub(crate) enum IpcEvent {
Call(Box<IpcCall>),
Closed(u64),
ActivityOwnerExited(ferese_activities::Owner),
ActivityDetected(
ferese_activities::Owner,
Box<ferese_config::activities::Matched>,
Arc<std::os::fd::OwnedFd>,
),
}
struct IpcConnection {
@@ -149,8 +161,28 @@ pub(crate) fn init(
event_loop.handle().insert_source(receiver, |event, _, state| {
if let channel::Event::Msg(event) = event {
let call = match event {
IpcEvent::Call(call) => call,
IpcEvent::Call(call) => *call,
IpcEvent::ActivityDetected(owner, matched, fd) => {
let output = state
.outputs_json()
.as_array()
.and_then(|outputs| {
outputs
.iter()
.find(|output| output["focused"] == true)
.or_else(|| outputs.first())
})
.and_then(|output| output["name"].as_str())
.map(str::to_owned);
state.activities.detect_process(owner, *matched, output, &fd);
return;
}
IpcEvent::ActivityOwnerExited(owner) => {
state.activities.owner_exit(owner);
return;
}
IpcEvent::Closed(owner) => {
state.activities.remove_connection(owner);
state.ipc_events.remove(owner);
for request in state.screenshot.terminate_owner(owner) {
state
@@ -170,7 +202,67 @@ pub(crate) fn init(
return;
}
};
if call.request.command == "event-stream" {
if call.request.command == "activity-publish" {
let result = validate_request(&call.request)
.map_err(|error| error.message)
.and_then(|()| {
if state.session_lock.active() {
return Err("Session locked".into());
}
let (owner, publish, matched) = call.activity.ok_or("Unverified activity owner")?;
let output = state
.outputs_json()
.as_array()
.and_then(|outputs| {
outputs
.iter()
.find(|output| output["focused"] == true)
.or_else(|| outputs.first())
})
.and_then(|output| output["name"].as_str())
.map(str::to_owned);
let window = state.activity_window(&owner);
state
.activities
.publish_verified(owner, publish, matched, output, window)
});
state.activities.schedule();
let response = match result {
Ok(result) => Response::success(call.request.id, result),
Err(error) => Response::error(call.request.id, "activity_rejected", error),
};
let _ = call.response.try_send(response);
} else if call.request.command == "activity-get" {
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));
} else {
state.activities.get(call.request.id, call.response);
}
} else if call.request.command == "activity-watch" {
if let Err(error) = validate_request(&call.request) {
let _ = call
.response
.try_send(Response::error(call.request.id, error.code, error.message));
} else if let Some(since) = call.request.args["since"].as_u64() {
state
.activities
.watch(call.owner, call.request.id, since, call.response);
} else {
let _ = call.response.try_send(Response::error(
call.request.id,
"invalid_argument",
"Missing activity revision",
));
}
} else if call.request.command == "event-stream" {
let result = validate_request(&call.request).and_then(|()| {
if call.request.args["version"].as_u64() != Some(u64::from(ferese_ipc::events::VERSION)) {
return Err(CommandError::new(
@@ -317,10 +409,26 @@ pub(crate) fn init(
})?;
let worker = Worker::spawn(encoded);
let activity_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, _| state.activities.tick());
let (rules_send, rules_receive) = sync_channel(1);
let monitor = activity_owner::Monitor::new(sender.clone());
let state_monitor = monitor.clone();
let state_sender = sender.clone();
event_loop.handle().insert_idle(move |state| {
state.activities.timer = Some(activity_timer);
state.activities.monitor = Some(state_monitor);
state.activities.attach_process_source(state_sender);
state.activities.schedule();
let _ = rules_send.send(state.activities.rules.clone());
});
let signal = event_loop.get_signal();
thread::Builder::new()
.name("ferese-ipc-listener".to_owned())
.spawn(move || accept_connections(listener, sender, signal))?;
.spawn(move || {
if let Ok(rules) = rules_receive.recv() {
accept_connections(listener, sender, signal, rules, monitor);
}
})?;
// A client that dies between receiving a screenshot path and unlinking it
// leaves the file behind, so staged files are reclaimed at startup and then
@@ -415,7 +523,13 @@ impl<Data: 'static> Drop for DeadlineTimer<Data> {
}
}
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
fn accept_connections(
listener: UnixListener,
sender: channel::SyncSender<IpcEvent>,
signal: LoopSignal,
rules: crate::activities::SharedRules,
monitor: activity_owner::Monitor,
) {
let active_connections = Arc::new(AtomicUsize::new(0));
let connection_ids = AtomicU64::new(1);
@@ -448,11 +562,13 @@ fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEve
let sender = sender.clone();
let owner = connection_ids.fetch_add(1, Ordering::Relaxed);
let signal = signal.clone();
let monitor = monitor.clone();
let rules = rules.clone();
if let Err(error) = thread::Builder::new()
.name("ferese-ipc-client".to_owned())
.spawn(move || {
let _permit = permit;
serve_connection(stream, sender, signal, owner);
serve_connection_with_activities(stream, sender, signal, owner, Some((monitor, rules)));
})
{
tracing::warn!(%error, "could not start IPC connection worker");
@@ -475,7 +591,17 @@ fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit>
Some(ConnectionPermit { active: active.clone() })
}
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal, owner: u64) {
#[cfg(test)]
fn serve_connection(stream: UnixStream, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal, owner: u64) {
serve_connection_with_activities(stream, sender, signal, owner, None)
}
fn serve_connection_with_activities(
mut stream: UnixStream,
sender: channel::SyncSender<IpcEvent>,
signal: LoopSignal,
owner: u64,
activity_host: Option<(activity_owner::Monitor, crate::activities::SharedRules)>,
) {
let native_portal = crate::handlers::window_capture::is_portal(&stream);
let _connection = IpcConnection {
owner,
@@ -484,7 +610,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
let mut idle = Some(transport::FRAME_TIMEOUT);
let mut owns_lease = false;
loop {
let request: Request = match transport::read_request(&stream, idle) {
let mut request: Request = match transport::read_request(&stream, idle) {
Ok(request) => request,
Err(ferese_ipc::FrameError::Io(error))
if matches!(
@@ -512,6 +638,61 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
}
continue;
}
let activity = if request.command == "activity-publish" {
let verified = (|| -> Result<_, String> {
validate_request(&request).map_err(|error| error.message)?;
let wire: ferese_ipc::activities::Request =
serde_json::from_value(std::mem::take(&mut request.args)).map_err(|error| error.to_string())?;
if wire.version != ferese_ipc::activities::VERSION {
return Err("Unsupported activity schema".into());
}
let (monitor, rules) = activity_host.as_ref().ok_or("Activity host unavailable")?;
let (mut identity, parents) = monitor
.verify(
&stream,
matches!(wire.owner, ferese_ipc::activities::OwnerScope::Parent),
)
.map_err(|error| error.to_string())?;
let mut publish = wire.publish;
let is_run = matches!(publish, ferese_ipc::activities::Publish::Run { .. });
let matched = if let ferese_ipc::activities::Publish::Hook { command, cwd, .. }
| ferese_ipc::activities::Publish::Run { command, cwd, .. } = &mut publish
{
let rules = rules.read().map_err(|_| "Activity rules unavailable")?.clone();
let matched = if rules.enabled || is_run {
rules.matcher.matches(command, cwd, &parents)
} else {
None
};
command.clear();
cwd.clear();
matched
} else {
None
};
if matches!(
publish,
ferese_ipc::activities::Publish::Start { .. } | ferese_ipc::activities::Publish::Run { .. }
) || (matches!(publish, ferese_ipc::activities::Publish::Hook { .. }) && matched.is_some())
{
identity.capture_ancestors();
}
Ok((identity, publish, matched))
})();
match verified {
Ok(verified) => Some(verified),
Err(error) => {
if transport::write_response(&stream, &Response::error(request.id, "owner_rejected", error))
.is_err()
{
return;
}
continue;
}
}
} else {
None
};
let (response, receiver) = sync_channel(1);
let event_stream_requested = request.command == "event-stream";
let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY);
@@ -525,14 +706,21 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
| "begin-session-end"
);
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let call = IpcEvent::Call(IpcCall {
let call = IpcEvent::Call(Box::new(IpcCall {
native_portal,
activity,
owner,
request,
response,
events: event_stream_requested.then_some(events),
});
if sender.try_send(call).is_err() {
}));
let terminal_activity = matches!(&call, IpcEvent::Call(call) if matches!(call.activity.as_ref().map(|(_, publish, _)| publish), Some(ferese_ipc::activities::Publish::End { .. } | ferese_ipc::activities::Publish::Cancel { .. })));
let sent = if terminal_activity {
sender.send(call).is_ok()
} else {
sender.try_send(call).is_ok()
};
if !sent {
tracing::warn!("disconnecting IPC client because the request queue is full");
return;
}
+379
View File
@@ -0,0 +1,379 @@
//! Authenticate the socket peer and its immediate parent before accepting any
//! activity request. pidfds bind monitoring to a process, not a reused PID.
use super::IpcEvent;
use ferese_activities::Owner;
use smithay::reexports::calloop::channel;
use std::collections::BTreeMap;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::{Arc, Condvar, Mutex};
/// Carries the kernel identity through the IPC queue. Monitoring begins only
/// when the compositor admits the request, so pruning cannot race a pending start.
#[derive(Debug)]
pub(crate) struct VerifiedOwner {
pub owner: Owner,
fd: OwnedFd,
ancestors: Vec<(u32, OwnedFd)>,
}
impl VerifiedOwner {
/// Run on the IPC worker, only for starts. Hold ancestor identities until
/// dispatch; a recycled PID must never select a window.
pub fn capture_ancestors(&mut self) {
let mut next = self.owner.pid;
let mut child = None;
for _ in 0..16 {
let Ok((birth, parent)) = identity(next) else { break };
if next == self.owner.pid && birth != self.owner.birth {
break;
}
let Ok(fd) = pidfd(next) else { break };
if identity(next).ok() != Some((birth, parent)) || exited(&fd) {
break;
}
if let Some((pid, expected)) = child
&& identity(pid).ok() != Some(expected)
{
break;
}
self.ancestors.push((next, fd));
child = Some((next, (birth, parent)));
if parent <= 1 || parent == next {
break;
}
next = parent;
}
}
pub fn ancestor_pids(&self) -> impl Iterator<Item = u32> + '_ {
self.ancestors
.iter()
.take_while(|(_, fd)| !exited(fd))
.map(|(pid, _)| *pid)
}
}
#[derive(Clone)]
pub(crate) struct Monitor(Arc<(Mutex<BTreeMap<Owner, OwnedFd>>, Condvar)>);
impl Monitor {
pub fn new(sender: channel::SyncSender<IpcEvent>) -> Self {
let shared = Arc::new((Mutex::new(BTreeMap::<Owner, OwnedFd>::new()), Condvar::new()));
let worker = shared.clone();
std::thread::Builder::new()
.name("ferese-activity-owners".into())
.spawn(move || {
loop {
let (lock, ready) = &*worker;
let mut owners = lock.lock().expect("activity owners");
while owners.is_empty() {
owners = ready.wait(owners).expect("activity owners");
}
let dead: Vec<_> = owners
.iter()
.filter_map(|(owner, fd)| {
let mut poll = libc::pollfd {
fd: fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll addresses one live, initialized descriptor.
(unsafe { libc::poll(&raw mut poll, 1, 0) } > 0).then_some(*owner)
})
.collect();
for owner in &dead {
owners.remove(owner);
}
drop(owners);
for owner in dead {
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
return;
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
})
.expect("could not start activity owner monitor");
Self(shared)
}
pub fn retain(&self, owners: &std::collections::BTreeSet<Owner>) {
self.0
.0
.lock()
.expect("activity owners")
.retain(|owner, _| owners.contains(owner));
}
pub fn verify(&self, stream: &UnixStream, parent: bool) -> io::Result<(VerifiedOwner, Vec<String>)> {
let peer = credentials(stream)?;
let peer_pid = u32::try_from(peer.pid).map_err(|_| io::Error::other("invalid peer PID"))?;
let peer_identity = identity(peer_pid)?;
let peer_fd = peer_pidfd(stream)?;
if identity(peer_pid)?.0 != peer_identity.0 || exited(&peer_fd) {
return Err(io::Error::other("peer identity changed"));
}
let pid = if parent { peer_identity.1 } else { peer_pid };
let (birth, _) = identity(pid)?;
let fd = pidfd(pid)?;
if identity(pid)?.0 != birth || exited(&fd) || (parent && identity(peer_pid)? != peer_identity) {
return Err(io::Error::other("owner identity changed"));
}
let metadata = std::fs::metadata(format!("/proc/{pid}"))?;
use std::os::unix::fs::MetadataExt;
if metadata.uid() != peer.uid {
return Err(io::Error::other("owner user differs from peer"));
}
let owner = Owner { pid, birth };
let mut parents = Vec::new();
let mut next = pid;
for _ in 0..16 {
let Ok((_, parent)) = identity(next) else {
break;
};
if let Ok(name) = std::fs::read_to_string(format!("/proc/{next}/comm")) {
parents.push(name.trim().chars().take(128).collect());
}
if parent == 0 || parent == next {
break;
}
next = parent;
}
Ok((
VerifiedOwner {
owner,
fd,
ancestors: Vec::new(),
},
parents,
))
}
pub fn register(&self, verified: VerifiedOwner) -> io::Result<Owner> {
let VerifiedOwner { owner, fd, .. } = verified;
if exited(&fd) {
return Err(io::Error::other("activity owner exited before admission"));
}
let (lock, ready) = &*self.0;
let mut owners = lock.lock().expect("activity owners");
if owners.len() >= 128 && !owners.contains_key(&owner) {
return Err(io::Error::other("owner monitor quota"));
}
owners.entry(owner).or_insert(fd);
ready.notify_one();
drop(owners);
Ok(owner)
}
}
pub(crate) fn exited(fd: &OwnedFd) -> bool {
let mut poll = libc::pollfd {
fd: fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll addresses one initialized descriptor retained by fd.
unsafe { libc::poll(&raw mut poll, 1, 0) != 0 }
}
fn peer_pidfd(stream: &UnixStream) -> io::Result<OwnedFd> {
let mut fd = -1_i32;
let mut len = std::mem::size_of::<i32>() as libc::socklen_t;
// SO_PEERPIDFD (Linux 6.5+) refers to the process captured when this socket
// connected, closing the credential-PID reuse race before authentication.
// SAFETY: both output buffers have the sizes expected by getsockopt.
if unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
77,
(&raw mut fd).cast(),
&raw mut len,
)
} != 0
{
return Err(io::Error::last_os_error());
}
if fd < 0 {
return Err(io::Error::other("missing peer pidfd"));
}
// SAFETY: SO_PEERPIDFD returned a new descriptor owned by this caller.
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
fn pidfd(pid: u32) -> io::Result<OwnedFd> {
// SAFETY: pidfd_open accepts numeric arguments and returns an owned fd.
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
if fd < 0 {
Err(io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) })
}
}
fn identity(pid: u32) -> io::Result<(u64, u32)> {
parse_stat(&std::fs::read_to_string(format!("/proc/{pid}/stat"))?)
}
fn parse_stat(stat: &str) -> io::Result<(u64, u32)> {
let end = stat
.rfind(')')
.ok_or_else(|| io::Error::other("invalid process stat"))?;
let fields: Vec<_> = stat[end + 1..].split_whitespace().collect();
let parent = fields
.get(1)
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other("missing parent identity"))?;
let birth = fields
.get(19)
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other("missing process birth"))?;
Ok((birth, parent))
}
fn credentials(stream: &UnixStream) -> io::Result<libc::ucred> {
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: both output buffers have the sizes expected by SO_PEERCRED.
if unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&raw mut credentials).cast(),
&raw mut len,
)
} == 0
{
Ok(credentials)
} else {
Err(io::Error::last_os_error())
}
}
pub(crate) fn inspect(pid: u32) -> io::Result<(Owner, OwnedFd, Vec<String>)> {
use std::os::unix::fs::MetadataExt;
if std::fs::metadata(format!("/proc/{pid}"))?.uid() != unsafe { libc::geteuid() } {
return Err(io::Error::other("process belongs to another user"));
}
let (birth, _) = identity(pid)?;
let fd = pidfd(pid)?;
if identity(pid)?.0 != birth || exited(&fd) {
return Err(io::Error::other("process disappeared"));
}
let mut next = pid;
let mut parents = Vec::new();
for _ in 0..16 {
if let Ok(name) = std::fs::read_to_string(format!("/proc/{next}/comm")) {
parents.push(name.trim().chars().take(128).collect());
}
let Ok((_, parent)) = identity(next) else {
break;
};
if parent == 0 || parent == next {
break;
}
next = parent;
}
Ok((Owner { pid, birth }, fd, parents))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn captured_ancestry_stops_at_an_exited_process() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let (owner, fd, _) = inspect(child.id()).unwrap();
let mut verified = VerifiedOwner {
owner,
fd,
ancestors: Vec::new(),
};
verified.capture_ancestors();
assert_eq!(verified.ancestor_pids().next(), Some(child.id()));
assert!(verified.ancestor_pids().any(|pid| pid == std::process::id()));
child.kill().unwrap();
child.wait().unwrap();
assert!(verified.ancestor_pids().next().is_none());
}
#[test]
fn pending_start_survives_monitor_pruning() {
use ferese_activities::{Data, Source, Template, Value};
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let mut engine = crate::activities::Engine::default();
engine.monitor = Some(monitor.clone());
let (peer, _other) = UnixStream::pair().unwrap();
let (verified, _) = monitor.verify(&peer, false).unwrap();
let owner = verified.owner;
// A policy timer or a different queued publication runs after verification,
// before this worker's Start reaches the compositor dispatcher.
engine.schedule();
engine
.publish_verified(
verified,
ferese_ipc::activities::Publish::Start {
local: "pending-start".into(),
data: Data {
source: Source::Publisher,
output: None,
priority: 100,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
},
lease_ms: None,
},
None,
Some("test".into()),
None,
)
.unwrap();
assert_eq!(engine.store.entries().count(), 1);
assert!(
monitor.0.0.lock().unwrap().contains_key(&owner),
"accepted lease-free activity has lost its owner monitor"
);
}
#[test]
fn owner_that_exits_in_the_dispatch_queue_is_rejected() {
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let (owner, fd, _) = inspect(child.id()).unwrap();
child.kill().unwrap();
child.wait().unwrap();
assert!(
monitor
.register(VerifiedOwner {
owner,
fd,
ancestors: Vec::new()
})
.is_err()
);
assert!(monitor.0.0.lock().unwrap().is_empty());
}
#[test]
fn process_names_with_spaces_or_parentheses_do_not_shift_birth_field() {
let fields = (0..20)
.map(|i| if i == 0 { "S".into() } else { i.to_string() })
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
parse_stat(&format!("42 (name ) with spaces) {fields}")).unwrap(),
(19, 1)
);
}
#[test]
fn verifies_kernel_peer_parent_and_monitors_actual_exit() {
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let (left, _) = UnixStream::pair().unwrap();
let (peer, _) = monitor.verify(&left, false).unwrap();
assert_eq!(peer.owner.pid, std::process::id());
let (parent, _) = monitor.verify(&left, true).unwrap();
assert_eq!(parent.owner.pid, unsafe { libc::getppid() } as u32);
let mut child = std::process::Command::new("sleep").arg("0.05").spawn().unwrap();
let fd = pidfd(child.id()).unwrap();
assert!(!exited(&fd));
child.wait().unwrap();
assert!(exited(&fd));
}
}
+2
View File
@@ -1,3 +1,5 @@
mod activities;
mod activities_process;
mod backends;
mod config;
mod cursor;
+208
View File
@@ -810,3 +810,211 @@ fn corner_heavy_shader_timing_sample() {
}
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn window_tint_does_not_inherit_panel_geometry() {
use smithay::backend::renderer::gles::UniformValue;
use smithay::output::{Mode, PhysicalProperties, Subpixel};
fn paint(renderer: &mut GlesRenderer, element: &impl RenderElement<GlesRenderer>) -> Vec<u8> {
let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (96, 96).into()).unwrap();
let mut target = renderer.bind(&mut texture).unwrap();
let rect = Rectangle::from_size((96, 96).into());
let mut frame = renderer
.render(&mut target, (96, 96).into(), Transform::Normal)
.unwrap();
frame.clear([1.0, 1.0, 1.0, 1.0].into(), &[rect]).unwrap();
let geometry = element.geometry(1.0.into());
element
.draw(
&mut frame,
element.src(),
geometry,
&[Rectangle::from_size(geometry.size)],
&[],
)
.unwrap();
let _ = frame.finish().unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((96, 96).into()), Fourcc::Abgr8888)
.unwrap();
renderer.map_texture(&mapping).unwrap().to_vec()
}
let mut renderer = renderer();
let mut resources = RenderResources::default();
let output = Output::new(
"tint-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (96, 96).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
Some((0, 0).into()),
);
let geometry = Rectangle::new((6, 6).into(), (80, 80).into());
let corners = RoundedRect::from_logical(geometry, 1.0, 12.0).with_shape(CornerShape::Continuous);
for resize_fill in [false, true] {
let tint = window_tint_element(
&mut resources,
&mut renderer,
ferese_layout::WindowId(1),
geometry,
corners,
[0.0, 0.0, 0.0, 0.44],
resize_fill,
&output,
)
.unwrap();
let baseline = paint(&mut renderer, &tint);
let program = material_program(&mut resources, &mut renderer).unwrap();
let panel = SharedPixelShaderElement::new(
program.0,
Rectangle::from_size((96, 24).into()),
None,
1.0,
vec![
Uniform::new("visible_rect", [0.0_f32, 72.0, 96.0, 24.0]),
Uniform::new("material_radii", [12.0_f32; 4]),
Uniform::new("tint", [0.2_f32, 0.2, 0.2, 1.0]),
Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", [0.0_f32; 4]),
Uniform::new("shadow_values", [0.0_f32; 2]),
Uniform::new("panel_count", 1.0_f32),
Uniform::new("panel_fusion", 0.0_f32),
Uniform::new(
"panel_shapes[0]",
UniformValue::Matrix3x3 {
matrices: vec![[0.0, 72.0, 96.0, 24.0, 12.0, 12.0, 12.0, 12.0, 0.0]],
transpose: false,
},
),
],
RenderElementKind::Unspecified,
);
paint(&mut renderer, &panel);
let after_panel = paint(&mut renderer, &tint);
let changed = baseline
.chunks_exact(4)
.zip(after_panel.chunks_exact(4))
.filter(|(a, b)| a != b)
.count();
assert_eq!(
changed, 0,
"a panel draw changed {changed} window pixels (resize_fill={resize_fill})"
);
assert!(
baseline.chunks_exact(4).any(|p| p[0] < 200),
"fixture must draw the tint"
);
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn floating_blur_sampling_halo_does_not_paint_neighboring_islands() {
use smithay::backend::renderer::ImportMem;
use smithay::backend::renderer::gles::UniformValue;
let mut renderer = renderer();
let mut resources = RenderResources::default();
let program = blur_program(&mut resources, &mut renderer).unwrap();
for scale in [1.0_f64, 1.25, 1.5, 1.75, 2.0] {
let size: Size<i32, Physical> = ((256. * scale) as i32, (96. * scale) as i32).into();
let full = Rectangle::from_size(size);
let shapes: Vec<[f32; 9]> = [24., 112.]
.into_iter()
.map(|x| [x, 24., 80., 32., 16., 16., 16., 16., 0.].map(|v| v * scale as f32))
.collect();
// Each island captures an extra 32 logical pixels on every side for
// blur sampling. That capture includes part of its neighboring island.
for island in 0..2 {
let shape = shapes[island];
let visible = [shape[0], shape[1], shape[2], shape[3]];
let halo = 32. * scale as f32;
let capture = [
(visible[0] - halo).max(0.),
(visible[1] - halo).max(0.),
(visible[0] + visible[2] + halo).min(size.w as f32),
(visible[1] + visible[3] + halo).min(size.h as f32),
];
let rect = Rectangle::<i32, Physical>::new(
(capture[0] as i32, capture[1] as i32).into(),
((capture[2] - capture[0]) as i32, (capture[3] - capture[1]) as i32).into(),
);
let mut pixels = vec![128_u8; (rect.size.w * rect.size.h * 4) as usize];
for p in pixels.chunks_exact_mut(4) {
p[3] = 255;
}
let source = renderer
.import_memory(&pixels, Fourcc::Abgr8888, (rect.size.w, rect.size.h).into(), false)
.unwrap();
let uniforms = vec![
Uniform::new("visible_rect", visible),
Uniform::new("material_radii", [16. * scale as f32; 4]),
Uniform::new("texture_size", [rect.size.w as f32, rect.size.h as f32]),
Uniform::new("capture_origin", [capture[0], capture[1]]),
Uniform::new("blur_radius", halo),
Uniform::new("presentation_alpha", 1.0_f32),
Uniform::new("background_opacity", 0.6_f32),
Uniform::new("tint", [0.1_f32, 0.1, 0.1, 0.5]),
Uniform::new("panel_count", 2.0_f32),
Uniform::new("panel_fusion", 0.0_f32),
Uniform::new(
"panel_shapes[0]",
UniformValue::Matrix3x3 {
matrices: shapes.clone(),
transpose: false,
},
),
];
let mut texture: GlesTexture = renderer
.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into())
.unwrap();
let mut target = renderer.bind(&mut texture).unwrap();
let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap();
frame.clear([0.; 4].into(), &[full]).unwrap();
// OpenGL coordinates have the opposite Y direction from the frame.
let dst = Rectangle::new((rect.loc.x, size.h - rect.loc.y - rect.size.h).into(), rect.size);
frame
.render_texture_from_to(
&source,
Rectangle::from_size(source.size().to_f64()),
dst,
&[Rectangle::from_size(dst.size)],
&[],
Transform::Normal,
1.,
Some(&program.0),
&uniforms,
)
.unwrap();
let _ = frame.finish().unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((size.w, size.h).into()), Fourcc::Abgr8888)
.unwrap();
let rendered = renderer.map_texture(&mapping).unwrap().to_vec();
let mut painted = 0;
for pixel in rendered.chunks_exact(4).enumerate() {
let (index, rgba) = pixel;
let x = (index % size.w as usize) as f32 + 0.5;
if x < visible[0] || x >= visible[0] + visible[2] {
assert_eq!(
rgba[3], 0,
"island {island} paints outside its bounds at x={x}, scale={scale}"
);
} else if rgba[3] > 0 {
painted += 1;
}
}
assert!(painted > 100, "fixture must paint its own island");
}
}
}
+16 -10
View File
@@ -197,6 +197,21 @@ pub(super) fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'sta
]
}
// The material program is shared with panels. GL uniforms survive between
// draws, so every ordinary tint must explicitly disable the panel mask.
pub(super) fn tint_uniforms(p: &BorderParameters) -> Vec<Uniform<'static>> {
vec![
Uniform::new("visible_rect", p.clip_rect),
Uniform::new("material_radii", [p.radius; 4]),
Uniform::new("tint", p.color),
Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", p.clip_rect),
Uniform::new("shadow_values", [0.0_f32; 2]),
Uniform::new("panel_count", 0.0_f32),
Uniform::new("panel_fusion", 0.0_f32),
]
}
#[allow(clippy::too_many_arguments)]
pub(super) fn window_tint_element(
resources: &mut RenderResources,
@@ -231,16 +246,7 @@ pub(super) fn window_tint_element(
focus_gradient_line: [0.0; 4],
focus_mix: 0.0,
};
let uniforms = |p: &BorderParameters| {
vec![
Uniform::new("visible_rect", p.clip_rect),
Uniform::new("material_radii", [p.radius; 4]),
Uniform::new("tint", p.color),
Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", p.clip_rect),
Uniform::new("shadow_values", [0.0_f32; 2]),
]
};
let uniforms = tint_uniforms;
let context = renderer.context_id().erased();
let buffers = if resize_fill {
&mut resources.windows.entry(id).or_default().resize_fill
+2 -1
View File
@@ -691,7 +691,8 @@ mod region_geometry_tests {
#[test]
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
use crate::effects::SemanticRole;
for surface in ["flat", "floating", "morph"] {
for style in ferese_panel::PanelStyle::AVAILABLE {
let surface = style.key();
for opacity in [0., 0.4, 1.] {
for reduce in [false, true] {
let source = format!(
+1 -8
View File
@@ -39,14 +39,7 @@ pub(super) fn overview_chrome_element(
if key.1 == OverviewChromePart::Outline {
border_uniforms(p)
} else {
vec![
Uniform::new("visible_rect", p.clip_rect),
Uniform::new("material_radii", [p.radius; 4]),
Uniform::new("tint", p.color),
Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", p.clip_rect),
Uniform::new("shadow_values", [0.0_f32; 2]),
]
tint_uniforms(p)
}
};
let context = renderer.context_id().erased();
@@ -25,6 +25,9 @@ varying vec2 v_coords;
void main() {
float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii);
// The capture includes a sampling halo, but this draw owns only its visible
// region. The shared panel mask also contains neighboring islands.
if (panel_count > 0.5) sdf = max(sdf, panel_box_distance(gl_FragCoord.xy, visible_rect));
float coverage = alpha * presentation_alpha * edge_coverage(sdf);
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
+10
View File
@@ -490,6 +490,7 @@ pub struct Ferese {
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
pub(crate) media_engine: crate::idle_inhibition::media::Engine,
pub(crate) activities: crate::activities::Engine,
pub(crate) automatic_idle_inhibited: bool,
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<DirectBackendState>,
@@ -548,6 +549,8 @@ pub struct Ferese {
#[derive(Clone)]
pub struct RuntimeConfig {
pub(crate) activity_settings: ferese_config::activities::Settings,
pub(crate) activity_matcher: ferese_config::activities::Matcher,
pub(crate) lock_idle: IdleSettings,
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) autostart: Vec<DaemonConfig>,
@@ -672,6 +675,8 @@ impl Ferese {
let default_cursor = crate::cursor::fallback_cursor();
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
let mut activities = crate::activities::Engine::default();
activities.configure(&config.activity_settings, config.activity_matcher.clone());
let mut state = Self {
session_lock_state,
session_lock: crate::session_lock::Lock::default(),
@@ -772,6 +777,7 @@ impl Ferese {
idle_inhibit: config.idle_inhibit,
media_players: Vec::new(),
media_engine: Default::default(),
activities,
automatic_idle_inhibited: false,
active_shortcuts_inhibitor: None,
direct_backend: None,
@@ -850,6 +856,10 @@ impl Ferese {
// 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 layout_changed = changed(&["layout", "scrolling", "workspaces"]);
let bindings_changed = changed(&["bindings", "commands"]);
+11 -7
View File
@@ -33,11 +33,11 @@ struct PreviewWindow {
}
impl Ferese {
/// Morph owns the gap on its reserved edge. Keep the layout's outer gap
/// The panel owns the gap on its reserved edge. Keep the layout's outer gap
/// on the other three edges, and keep floating work-area bounds unchanged.
pub(super) fn morph_layout_bounds(&self, mut bounds: Rect, outer: f64) -> Rect {
pub(super) fn panel_layout_bounds(&self, mut bounds: Rect, outer: f64) -> Rect {
use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone};
if self.panel_style != ferese_panel::PanelStyle::Morph || outer <= 0. || !outer.is_finite() {
if outer <= 0. || !outer.is_finite() {
return bounds;
}
let Some(output) = self
@@ -64,7 +64,7 @@ impl Ferese {
};
let top = f64::from(output_geometry.loc.y + geometry.loc.y);
let bottom = top + f64::from(geometry.size.h);
// Only remove a gap if Morph actually borders the usable area.
// Only remove a gap if the panel actually borders the usable area.
// Another reserved bar on this edge must retain its own spacing.
if policy.anchor.contains(Anchor::TOP)
&& !policy.anchor.contains(Anchor::BOTTOM)
@@ -91,7 +91,7 @@ impl Ferese {
} else {
self.gap_config.outer
};
self.morph_layout_bounds(bounds, outer)
self.panel_layout_bounds(bounds, outer)
}
pub(super) fn visible_workspace_ids(&self) -> HashSet<WorkspaceId> {
let mut visible = self
@@ -321,7 +321,7 @@ impl Ferese {
};
let layout_bounds = self.workspace_layout_bounds(bounds, &workspace.layout);
let maximized_bounds = self.morph_layout_bounds(bounds, self.gap_config.outer);
let maximized_bounds = self.panel_layout_bounds(bounds, self.gap_config.outer);
let workspace_fullscreen = workspace.fullscreen;
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused;
@@ -750,7 +750,7 @@ impl Ferese {
windows.sort_by_key(|w| w.id.0);
let input = PreviewInput {
bounds: self.workspace_layout_bounds(bounds, &workspace.layout),
maximized_bounds: self.morph_layout_bounds(bounds, self.gap_config.outer),
maximized_bounds: self.panel_layout_bounds(bounds, self.gap_config.outer),
fullscreen_bounds: self.full_output_bounds_for(output).unwrap_or(bounds),
gaps: self.gap_config,
focused: workspace.last_focused,
@@ -835,3 +835,7 @@ impl Ferese {
}
}
}
#[cfg(test)]
#[path = "panel_clearance_tests.rs"]
mod panel_clearance_tests;
+7
View File
@@ -97,6 +97,13 @@ impl Ferese {
{
return;
}
let fallback = self
.space
.outputs()
.find(|candidate| *candidate != output)
.map(|output| output.name());
self.activities.store.remove_output(&output.name(), fallback.as_deref());
self.activities.changed();
self.display_presentation.remove_output(output);
self.output_redraw_pending.retain(|pending| pending != output);
if let Some(id) = self.output_ids.get(output) {
@@ -0,0 +1,116 @@
use super::*;
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
use smithay::utils::Transform;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) {
let words = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode];
wire.write_all(
&words
.into_iter()
.chain(args.iter().copied())
.flat_map(u32::to_ne_bytes)
.collect::<Vec<_>>(),
)
.unwrap();
}
#[test]
fn every_panel_style_owns_clearance_at_its_reserved_edge() {
if !crate::startup_tests::private_runtime(
"state::layout::panel_clearance_tests::every_panel_style_owns_clearance_at_its_reserved_edge",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = Output::new(
"clearance".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
state.begin_desktop_transition();
let changes = state
.publish_desktop(vec![DesktopOutput {
output: output.clone(),
identity: "clearance".into(),
mode: Mode {
size: (800, 600).into(),
refresh: 60_000,
},
transform: Transform::Normal,
scale: Scale::Fractional(1.0),
position: (0, 0).into(),
}])
.unwrap();
state.finish_desktop_transition(changes);
let (mut wire, server) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(ClientState::default()))
.unwrap();
let compositor = client
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let layers = client
.create_resource::<ZwlrLayerShellV1, (), Ferese>(&state.display_handle, 4, ())
.unwrap();
request(&mut wire, compositor.id().protocol_id(), 0, &[2]);
let mut namespace = b"ferese-shell-top-bar\0".to_vec();
let length = namespace.len() as u32;
namespace.resize(namespace.len().div_ceil(4) * 4, 0);
let args = [3, 2, 0, 2, length]
.into_iter()
.chain(
namespace
.chunks_exact(4)
.map(|b| u32::from_ne_bytes(b.try_into().unwrap())),
)
.collect::<Vec<_>>();
request(&mut wire, layers.id().protocol_id(), 0, &args);
request(&mut wire, 3, 0, &[800, 36]);
request(&mut wire, 3, 1, &[1 | 4 | 8]); // Top, left and right.
for style in ferese_panel::PanelStyle::ALL {
state.panel_style = style;
for clearance in [0, 5, 24] {
let mut panel = ferese_panel::Panel::from_preset(style);
panel.geometry.height = Some(36.);
panel.geometry.window_clearance = Some(clearance);
panel.geometry.edge_margin = Some(8);
let geometry = panel.resolved_geometry();
request(&mut wire, 3, 2, &[panel.exclusive_zone(false) as u32]);
request(&mut wire, 3, 3, &[geometry.edge_margin as u32, 0, 0, 0]);
request(&mut wire, 2, 6, &[]);
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
let bounds = state.output_bounds_for(&output).unwrap();
let edge = f64::from(geometry.edge_margin + 36 + clearance);
assert_eq!(bounds.y, edge, "{style:?}, clearance={clearance}");
for outer in [0., 12.] {
let adjusted = state.panel_layout_bounds(bounds, outer);
let window = maximized_rect(adjusted, outer);
assert_eq!(window.y, edge, "{style:?}, clearance={clearance}, outer={outer}");
assert_eq!(window.x, bounds.x + outer);
assert_eq!(window.y + window.height, bounds.y + bounds.height - outer);
}
}
}
request(&mut wire, 3, 2, &[0]);
request(&mut wire, 2, 6, &[]);
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
let bounds = state.output_bounds_for(&output).unwrap();
assert_eq!(
state.panel_layout_bounds(bounds, 12.),
bounds,
"an unreserved panel must not remove gaps"
);
}
+6
View File
@@ -166,6 +166,12 @@ impl Ferese {
pub(crate) fn finish_desktop_transition(&mut self, mut changes: DesktopChanges) {
self.desktop_transition.as_mut().unwrap().publishing = true;
let activity_output = self.space.outputs().next().map(|output| output.name());
if let Some(output) = activity_output
&& self.activities.store.attach_output(&output)
{
self.activities.changed();
}
if changes.layout {
self.relayout_desktop();
}