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