diff --git a/Cargo.lock b/Cargo.lock index 3f2193d..28733c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,6 +1740,7 @@ dependencies = [ "ab_glyph", "async-channel", "calloop 0.14.4", + "ferese-activities", "ferese-animation", "ferese-config", "ferese-core", @@ -1765,6 +1766,13 @@ dependencies = [ "zbus", ] +[[package]] +name = "ferese-activities" +version = "0.1.0-beta" +dependencies = [ + "serde", +] + [[package]] name = "ferese-animation" version = "0.1.0-beta" @@ -1777,10 +1785,12 @@ dependencies = [ name = "ferese-config" version = "0.1.0-beta" dependencies = [ + "ferese-activities", "ferese-panel", "ferese-theme-model", "jiff", "kdl", + "regex", "serde", "serde_json", "tempfile", @@ -1798,6 +1808,7 @@ dependencies = [ name = "ferese-ipc" version = "0.1.0-beta" dependencies = [ + "ferese-activities", "ferese-theme-model", "libc", "serde", @@ -1900,6 +1911,7 @@ name = "ferese-shell" version = "0.1.0-beta" dependencies = [ "base64", + "ferese-activities", "ferese-animation", "ferese-config", "ferese-ipc", @@ -1965,6 +1977,7 @@ name = "feresectl" version = "0.1.0-beta" dependencies = [ "clap", + "ferese-activities", "ferese-config", "ferese-ipc", "libc", diff --git a/Cargo.toml b/Cargo.toml index a81c385..ce4ee26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "compositor", "feresectl", "crates/ferese-animation", + "crates/ferese-activities", "crates/ferese-core", "crates/ferese-config", "crates/ferese-theme", diff --git a/README.md b/README.md index 7242916..a90a6e8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Ferese combines a window manager and desktop shell with one configuration. - Scrolling columns, window groups, tree tiling and floating windows with saved size and position - Integrated shell with notifications, media controls, Settings and Control Center - [Custom panels](docs/panel-configuration.md) with seven styles, editable groups and items, and top or bottom placement +- [Live Activities](docs/activities.md) for build status, running commands and progress from connected apps - Workspace overview and recent-window switching - [Continuum animations](docs/configuration.md#animations): interruptible springs, gesture momentum and shared motion controls - Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 07a27b8..4725cef 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -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" } diff --git a/compositor/src/activities.rs b/compositor/src/activities.rs new file mode 100644 index 0000000..176cc84 --- /dev/null +++ b/compositor/src/activities.rs @@ -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>>; + +pub(crate) struct Engine { + pub store: Store, + pub rules: SharedRules, + pub monitor: Option, + process_enabled: bool, + process_sender: Option>, + process_watcher: Option, + pub timer: Option>, + revision: u64, + cached_snapshot: Option>, + publisher: Option, + watchers: BTreeMap)>, + rates: BTreeMap, + windows: BTreeMap, + results: BTreeMap, +} +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, + 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, + ) { + 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 { + 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)>) { + 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) { + self.deliver(vec![(request, sender)]); + } + pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender) { + 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, + output: Option, + window: Option, + ) -> Result { + // 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, + output: Option, + ) -> Result { + self.publish_from(owner, publish, matched, output, None) + } + + fn publish_from( + &mut self, + owner: Owner, + publish: Publish, + matched: Option, + output: Option, + window: Option, + ) -> Result { + 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) -> Result { + 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 { + 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, windows: &[(u32, u64)]) -> Option { + 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")); + } +} diff --git a/compositor/src/activities/publication.rs b/compositor/src/activities/publication.rs new file mode 100644 index 0000000..8f9c45d --- /dev/null +++ b/compositor/src/activities/publication.rs @@ -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); +struct Job { + snapshot: Arc, + recipients: Vec, +} +pub(super) struct Publisher(SyncSender); +impl Publisher { + pub fn new() -> std::io::Result { + let (send, receive) = mpsc::sync_channel::(8); + std::thread::Builder::new() + .name("ferese-activity-snapshots".into()) + .spawn(move || { + let mut cached: Option<(Arc, 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, recipients: Vec) { + 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) { + 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); + } +} diff --git a/compositor/src/activities_process.rs b/compositor/src/activities_process.rs new file mode 100644 index 0000000..f94f1c1 --- /dev/null +++ b/compositor/src/activities_process.rs @@ -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); +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, + ) -> std::io::Result { + 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::, 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::().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)) + } +} diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 29cf003..f664345 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -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>, #[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(), diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 53cc297..c4bcb4d 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -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, + )>, owner: u64, request: Request, response: SyncSender, @@ -49,9 +55,15 @@ struct IpcCall { } #[derive(Debug)] -enum IpcEvent { - Call(IpcCall), +pub(crate) enum IpcEvent { + Call(Box), Closed(u64), + ActivityOwnerExited(ferese_activities::Owner), + ActivityDetected( + ferese_activities::Owner, + Box, + Arc, + ), } 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 Drop for DeadlineTimer { } } -fn accept_connections(listener: UnixListener, sender: channel::SyncSender, signal: LoopSignal) { +fn accept_connections( + listener: UnixListener, + sender: channel::SyncSender, + 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) -> Option Some(ConnectionPermit { active: active.clone() }) } -fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender, signal: LoopSignal, owner: u64) { +#[cfg(test)] +fn serve_connection(stream: UnixStream, sender: channel::SyncSender, signal: LoopSignal, owner: u64) { + serve_connection_with_activities(stream, sender, signal, owner, None) +} +fn serve_connection_with_activities( + mut stream: UnixStream, + sender: channel::SyncSender, + 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 request, Err(ferese_ipc::FrameError::Io(error)) if matches!( @@ -512,6 +638,61 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender 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, +} + +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 + '_ { + self.ancestors + .iter() + .take_while(|(_, fd)| !exited(fd)) + .map(|(pid, _)| *pid) + } +} + +#[derive(Clone)] +pub(crate) struct Monitor(Arc<(Mutex>, Condvar)>); +impl Monitor { + pub fn new(sender: channel::SyncSender) -> Self { + let shared = Arc::new((Mutex::new(BTreeMap::::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) { + self.0 + .0 + .lock() + .expect("activity owners") + .retain(|owner, _| owners.contains(owner)); + } + pub fn verify(&self, stream: &UnixStream, parent: bool) -> io::Result<(VerifiedOwner, Vec)> { + 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 { + 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 { + let mut fd = -1_i32; + let mut len = std::mem::size_of::() 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 { + // 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 { + let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 }; + let mut len = std::mem::size_of::() 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)> { + 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::>() + .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)); + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 83e6b4b..4d49148 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -1,3 +1,5 @@ +mod activities; +mod activities_process; mod backends; mod config; mod cursor; diff --git a/compositor/src/render/corner_tests.rs b/compositor/src/render/corner_tests.rs index 6a83e37..0b4043b 100644 --- a/compositor/src/render/corner_tests.rs +++ b/compositor/src/render/corner_tests.rs @@ -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) -> Vec { + 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 = ((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::::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"); + } + } +} diff --git a/compositor/src/render/decorations.rs b/compositor/src/render/decorations.rs index 9bf141f..ec6397e 100644 --- a/compositor/src/render/decorations.rs +++ b/compositor/src/render/decorations.rs @@ -197,6 +197,21 @@ pub(super) fn border_uniforms(parameters: &BorderParameters) -> Vec Vec> { + 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 diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs index e169a43..dfc0c8f 100644 --- a/compositor/src/render/materials.rs +++ b/compositor/src/render/materials.rs @@ -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!( diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 185cd10..906bea1 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -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(); diff --git a/compositor/src/render/shaders/blur_shader.frag b/compositor/src/render/shaders/blur_shader.frag index ce8d49f..2730ef8 100644 --- a/compositor/src/render/shaders/blur_shader.frag +++ b/compositor/src/render/shaders/blur_shader.frag @@ -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; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index cbaf75b..66ffd6a 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -490,6 +490,7 @@ pub struct Ferese { pub(crate) idle_inhibit: crate::idle_inhibition::Settings, pub(crate) media_players: Vec, 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, pub direct_backend: Option, @@ -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, @@ -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"]); diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index e92b41b..af46a14 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -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 { 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; diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index bb85e6c..4ca95ad 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -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) { diff --git a/compositor/src/state/panel_clearance_tests.rs b/compositor/src/state/panel_clearance_tests.rs new file mode 100644 index 0000000..fa4d61d --- /dev/null +++ b/compositor/src/state/panel_clearance_tests.rs @@ -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::>(), + ) + .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::(&state.display_handle, 6, ()) + .unwrap(); + let layers = client + .create_resource::(&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::>(); + 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" + ); +} diff --git a/compositor/src/state/reconciliation.rs b/compositor/src/state/reconciliation.rs index 3aae95b..e48cf13 100644 --- a/compositor/src/state/reconciliation.rs +++ b/compositor/src/state/reconciliation.rs @@ -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(); } diff --git a/contrib/activities/bash-preexec/LICENSE.md b/contrib/activities/bash-preexec/LICENSE.md new file mode 100644 index 0000000..b4521b3 --- /dev/null +++ b/contrib/activities/bash-preexec/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017 Ryan Caloras and contributors (see https://github.com/rcaloras/bash-preexec) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/contrib/activities/bash-preexec/SOURCE b/contrib/activities/bash-preexec/SOURCE new file mode 100644 index 0000000..318ed62 --- /dev/null +++ b/contrib/activities/bash-preexec/SOURCE @@ -0,0 +1,4 @@ +https://github.com/rcaloras/bash-preexec +Commit: 5ae4758c36e8391fb3932e6ae68c283489fc813d +License: MIT (LICENSE.md) +Files are unmodified. diff --git a/contrib/activities/bash-preexec/bash-preexec.sh b/contrib/activities/bash-preexec/bash-preexec.sh new file mode 100644 index 0000000..97aab11 --- /dev/null +++ b/contrib/activities/bash-preexec/bash-preexec.sh @@ -0,0 +1,566 @@ +# bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions. +# https://github.com/rcaloras/bash-preexec +# +# +# 'preexec' functions are executed before each interactive command is +# executed, with the interactive command as its argument. The 'precmd' +# function is executed before each prompt is displayed. +# +# Author: Ryan Caloras (ryan@bashhub.com) +# Forked from Original Author: Glyph Lefkowitz +# +# V0.7.0 +# + +# General Usage: +# +# 1. Source this file at the end of your bash profile so as not to interfere +# with anything else that's using PROMPT_COMMAND. +# +# 2. Add any precmd or preexec functions by appending them to their arrays: +# e.g. +# precmd_functions+=(my_precmd_function) +# precmd_functions+=(some_other_precmd_function) +# +# preexec_functions+=(my_preexec_function) +# +# 3. Consider changing anything using the DEBUG trap or PROMPT_COMMAND +# to use preexec and precmd instead. Preexisting usages will be +# preserved, but doing so manually may be less surprising. +# +# Note: This module requires two Bash features which you must not otherwise be +# using: the "DEBUG" trap, and the "PROMPT_COMMAND" variable. If you override +# either of these after bash-preexec has been installed it will most likely break. + +# Tell shellcheck what kind of file this is. +# shellcheck shell=bash + +# Make sure this is bash that's running and return otherwise. +# Use POSIX syntax for this line: +if [ -z "${BASH_VERSION-}" ]; then + return 1 +fi + +# We only support Bash 3.1+. +# Note: BASH_VERSINFO is first available in Bash-2.0. +if [[ -z "${BASH_VERSINFO-}" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then + return 1 +fi + +# Avoid duplicate inclusion +if [[ -n "${bash_preexec_imported:-}" || -n "${__bp_imported:-}" ]]; then + return 0 +fi +bash_preexec_imported="defined" + +# WARNING: This variable is no longer used and should not be relied upon. +# Use ${bash_preexec_imported} instead. +# shellcheck disable=SC2034 +__bp_imported="${bash_preexec_imported}" + +# Should be available to each precmd and preexec +# functions, should they want it. $? and $_ are available as $? and $_, but +# $PIPESTATUS is available only in a copy, $BP_PIPESTATUS. +# TODO: Figure out how to restore PIPESTATUS before each precmd or preexec +# function. +__bp_last_ret_value="$?" +BP_PIPESTATUS=("${PIPESTATUS[@]}") +__bp_last_argument_prev_command="$_" + +__bp_inside_precmd=0 +__bp_inside_preexec=0 + +# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install +# shellcheck disable=SC2016 +__bp_install_string='__bp_install "$_"' + +# Fails if any of the given variables are readonly +# Reference https://stackoverflow.com/a/4441178 +__bp_require_not_readonly() { + local var + for var; do + if ! ( unset "$var" 2> /dev/null ); then + echo "bash-preexec requires write access to ${var}" >&2 + return 1 + fi + done +} + +# Remove ignorespace and or replace ignoreboth from HISTCONTROL +# so we can accurately invoke preexec with a command from our +# history even if it starts with a space. +__bp_adjust_histcontrol() { + local histcontrol + histcontrol="${HISTCONTROL:-}" + histcontrol="${histcontrol//ignorespace}" + # Replace ignoreboth with ignoredups + if [[ "$histcontrol" == *"ignoreboth"* ]]; then + histcontrol="ignoredups:${histcontrol//ignoreboth}" + fi + export HISTCONTROL="$histcontrol" +} + +# This variable describes whether we are currently in "interactive mode"; +# i.e. whether this shell has just executed a prompt and is waiting for user +# input. It documents whether the current command invoked by the trace hook is +# run interactively by the user; it's set immediately after the prompt hook, +# and unset as soon as the trace hook is run. +__bp_preexec_interactive_mode="" + +# These global arrays are used to add functions to be run before, or after, +# prompts. Note that Bash < 4.2 does not have the "-g" option of the "declare" +# builtin. We actually do not need to explicitly initialize these arrays. +#declare -ga precmd_functions +#declare -ga preexec_functions + +# Trims leading and trailing whitespace from $2 and writes it to the variable +# name passed as $1 +__bp_trim_whitespace() { + local var=${1:?} text=${2:-} + text="${text#"${text%%[![:space:]]*}"}" # remove leading whitespace characters + text="${text%"${text##*[![:space:]]}"}" # remove trailing whitespace characters + printf -v "$var" '%s' "$text" +} + + +# Trims whitespace and removes any leading or trailing semicolons from $2 and +# writes the resulting string to the variable name passed as $1. This also +# removes the no-op colons, which are converted from the hooks to remove. Used +# for manipulating substrings in PROMPT_COMMAND +__bp_sanitize_string() { + local var=${1:?} sanitized=${2:-} + + local unset_extglob= + if ! shopt -q extglob; then + unset_extglob=yes + shopt -s extglob + fi + + # We specify newline character through the variable `nl' because $'\n' + # inside "${var//...}" is treated literally as "\$'\\n'" when `extquote' is + # unset (shopt -u extquote). (Note: Bash 5.2's extquote seems to be buggy.) + local tmp nl=$'\n' + while + # Note: Quoting parameter expansions $nl in PAT of ${var//PAT/REP} is + # required by shellcheck. On the other hand, we should not quote the + # parameter expansions $nl in REP because the quotes will remain in the + # replaced result with `shopt -s compat42'. + # Note: We use ?(+([[:blank:]])) instead of *([[:blank:]]) to work + # around a bug of Bash 3.2 that *(...) is not properly processed as + # extglob at the beginning of the pattern in ${var//pat/rep}. + tmp="${sanitized//?(+([[:blank:]]))[";$nl"]*([[:blank:]]):*([[:blank:]])[";$nl"]*([[:blank:]])/$nl}" + [[ "$tmp" != "$sanitized" ]] + do + sanitized="$tmp" + done + sanitized="${sanitized#:*([[:blank:]])[";$nl"]}" + sanitized="${sanitized%[";$nl"]*([[:blank:]]):}" + __bp_trim_whitespace sanitized "$sanitized" + sanitized=${sanitized%;} + sanitized=${sanitized#;} + __bp_trim_whitespace sanitized "$sanitized" + if [[ "$sanitized" == ":" ]]; then + sanitized= + fi + printf -v "$var" '%s' "$sanitized" + + if [[ -n "$unset_extglob" ]]; then + shopt -u extglob + fi +} + + +# Bash >= 5.1 supports the array version of PROMPT_COMMAND. +__bp_use_array_prompt_command() { + (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )) +} + + +# Remove $1 and sanitize each elements of PROMPT_COMMAND. We want to keep +# PROMPT_COMMAND scalar in bash < 5.1 because some configuration tests the +# support for the array PROMPT_COMMAND by checking the array attribute of +# PROMPT_COMMAND. +__bp_remove_command_from_prompt_command() { + local removed_command="${1-}" + if __bp_use_array_prompt_command; then + local i sanitized_prompt_command + for i in "${!PROMPT_COMMAND[@]}"; do + sanitized_prompt_command="${PROMPT_COMMAND[i]:-}" + sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" + __bp_sanitize_string sanitized_prompt_command "$sanitized_prompt_command" + if [[ -n "$sanitized_prompt_command" ]]; then + PROMPT_COMMAND[i]="$sanitized_prompt_command" + else + unset -v 'PROMPT_COMMAND[i]' + fi + done + else + local sanitized_prompt_command="${PROMPT_COMMAND:-}" + sanitized_prompt_command="${sanitized_prompt_command//"$removed_command"/:}" # no-op + __bp_sanitize_string PROMPT_COMMAND "$sanitized_prompt_command" + fi +} + + +# This function is installed as part of the PROMPT_COMMAND; +# It sets a variable to indicate that the prompt was just displayed, +# to allow the DEBUG trap to know that the next command is likely interactive. +__bp_interactive_mode() { + if [[ "${1-}" != "force" && ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then + # When this function is not called from the top level, the current + # function call is probably performed via PROMPT_COMMAND saved by + # another framework (e.g., starship). In this case, we do not want to + # turn on the "interactive mode" here. + return 0 + fi + + __bp_preexec_interactive_mode="on" +} + + +# This function is installed as part of the PROMPT_COMMAND. +# It will invoke any functions defined in the precmd_functions array. +__bp_precmd_invoke_cmd() { + # Save the returned value and the last argument from our last command, and + # the returned value from each process in its pipeline. Note: this MUST be + # the first thing done in this function. + # BP_PIPESTATUS may be unused, ignore + # shellcheck disable=SC2034 + __bp_last_ret_value="$?" __bp_last_argument_prev_command="$_" \ + BP_PIPESTATUS=("${PIPESTATUS[@]}") + + + # Don't invoke precmds if we are inside an execution of an "original + # prompt command" by another precmd execution loop. This avoids infinite + # recursion. + if (( __bp_inside_precmd > 0 )); then + return "$__bp_last_ret_value" + fi + + # Check and adjust PROMPT_COMMAND to make sure that PROMPT_COMMAND has the + # form "__bp_precmd_invoke_cmd; ...; __bp_interactive_mode" + if ! __bp_install_prompt_command; then + if [[ "${1-}" != "force" && ! "${BATS_VERSION-}" ]] && (( ${#FUNCNAME[*]} > 1 )); then + # When PROMPT_COMMAND is already properly set up but this function + # is not called from the top level, the current function call is + # probably performed via PROMPT_COMMAND saved by another framework + # (e.g., starship). In this case, we do not need to invoke precmd + # because it is supposed to be already processed by the top-level + # __bp_precmd_invoke_cmd. + return "$__bp_last_ret_value" + fi + fi + + local __bp_inside_precmd=1 + __bp_invoke_precmd_functions "$__bp_last_ret_value" "$__bp_last_argument_prev_command" + + __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command" +} + +# This function invokes every function defined in the "precmd_functions" array. +# This function receives the arguments $1 and $2 for $? and $_, respectively, +# which will be set for each precmd function. This function returns the last +# non-zero exit status of the hook functions. If there is no error, this +# function returns 0. +__bp_invoke_precmd_functions() { + local lastexit=$1 lastarg=$2 + # Invoke every function defined in our function array. + local precmd_function + local precmd_function_ret_value + local precmd_ret_value=0 + for precmd_function in "${precmd_functions[@]}"; do + + # Only execute this function if it actually exists. + # Test existence of functions with: declare -[Ff] + if type -t "$precmd_function" 1>/dev/null; then + __bp_set_ret_value "$lastexit" "$lastarg" + # Quote our function invocation to prevent issues with IFS + "$precmd_function" + precmd_function_ret_value=$? + if [[ "$precmd_function_ret_value" != 0 ]]; then + precmd_ret_value="$precmd_function_ret_value" + fi + fi + done + + __bp_set_ret_value "$precmd_ret_value" +} + +# Sets a return value in $?. We may want to get access to the $? variable in our +# precmd functions. This is available for instance in zsh. We can simulate it in bash +# by setting the value here. +__bp_set_ret_value() { + return ${1:+"$1"} +} + +__bp_in_prompt_command() { + + local prompt_command_array IFS=$'\n;' + read -rd '' -a prompt_command_array <<< "${PROMPT_COMMAND[*]:-}" + + local trimmed_arg + __bp_trim_whitespace trimmed_arg "${1:-}" + + local command trimmed_command + for command in "${prompt_command_array[@]:-}"; do + __bp_trim_whitespace trimmed_command "$command" + if [[ "$trimmed_command" == "$trimmed_arg" ]]; then + return 0 + fi + done + + return 1 +} + +__bp_load_this_command_from_history() { + this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + this_command="${this_command#*[[:digit:]][* ] }" + + # Sanity check to make sure we have something to invoke our function with. + [[ -n "$this_command" ]] +} + +# This function is installed as the DEBUG trap. It is invoked before each +# interactive prompt display. Its purpose is to inspect the current +# environment to attempt to detect if the current command is being invoked +# interactively, and invoke 'preexec' if so. +__bp_preexec_invoke_exec() { + local lastarg=$_ + + # Don't invoke preexecs if we are inside of another preexec. + if (( __bp_inside_preexec > 0 )); then + return + fi + local __bp_inside_preexec=1 + + # Checks if the file descriptor is not standard out (i.e. '1') + # __bp_delay_install checks if we're in test. Needed for bats to run. + # Prevents preexec from being invoked for functions in PS1 + if [[ ! -t 1 && -z "${__bp_delay_install:-}" ]]; then + return + fi + + if [[ -n "${COMP_POINT:-}" || -n "${READLINE_POINT:-}" ]]; then + # We're in the middle of a completer or a keybinding set up by "bind + # -x". This obviously can't be an interactively issued command. + return + fi + if [[ -z "${__bp_preexec_interactive_mode:-}" ]]; then + # We're doing something related to displaying the prompt. Let the + # prompt set the title instead of me. + return + else + # If we're in a subshell, then the prompt won't be re-displayed to put + # us back into interactive mode, so let's not set the variable back. + # In other words, if you have a subshell like + # (sleep 1; sleep 2) + # You want to see the 'sleep 2' as a set_command_title as well. + if [[ 0 -eq "${BASH_SUBSHELL:-}" ]]; then + __bp_preexec_interactive_mode="" + fi + fi + + if __bp_in_prompt_command "${BASH_COMMAND:-}"; then + # If we're executing something inside our prompt_command then we don't + # want to call preexec. Bash prior to 3.1 can't detect this at all :/ + __bp_preexec_interactive_mode="" + return + fi + + # Save the contents of $_ so that it can be restored later on. + # https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702 + __bp_last_argument_prev_command=$lastarg + + local this_command + __bp_load_this_command_from_history || return + + __bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command" + local preexec_ret_value=$? + + # Restore the last argument of the last executed command, and set the return + # value of the DEBUG trap to be the return code of the last preexec function + # to return an error. + # If `extdebug` is enabled a non-zero return value from any preexec function + # will cause the user's command not to execute. + # Run `shopt -s extdebug` to enable + __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command" +} + +__bp_invoke_preexec_from_ps0() { + __bp_last_argument_prev_command="${1:-}" + + local this_command + __bp_load_this_command_from_history || return + + __bp_invoke_preexec_functions "${__bp_last_ret_value:-}" "$__bp_last_argument_prev_command" "$this_command" +} + +# This function invokes every function defined in the "preexec_functions" +# array. This function receives the arguments $1 and $2 for $? and $_, +# respectively, which will be set for each preexec function. The third +# argument $3 specifies the user command that is going to be executed +# (corresponding to BASH_COMMAND in the DEBUG trap). This function returns the +# last non-zero exit status from the preexec functions. If there is no error, +# this function returns `0`. +__bp_invoke_preexec_functions() { + local lastexit=$1 lastarg=$2 this_command=$3 + local preexec_function + local preexec_function_ret_value + local preexec_ret_value=0 + for preexec_function in "${preexec_functions[@]:-}"; do + + # Only execute each function if it actually exists. + # Test existence of function with: declare -[fF] + if type -t "$preexec_function" 1>/dev/null; then + __bp_set_ret_value "$lastexit" "$lastarg" + # Quote our function invocation to prevent issues with IFS + "$preexec_function" "$this_command" + preexec_function_ret_value="$?" + if [[ "$preexec_function_ret_value" != 0 ]]; then + preexec_ret_value="$preexec_function_ret_value" + fi + fi + done + __bp_set_ret_value "$preexec_ret_value" +} + +__bp_hook_preexec_into_debug() { + local trap_string + trap_string=$(trap -p DEBUG) + trap '__bp_preexec_invoke_exec "$_"' DEBUG + + # Preserve any prior DEBUG trap as a preexec function + eval "local trap_argv=(${trap_string:-})" + local prior_trap=${trap_argv[2]:-} + if [[ -n "$prior_trap" ]]; then + eval '__bp_original_debug_trap() { + '"$prior_trap"' + }' + preexec_functions+=(__bp_original_debug_trap) + fi + + # Adjust our HISTCONTROL Variable if needed. + __bp_adjust_histcontrol + + # Issue #25. Setting debug trap for subshells causes sessions to exit for + # backgrounded subshell commands (e.g. (pwd)& ). Believe this is a bug in Bash. + # + # Disabling this by default. It can be enabled by setting this variable. + if [[ -n "${__bp_enable_subshells:-}" ]]; then + + # Set so debug trap will work be invoked in subshells. + set -o functrace > /dev/null 2>&1 + shopt -s extdebug > /dev/null 2>&1 + fi +} + +__bp_hook_preexec_into_ps0() { + # shellcheck disable=SC2016 + PS0=${PS0-}'${ __bp_invoke_preexec_from_ps0 "$_" >&2; }' + + # Adjust our HISTCONTROL Variable if needed. + __bp_adjust_histcontrol +} + +if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then + __bp_hook_preexec_proc=__bp_hook_preexec_into_ps0 +else + __bp_hook_preexec_proc=__bp_hook_preexec_into_debug +fi + +__bp_install() { + local lastexit=$? lastarg=$_ + # Exit if we already have this installed. + # shellcheck disable=SC2016 + if [[ "${PROMPT_COMMAND[*]:-}" == *'__bp_precmd_invoke_cmd "$_"'* ]]; then + return 1 + fi + + "$__bp_hook_preexec_proc" + + # Remove setting our trap install string and sanitize the existing prompt command string + __bp_remove_command_from_prompt_command "$__bp_install_string" + + __bp_install_prompt_command || true + + # Add two functions to our arrays for convenience + # of definition. + precmd_functions+=(precmd) + preexec_functions+=(preexec) + + # Invoke our two functions manually that were added to $PROMPT_COMMAND + __bp_set_ret_value "$lastexit" "$lastarg" + __bp_precmd_invoke_cmd force + __bp_interactive_mode force +} + +# Note: We need to add the "trace" attribute to these functions so that "trap +# ... DEBUG" inside "__bp_install" and "__bp_hook_preexec_into_debug" takes +# effect even when there is an existing DEBUG trap. +declare -ft __bp_install __bp_hook_preexec_into_debug + +# Encloses PROMPT_COMMAND hooks within __bp_precmd_invoke_cmd and +# __bp_interactive_mode. If all the PROMPT_COMMAND hooks are already surrounded +# by __bp_precmd_invoke_cmd and __bp_interactive_mode, the function exits with +# status 1. +__bp_install_prompt_command() { + local prompt_command="${PROMPT_COMMAND:-}" + if __bp_use_array_prompt_command; then + local IFS=$'\n' + prompt_command="${PROMPT_COMMAND[*]:-}" + IFS=$' \t\n' + fi + + # Exit if we already have a properly set-up hooks in PROMPT_COMMAND + # shellcheck disable=SC2016 + local prologue='__bp_precmd_invoke_cmd "$_"' + local epilogue='__bp_interactive_mode' + if [[ "$prompt_command" == "$prologue"$'\n'* && "$prompt_command" == *$'\n'"$epilogue" ]]; then + return 1 + fi + + __bp_remove_command_from_prompt_command "$prologue" + __bp_remove_command_from_prompt_command "$epilogue" + + # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've + # actually entered something. + # shellcheck disable=SC2128,SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND=$prologue${PROMPT_COMMAND:+$'\n'$PROMPT_COMMAND} + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=("$epilogue") + else + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=$'\n'$epilogue + fi + return 0 +} + +# Sets an installation string as part of our PROMPT_COMMAND to install +# after our session has started. This allows bash-preexec to be included +# at any point in our bash profile. +__bp_install_after_session_init() { + # bash-preexec needs to modify these variables in order to work correctly + # if it can't, just stop the installation + __bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return + if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then + __bp_require_not_readonly PS0 || return + fi + + if __bp_use_array_prompt_command; then + PROMPT_COMMAND+=("${__bp_install_string}") + else + local sanitized_prompt_command + __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}" + if [[ -n "$sanitized_prompt_command" ]]; then + # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND=${sanitized_prompt_command}$'\n' + fi + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=${__bp_install_string} + fi +} + +# Run our install so long as we're not delaying it. +if [[ -z "${__bp_delay_install:-}" ]]; then + __bp_install_after_session_init +fi diff --git a/contrib/activities/ferese.bash b/contrib/activities/ferese.bash new file mode 100644 index 0000000..041e868 --- /dev/null +++ b/contrib/activities/ferese.bash @@ -0,0 +1,47 @@ +# Opt in by sourcing this file in an interactive Bash shell. Rules must also +# enable activities.sources.shell-hook. No command is executed by these hooks. +[[ $- == *i* ]] || return +[[ ${__ferese_installed:-} == 1 ]] && return +__ferese_installed=1 +__ferese_sequence=0 +__ferese_phase=prompt +_ferese_preexec() { + local saved=$? command=${1:-$BASH_COMMAND} + [[ $__ferese_phase == armed && $command != _ferese_* ]] || return "$saved" + __ferese_phase=running + ((__ferese_sequence+=1)) + __ferese_local="bash-$$-$__ferese_sequence" + feresectl activity hook "$__ferese_local" --command "${command:0:4096}" --cwd "${PWD:0:1024}" >/dev/null 2>&1 + return "$saved" +} +_ferese_finish() { + local saved=$? + __ferese_phase=prompt + if [[ -n ${__ferese_local:-} ]]; then + if ((saved == 130 || saved == 143)); then + feresectl activity cancel "$__ferese_local" >/dev/null 2>&1 + elif ((saved == 0)); then + feresectl activity end "$__ferese_local" --result ok >/dev/null 2>&1 + else + feresectl activity end "$__ferese_local" --result fail >/dev/null 2>&1 + fi + unset __ferese_local + fi + return "$saved" +} +_ferese_arm() { local saved=$?; __ferese_phase=armed; return "$saved"; } +if [[ -n ${bash_preexec_imported:-} ]] || declare -p preexec_functions >/dev/null 2>&1; then + preexec_functions+=(_ferese_preexec) +else + # Bash DEBUG has a single owner and is not inherited reliably by sourced + # files. The established bash-preexec arrays compose without replacing it. + printf '%s\n' 'Ferese: load bash-preexec before this hook to preserve existing DEBUG integrations.' >&2 + unset __ferese_installed + return + +fi +if [[ $(declare -p PROMPT_COMMAND 2>/dev/null) == 'declare -a'* ]]; then + PROMPT_COMMAND=(_ferese_finish "${PROMPT_COMMAND[@]}" _ferese_arm) +else + PROMPT_COMMAND=(_ferese_finish "${PROMPT_COMMAND:-:}" _ferese_arm) +fi diff --git a/contrib/activities/ferese.fish b/contrib/activities/ferese.fish new file mode 100644 index 0000000..0a13091 --- /dev/null +++ b/contrib/activities/ferese.fish @@ -0,0 +1,26 @@ +# Source explicitly from config.fish. Named event handlers compose with others. +status is-interactive; or return +set -q __ferese_installed; and return +set -g __ferese_installed 1 +set -g __ferese_sequence 0 +function __ferese_preexec --on-event fish_preexec + set -l saved $status + set -g __ferese_sequence (math $__ferese_sequence + 1) + set -g __ferese_local "fish-$fish_pid-$__ferese_sequence" + feresectl activity hook "$__ferese_local" --command (string sub -l 4096 -- "$argv[1]") --cwd (string sub -l 1024 -- "$PWD") >/dev/null 2>&1 + return $saved +end +function __ferese_postexec --on-event fish_postexec + set -l saved $status + if set -q __ferese_local + if test $saved -eq 130; or test $saved -eq 143 + feresectl activity cancel "$__ferese_local" >/dev/null 2>&1 + else if test $saved -eq 0 + feresectl activity end "$__ferese_local" --result ok >/dev/null 2>&1 + else + feresectl activity end "$__ferese_local" --result fail >/dev/null 2>&1 + end + set -e __ferese_local + end + return $saved +end diff --git a/contrib/activities/ferese.zsh b/contrib/activities/ferese.zsh new file mode 100644 index 0000000..f923c8a --- /dev/null +++ b/contrib/activities/ferese.zsh @@ -0,0 +1,29 @@ +# Source explicitly from .zshrc; add-zsh-hook preserves other integrations. +[[ -o interactive ]] || return +[[ ${_ferese_installed:-} == 1 ]] && return +_ferese_installed=1 +_ferese_sequence=0 +autoload -Uz add-zsh-hook +_ferese_preexec() { + local saved=$? + ((_ferese_sequence+=1)) + _ferese_local="zsh-$$-$_ferese_sequence" + feresectl activity hook "$_ferese_local" --command "${1[1,4096]}" --cwd "${PWD[1,1024]}" >/dev/null 2>&1 + return "$saved" +} +_ferese_precmd() { + local saved=$? + if [[ -n ${_ferese_local:-} ]]; then + if (( saved == 130 || saved == 143 )); then + feresectl activity cancel "$_ferese_local" >/dev/null 2>&1 + elif (( saved == 0 )); then + feresectl activity end "$_ferese_local" --result ok >/dev/null 2>&1 + else + feresectl activity end "$_ferese_local" --result fail >/dev/null 2>&1 + fi + unset _ferese_local + fi + return "$saved" +} +add-zsh-hook preexec _ferese_preexec +add-zsh-hook precmd _ferese_precmd diff --git a/crates/ferese-activities/Cargo.toml b/crates/ferese-activities/Cargo.toml new file mode 100644 index 0000000..3d180d2 --- /dev/null +++ b/crates/ferese-activities/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ferese-activities" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde.workspace = true diff --git a/crates/ferese-activities/src/lib.rs b/crates/ferese-activities/src/lib.rs new file mode 100644 index 0000000..eaf185e --- /dev/null +++ b/crates/ferese-activities/src/lib.rs @@ -0,0 +1,553 @@ +//! Typed activity data and deterministic policy. The host supplies verified owners +//! and suspend-aware milliseconds; this crate performs no I/O or rendering. +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub type Time = u64; +const BOOST_MS: u64 = 5_000; +pub const MAX_TEXT: usize = 256; +pub const MAX_LEASE_MS: u64 = 300_000; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Owner { + pub pid: u32, + pub birth: u64, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Id { + pub owner: Owner, + pub local: String, + pub generation: u64, + pub session: u64, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", content = "value", rename_all = "kebab-case", deny_unknown_fields)] +pub enum Value { + Indeterminate, + Progress(f32), + Text(String), + Countdown { remaining_ms: u64, sampled_at: Time }, +} + +impl Value { + pub fn remaining_ms(&self, now: Time) -> Option { + match self { + Self::Countdown { + remaining_ms, + sampled_at, + } => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", content = "data", rename_all = "kebab-case", deny_unknown_fields)] +pub enum Template { + Progress, + Timer, + Media { artist: String, playing: bool }, + List { rows: Vec }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Source { + Publisher, + Command, + Media, + Capture, + Download, + Timer, + Notification, + Process, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum State { + Running, + Done, + Failed, + Cancelled, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Data { + pub source: Source, + pub output: Option, + pub priority: u8, + pub icon: String, + pub title: String, + pub value: Value, + pub template: Template, +} + +impl Data { + pub fn validate(&mut self, now: Time) -> Result<(), &'static str> { + text(&self.title)?; + if self.output.as_ref().is_some_and(|output| text(output).is_err()) { + return Err("invalid output name"); + } + if self.icon.len() > 64 || !self.icon.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { + return Err("icon must be a symbolic name"); + } + if !matches!( + self.icon.as_str(), + "hammer" | "timer" | "download" | "media" | "microphone" | "camera" | "call" | "notification" | "ferese" + ) { + self.icon = "ferese".into(); + } + match &mut self.value { + Value::Progress(progress) if !progress.is_finite() || !(0.0..=1.0).contains(progress) => { + return Err("progress must be finite and between zero and one"); + } + Value::Text(value) => text(value)?, + Value::Countdown { + remaining_ms, + sampled_at, + } => { + if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 { + return Err("countdown exceeds seven days"); + } + *sampled_at = now; + } + _ => {} + } + match &self.template { + Template::Timer if !matches!(self.value, Value::Countdown { .. }) => { + return Err("timer requires a countdown"); + } + Template::Progress if matches!(self.value, Value::Countdown { .. }) => { + return Err("countdown requires timer template"); + } + Template::Media { artist, .. } => { + text(artist)?; + if !matches!(self.value, Value::Text(_) | Value::Indeterminate | Value::Progress(_)) { + return Err("invalid media value"); + } + } + Template::List { rows } => { + if rows.len() > 8 { + return Err("list exceeds eight rows"); + } + for row in rows { + text(row)?; + } + if !matches!(self.value, Value::Text(_) | Value::Indeterminate) { + return Err("invalid list value"); + } + } + _ => {} + } + Ok(()) + } +} + +fn text(value: &str) -> Result<(), &'static str> { + if value.len() > MAX_TEXT || value.chars().any(char::is_control) { + Err("text exceeds its budget or contains control characters") + } else { + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Limits { + pub max_visible: usize, + pub show_badge: bool, + pub max_total: usize, + pub owner_ceiling: u8, + pub start_delay_ms: u64, + pub result_flash_ms: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_visible: 2, + show_badge: true, + max_total: 128, + owner_ceiling: 160, + start_delay_ms: 300, + result_flash_ms: 1_500, + } + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct Activity { + pub id: Id, + pub data: Data, + pub state: State, + pub created_at: Time, + pub eligible_at: Time, + pub refreshed_at: Time, + pub lease_deadline: Option