Add Live Activities as configurable bar items (#30)

* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

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

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

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

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

* Prevent panel blur from repainting neighboring islands

---------

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

No files matched your search

Generated
+13
View File
@@ -1740,6 +1740,7 @@ dependencies = [
"ab_glyph", "ab_glyph",
"async-channel", "async-channel",
"calloop 0.14.4", "calloop 0.14.4",
"ferese-activities",
"ferese-animation", "ferese-animation",
"ferese-config", "ferese-config",
"ferese-core", "ferese-core",
@@ -1765,6 +1766,13 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "ferese-activities"
version = "0.1.0-beta"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "ferese-animation" name = "ferese-animation"
version = "0.1.0-beta" version = "0.1.0-beta"
@@ -1777,10 +1785,12 @@ dependencies = [
name = "ferese-config" name = "ferese-config"
version = "0.1.0-beta" version = "0.1.0-beta"
dependencies = [ dependencies = [
"ferese-activities",
"ferese-panel", "ferese-panel",
"ferese-theme-model", "ferese-theme-model",
"jiff", "jiff",
"kdl", "kdl",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -1798,6 +1808,7 @@ dependencies = [
name = "ferese-ipc" name = "ferese-ipc"
version = "0.1.0-beta" version = "0.1.0-beta"
dependencies = [ dependencies = [
"ferese-activities",
"ferese-theme-model", "ferese-theme-model",
"libc", "libc",
"serde", "serde",
@@ -1900,6 +1911,7 @@ name = "ferese-shell"
version = "0.1.0-beta" version = "0.1.0-beta"
dependencies = [ dependencies = [
"base64", "base64",
"ferese-activities",
"ferese-animation", "ferese-animation",
"ferese-config", "ferese-config",
"ferese-ipc", "ferese-ipc",
@@ -1965,6 +1977,7 @@ name = "feresectl"
version = "0.1.0-beta" version = "0.1.0-beta"
dependencies = [ dependencies = [
"clap", "clap",
"ferese-activities",
"ferese-config", "ferese-config",
"ferese-ipc", "ferese-ipc",
"libc", "libc",
+1
View File
@@ -3,6 +3,7 @@ members = [
"compositor", "compositor",
"feresectl", "feresectl",
"crates/ferese-animation", "crates/ferese-animation",
"crates/ferese-activities",
"crates/ferese-core", "crates/ferese-core",
"crates/ferese-config", "crates/ferese-config",
"crates/ferese-theme", "crates/ferese-theme",
+1
View File
@@ -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 - Scrolling columns, window groups, tree tiling and floating windows with saved size and position
- Integrated shell with notifications, media controls, Settings and Control Center - 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 - [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 - Workspace overview and recent-window switching
- [Continuum animations](docs/configuration.md#animations): interruptible springs, gesture momentum and shared motion controls - [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 - Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-activities = { path = "../crates/ferese-activities" }
ferese-panel = { path = "../crates/ferese-panel" } ferese-panel = { path = "../crates/ferese-panel" }
ferese-shape.workspace = true ferese-shape.workspace = true
ferese-config = { path = "../crates/ferese-config" } ferese-config = { path = "../crates/ferese-config" }
+771
View File
@@ -0,0 +1,771 @@
//! Session broker: verified process identities, one-shot policy deadlines and
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
mod publication;
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
use ferese_ipc::{
Response,
activities::{Publish, Snapshot},
};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub(crate) struct Rules {
pub enabled: bool,
pub matcher: ferese_config::activities::Matcher,
}
pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
pub(crate) struct Engine {
pub store: Store,
pub rules: SharedRules,
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
process_enabled: bool,
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
process_watcher: Option<crate::activities_process::Watcher>,
pub timer: Option<crate::ipc::DeadlineTimer<crate::Ferese>>,
revision: u64,
cached_snapshot: Option<Arc<Snapshot>>,
publisher: Option<publication::Publisher>,
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
rates: BTreeMap<Owner, (u64, u16)>,
windows: BTreeMap<Id, u64>,
results: BTreeMap<Id, ferese_config::activities::ResultText>,
}
impl Default for Engine {
fn default() -> Self {
let settings = ferese_config::activities::Settings::default();
Self {
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
rules: Arc::new(RwLock::new(Arc::new(Rules {
enabled: false,
matcher: ferese_config::activities::Matcher::new(&settings).expect("built-in rules"),
}))),
monitor: None,
process_enabled: false,
process_sender: None,
process_watcher: None,
timer: None,
revision: 1,
cached_snapshot: None,
publisher: publication::Publisher::new()
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
.ok(),
watchers: BTreeMap::new(),
rates: BTreeMap::new(),
results: BTreeMap::new(),
windows: BTreeMap::new(),
}
}
}
impl Engine {
pub fn detect_process(
&mut self,
owner: Owner,
matched: ferese_config::activities::Matched,
output: Option<String>,
fd: &std::os::fd::OwnedFd,
) {
if crate::ipc::activity_owner::exited(fd) {
return;
}
// A dispatched detection is not an admission acknowledgement. Retry
// starts after rejection, and refresh existing records without restarting
// their eligibility delay or recency boost.
if self.resolve(owner, "process", None).is_ok() {
let _ = self.publish(
owner,
Publish::Update {
local: "process".into(),
handle: None,
value: None,
lease_ms: Some(5_000),
},
None,
None,
);
} else {
let _ = self.publish(
owner,
Publish::Hook {
local: "process".into(),
command: String::new(),
cwd: String::new(),
},
Some(matched),
output,
);
}
}
pub fn configure(
&mut self,
settings: &ferese_config::activities::Settings,
matcher: ferese_config::activities::Matcher,
) {
self.store.limits = settings.limits();
self.process_enabled = settings.sources.process_watch;
*self.rules.write().expect("activity rules") = Arc::new(Rules {
enabled: settings.sources.shell_hook,
matcher,
});
self.refresh_process_source();
self.changed();
}
pub fn attach_process_source(
&mut self,
sender: smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>,
) {
self.process_sender = Some(sender);
self.refresh_process_source();
}
fn refresh_process_source(&mut self) {
if !self.process_enabled {
self.process_watcher = None;
} else if self.process_watcher.is_none()
&& let Some(sender) = &self.process_sender
{
match crate::activities_process::Watcher::start(self.rules.clone(), sender.clone()) {
Ok(watcher) => self.process_watcher = Some(watcher),
Err(error) => tracing::warn!(%error, "could not start optional activity process source"),
}
}
}
pub fn snapshot(&mut self) -> Arc<Snapshot> {
if let Some(snapshot) = &self.cached_snapshot {
return snapshot.clone();
}
let snapshot = Arc::new(Snapshot {
version: ferese_ipc::activities::VERSION,
revision: self.revision,
now_ms: ferese_ipc::activities::now_ms(),
limits: self.store.limits,
activities: self.store.entries().cloned().collect(),
windows: self
.windows
.iter()
.map(|(activity, window)| ferese_ipc::activities::ActivityWindow {
activity: activity.clone(),
window: *window,
})
.collect(),
});
self.cached_snapshot = Some(snapshot.clone());
snapshot
}
pub fn remove_connection(&mut self, owner: u64) {
self.watchers.remove(&owner);
}
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
if recipients.is_empty() {
return;
}
let snapshot = self.snapshot();
if let Some(publisher) = &self.publisher {
publisher.send(snapshot, recipients);
} else {
publication::unavailable(recipients);
}
}
pub fn get(&mut self, request: u64, sender: SyncSender<Response>) {
self.deliver(vec![(request, sender)]);
}
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
if since != self.revision {
self.get(request, sender);
} else if self.watchers.len() < 32 {
self.watchers.insert(connection, (request, sender));
} else {
let _ = sender.try_send(Response::error(
request,
"subscriber_limit",
"Activity subscriber limit",
));
}
}
pub fn owner_exit(&mut self, owner: Owner) {
self.store.owner_exit(owner);
self.rates.remove(&owner);
self.changed();
}
pub fn changed(&mut self) {
self.revision = self.revision.saturating_add(1);
self.cached_snapshot = None;
self.results
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
self.windows
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
let recipients = std::mem::take(&mut self.watchers).into_values().collect();
self.deliver(recipients);
self.schedule();
}
pub fn tick(&mut self) {
self.store.advance(ferese_ipc::activities::now_ms());
self.changed();
}
pub fn schedule(&self) {
if let Some(monitor) = &self.monitor {
monitor.retain(
&self
.store
.entries()
.filter(|entry| entry.state == State::Running)
.map(|entry| entry.id.owner)
.collect(),
);
}
if let Some(timer) = &self.timer {
let now = ferese_ipc::activities::now_ms();
timer.update(
self.store
.next_deadline(now)
.map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(now))),
);
}
}
pub fn publish_verified(
&mut self,
verified: crate::ipc::activity_owner::VerifiedOwner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
window: Option<u64>,
) -> Result<serde_json::Value, String> {
// Admission and publication run in one compositor dispatch. A queued
// request owns its pidfd, but is not yet subject to monitor pruning.
let owner = self
.monitor
.as_ref()
.ok_or("Activity owner monitor unavailable")?
.register(verified)
.map_err(|error| error.to_string())?;
let result = self
.publish_from(owner, publish, matched, output, window)
.map_err(str::to_owned);
self.schedule();
result
}
pub fn publish(
&mut self,
owner: Owner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
) -> Result<serde_json::Value, &'static str> {
self.publish_from(owner, publish, matched, output, None)
}
fn publish_from(
&mut self,
owner: Owner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
window: Option<u64>,
) -> Result<serde_json::Value, &'static str> {
let started = match &publish {
Publish::Start { local, .. } | Publish::Run { local, .. } | Publish::Hook { local, .. } => {
Some(local.clone())
}
_ => None,
};
let now = ferese_ipc::activities::now_ms();
if matches!(publish, Publish::Hook { .. }) && matched.is_none() {
return Ok(json!({"matched":false}));
}
self.rates.retain(|_, (at, _)| now.saturating_sub(*at) < 60_000);
if self.rates.len() >= 128 && !self.rates.contains_key(&owner) {
return Err("owner publication quota exceeded");
}
let rate = self.rates.entry(owner).or_insert((now, 0));
if now.saturating_sub(rate.0) >= 1_000 {
*rate = (now, 0);
}
// Each connection waits for acknowledgement; excess replaceable updates
// are explicitly rejected so terminal events retain capacity.
if (matches!(publish, Publish::Update { .. }) && rate.1 >= 30)
|| (matches!(
publish,
Publish::Start { .. } | Publish::Hook { .. } | Publish::Run { .. }
) && rate.1 >= 10)
{
return Err("publication rate exceeded");
}
rate.1 = rate.1.saturating_add(1);
// Expiration is a change even when the following publication is rejected.
// Notify subscribers before a store mutation can silently sweep leases.
if self.store.advance(now) {
self.changed();
}
let result = match publish {
Publish::Start {
local,
mut data,
lease_ms,
} => {
if data.output.is_none() {
data.output = output;
}
let id = self
.store
.start(owner, local, data, now, 0, lease_ms, true, true, None)?;
json!({"handle":id})
}
Publish::Run { local, title, .. } => {
if let Some(matched) = matched {
let data = Data {
source: if local == "process" {
Source::Process
} else {
Source::Command
},
output,
priority: matched.rule.priority,
icon: matched.rule.icon.clone(),
title: title.unwrap_or(matched.title),
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self.store.start(
owner,
local,
data,
now,
matched.rule.min_duration_ms,
None,
true,
matched.rule.flash_on_fail,
matched.group,
)?;
self.results.insert(id.clone(), matched.rule.result);
json!({"handle":id,"progress":matched.rule.progress})
} else {
let data = Data {
source: Source::Command,
output,
priority: 100,
icon: "ferese".into(),
title: title.unwrap_or_else(|| "Command".into()),
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self.store.start(owner, local, data, now, 0, None, true, true, None)?;
json!({"handle":id,"progress":"indeterminate"})
}
}
Publish::Hook { local, .. } => {
let Some(matched) = matched else {
return Ok(json!({"matched":false}));
};
let data = Data {
source: if local == "process" {
Source::Process
} else {
Source::Command
},
output,
priority: matched.rule.priority,
icon: matched.rule.icon.clone(),
title: matched.title,
value: Value::Indeterminate,
template: Template::Progress,
};
let lease = (local == "process").then_some(5_000);
let id = self.store.start(
owner,
local,
data,
now,
matched.rule.min_duration_ms,
lease,
true,
matched.rule.flash_on_fail,
matched.group,
)?;
self.results.insert(id.clone(), matched.rule.result);
json!({"matched":true,"handle":id,"progress":matched.rule.progress})
}
Publish::Update {
local,
handle,
value,
lease_ms,
} => {
let id = self.resolve(owner, &local, handle)?;
self.store.update(owner, &id, value, lease_ms, now)?;
json!({})
}
Publish::End {
local,
handle,
result,
message,
} => {
let id = self.resolve(owner, &local, handle)?;
let message = message.or_else(|| {
self.results.get(&id).map(|text| {
if result == State::Done {
text.ok.clone()
} else {
text.fail.clone()
}
})
});
self.store.finish(owner, &id, result, message, now)?;
json!({})
}
Publish::Cancel { local, handle } => {
let id = self.resolve(owner, &local, handle)?;
self.store.finish(owner, &id, State::Cancelled, None, now)?;
json!({})
}
};
if let Some(local) = started
&& let Some(id) = self.store.resolve(owner, &local)
&& let Some(window) = window
{
self.windows.insert(id.clone(), window);
}
self.changed();
Ok(result)
}
fn resolve(&self, owner: Owner, local: &str, handle: Option<Id>) -> Result<Id, &'static str> {
match handle {
Some(id) if id.local == local => Ok(id),
Some(_) => Err("handle does not match local ID"),
None => self
.store
.resolve(owner, local)
.cloned()
.ok_or("unknown owner-scoped activity"),
}
}
}
impl crate::Ferese {
pub(crate) fn activity_window(&self, owner: &crate::ipc::activity_owner::VerifiedOwner) -> Option<u64> {
use smithay::reexports::wayland_server::Resource;
let mut ancestors = owner.ancestor_pids().peekable();
ancestors.peek()?;
let windows: Vec<_> = self
.windows
.records()
.filter_map(|(id, record)| {
let client = record.surface.as_ref()?.client()?;
let pid = u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
Some((pid, id.0))
})
.collect();
origin_window(ancestors, &windows)
}
}
fn origin_window(ancestors: impl Iterator<Item = u32>, windows: &[(u32, u64)]) -> Option<u64> {
for pid in ancestors {
let mut matches = windows.iter().filter(|(client, _)| *client == pid);
if let Some((_, window)) = matches.next() {
// A terminal server may own several windows. Never guess from focus.
return matches.next().is_none().then_some(*window);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn owner() -> Owner {
Owner { pid: 12, birth: 3 }
}
fn data() -> Data {
Data {
source: Source::Publisher,
output: None,
priority: 255,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
}
}
#[test]
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
let mut engine = Engine::default();
let id = engine
.store
.start(
owner(),
"expired".into(),
data(),
ferese_ipc::activities::now_ms().saturating_sub(5_000),
0,
Some(1_000),
true,
true,
None,
)
.unwrap();
let before = engine.snapshot();
assert_eq!(before.activities.len(), 1);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 11, before.revision, sender);
assert!(
engine
.publish(
owner(),
Publish::Update {
local: "expired".into(),
handle: Some(id),
value: None,
lease_ms: None,
},
None,
None
)
.is_err()
);
assert!(engine.snapshot().activities.is_empty());
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
let snapshot: Snapshot = serde_json::from_value(response.result.unwrap()).unwrap();
assert!(snapshot.activities.is_empty());
assert!(snapshot.revision > before.revision);
assert_eq!(before.activities.len(), 1);
}
#[test]
fn snapshots_are_lazy_shared_and_immutable_across_publications() {
let mut engine = Engine::default();
engine.changed();
assert!(engine.cached_snapshot.is_none(), "no subscriber needs a snapshot");
let before = engine.snapshot();
assert!(Arc::ptr_eq(&before, &engine.snapshot()));
let (first, first_reply) = std::sync::mpsc::sync_channel(1);
let (second, second_reply) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 11, before.revision, first);
engine.watch(2, 22, before.revision, second);
engine
.publish(
owner(),
Publish::Start {
local: "build".into(),
data: data(),
lease_ms: None,
},
None,
None,
)
.unwrap();
let after = engine.snapshot();
assert!(!Arc::ptr_eq(&before, &after));
assert!(before.activities.is_empty());
assert_eq!(after.activities.len(), 1);
for (id, reply) in [(11, first_reply), (22, second_reply)] {
let reply = reply.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(reply.id, id);
let snapshot: Snapshot = serde_json::from_value(reply.result.unwrap()).unwrap();
assert_eq!(snapshot.revision, after.revision);
assert_eq!(snapshot.activities[0].id, after.activities[0].id);
}
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.watch(3, 33, before.revision, sender);
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap().id, 33);
assert!(Arc::ptr_eq(&after, &engine.snapshot()));
}
#[test]
fn process_that_exits_before_detection_dispatch_is_not_published() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let (owner, fd, _) = crate::ipc::activity_owner::inspect(child.id()).unwrap();
let matched = ferese_config::activities::Matcher::new(&Default::default())
.unwrap()
.matches("cargo test", "/tmp", &[])
.unwrap();
child.kill().unwrap();
child.wait().unwrap();
let mut engine = Engine::default();
engine.detect_process(owner, matched, None, &fd);
assert_eq!(engine.store.entries().count(), 0);
}
#[test]
fn rejected_process_detection_is_retried_without_restarting_admitted_records() {
let mut engine = Engine::default();
let (owner, fd, _) = crate::ipc::activity_owner::inspect(std::process::id()).unwrap();
let settings = ferese_config::activities::Settings::default();
let matched = ferese_config::activities::Matcher::new(&settings)
.unwrap()
.matches("cargo test", "/tmp/test", &[])
.unwrap();
engine.store.limits.max_total = 0;
engine.detect_process(owner, matched.clone(), None, &fd);
assert_eq!(engine.store.entries().count(), 0);
engine.store.limits.max_total = 128;
engine.detect_process(owner, matched.clone(), None, &fd);
let admitted = engine.store.entries().next().unwrap().clone();
engine.detect_process(owner, matched, None, &fd);
let refreshed = engine.store.entries().next().unwrap();
assert_eq!(refreshed.id, admitted.id);
assert_eq!(refreshed.eligible_at, admitted.eligible_at);
assert_eq!(engine.store.entries().count(), 1);
}
#[test]
fn terminal_matching_uses_nearest_unique_ancestor_and_never_guesses() {
let ancestry = [400, 300, 200, 100];
assert_eq!(origin_window(ancestry.into_iter(), &[(200, 7), (100, 8)]), Some(7));
assert_eq!(
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
None
);
assert_eq!(origin_window(ancestry.into_iter(), &[(900, 7)]), None);
assert_eq!(origin_window([].into_iter(), &[(200, 7)]), None);
}
#[test]
fn activity_window_survives_updates_and_results_then_expires() {
let mut engine = Engine::default();
engine.store.limits.start_delay_ms = 0;
engine
.publish_from(
owner(),
Publish::Run {
local: "build".into(),
title: Some("Build".into()),
command: String::new(),
cwd: String::new(),
},
None,
None,
Some(42),
)
.unwrap();
let id = engine.store.entries().next().unwrap().id.clone();
assert_eq!(engine.snapshot().windows[0].window, 42);
engine
.publish(
owner(),
Publish::Update {
local: "build".into(),
handle: Some(id.clone()),
value: Some(Value::Progress(0.5)),
lease_ms: None,
},
None,
None,
)
.unwrap();
assert_eq!(engine.snapshot().windows[0].window, 42);
engine
.publish(
owner(),
Publish::End {
local: "build".into(),
handle: Some(id.clone()),
result: State::Done,
message: None,
},
None,
None,
)
.unwrap();
engine.owner_exit(owner());
assert_eq!(engine.snapshot().windows[0].activity, id);
engine.store.advance(ferese_ipc::activities::now_ms() + 2_000);
engine.changed();
assert!(engine.snapshot().windows.is_empty());
}
#[test]
fn one_shot_disconnect_does_not_own_lifetime_and_rate_limit_preserves_completion() {
let mut engine = Engine::default();
let result = engine
.publish(
owner(),
Publish::Start {
local: "build".into(),
data: data(),
lease_ms: None,
},
None,
Some("first".into()),
)
.unwrap();
let id: Id = serde_json::from_value(result["handle"].clone()).unwrap();
engine.remove_connection(1);
assert_eq!(engine.store.entries().count(), 1);
for _ in 0..40 {
let _ = engine.publish(
owner(),
Publish::Update {
local: "build".into(),
handle: Some(id.clone()),
value: Some(Value::Progress(0.5)),
lease_ms: None,
},
None,
Some("other".into()),
);
}
assert_eq!(
engine.store.entries().next().unwrap().data.output.as_deref(),
Some("first")
);
engine
.publish(
owner(),
Publish::End {
local: "build".into(),
handle: Some(id),
result: State::Done,
message: None,
},
None,
None,
)
.unwrap();
assert_eq!(
engine.store.entries().count(),
0,
"completion before delay never flashes"
);
}
#[test]
fn rule_is_pinned_across_atomic_reload_and_no_raw_metadata_is_published() {
let settings = ferese_config::activities::Settings::default();
let matched = ferese_config::activities::Matcher::new(&settings)
.unwrap()
.matches("cargo test --secret", "/secret/cwd", &[])
.unwrap();
let mut engine = Engine::default();
engine
.publish(
owner(),
Publish::Run {
local: "build".into(),
title: None,
command: String::new(),
cwd: String::new(),
},
Some(matched),
Some("first".into()),
)
.unwrap();
let before = engine.store.entries().next().unwrap().clone();
let mut replacement = settings.clone();
replacement.start_delay_ms = 5_000;
replacement.rules[0].title = "Replacement".into();
engine.configure(
&replacement,
ferese_config::activities::Matcher::new(&replacement).unwrap(),
);
let after = engine.store.entries().next().unwrap();
assert_eq!(after.data.title, "cargo test");
assert_eq!(after.eligible_at, before.eligible_at);
let serialized = serde_json::to_string(engine.snapshot().as_ref()).unwrap();
assert!(!serialized.contains("secret"));
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Bounded serialization and delivery outside the compositor event loop.
use ferese_ipc::{Response, activities::Snapshot};
use std::sync::{
Arc,
mpsc::{self, SyncSender},
};
type Recipient = (u64, SyncSender<Response>);
struct Job {
snapshot: Arc<Snapshot>,
recipients: Vec<Recipient>,
}
pub(super) struct Publisher(SyncSender<Job>);
impl Publisher {
pub fn new() -> std::io::Result<Self> {
let (send, receive) = mpsc::sync_channel::<Job>(8);
std::thread::Builder::new()
.name("ferese-activity-snapshots".into())
.spawn(move || {
let mut cached: Option<(Arc<Snapshot>, serde_json::Value)> = None;
while let Ok(job) = receive.recv() {
if !cached
.as_ref()
.is_some_and(|(snapshot, _)| Arc::ptr_eq(snapshot, &job.snapshot))
{
cached = Some((job.snapshot.clone(), serde_json::json!(job.snapshot.as_ref())));
}
let value = &cached.as_ref().unwrap().1;
for (id, sender) in job.recipients {
// Client queues are bounded too. A slow/disconnected client
// cannot block serialization or another subscriber.
let _ = sender.try_send(Response::success(id, value.clone()));
}
}
})?;
Ok(Self(send))
}
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
unavailable(job.recipients);
}
}
}
pub(super) fn unavailable(recipients: Vec<Recipient>) {
for (id, sender) in recipients {
let _ = sender.try_send(Response::error(
id,
"activity_busy",
"Activity snapshot delivery unavailable; retry",
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
let (send, _receive) = mpsc::sync_channel(0);
let publisher = Publisher(send);
let (reply, receive) = mpsc::sync_channel(1);
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(response.id, 7);
assert_eq!(response.error.unwrap().code, "activity_busy");
let publisher = Publisher::new().unwrap();
let (slow, _slow_receive) = mpsc::sync_channel(1);
slow.send(Response::success(1, serde_json::json!({}))).unwrap();
let (fast, fast_receive) = mpsc::sync_channel(1);
publisher.send(Arc::new(Snapshot::default()), vec![(2, slow), (3, fast)]);
assert_eq!(fast_receive.recv_timeout(Duration::from_secs(1)).unwrap().id, 3);
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Explicitly enabled fallback. Bounded /proc sampling and regex work stay on
//! one worker; disappearance emits owner exit, never an inferred exit status.
use crate::ipc::IpcEvent;
use ferese_activities::Owner;
use smithay::reexports::calloop::channel;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
pub(crate) struct Watcher(Arc<AtomicBool>);
impl Drop for Watcher {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
impl Watcher {
pub fn start(
rules: crate::activities::SharedRules,
sender: channel::SyncSender<IpcEvent>,
) -> std::io::Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = stop.clone();
std::thread::Builder::new()
.name("ferese-activity-processes".into())
.spawn(move || {
let mut observed =
BTreeMap::<Owner, (Arc<std::os::fd::OwnedFd>, ferese_config::activities::Matched)>::new();
while !worker_stop.load(Ordering::Acquire) {
let rules = rules.read().expect("activity rules").clone();
let dead: Vec<_> = observed
.iter()
.filter_map(|(owner, (fd, _))| crate::ipc::activity_owner::exited(fd).then_some(*owner))
.collect();
for owner in dead {
observed.remove(&owner);
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
return;
}
}
// Reconcile every detection: admission may have failed or its lease expired.
for (owner, (fd, matched)) in &observed {
let _ = sender.try_send(IpcEvent::ActivityDetected(
*owner,
Box::new(matched.clone()),
fd.clone(),
));
}
if let Ok(processes) = std::fs::read_dir("/proc") {
for process in processes.take(4_096).flatten() {
if worker_stop.load(Ordering::Acquire) || observed.len() >= 128 {
break;
}
let Some(pid) = process.file_name().to_str().and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok((owner, fd, parents)) = crate::ipc::activity_owner::inspect(pid) else {
continue;
};
if observed.contains_key(&owner) || parents.iter().any(|parent| parent == "feresectl") {
continue;
}
let Ok(file) = std::fs::File::open(process.path().join("cmdline")) else {
continue;
};
let mut bytes = Vec::new();
if file.take(4_097).read_to_end(&mut bytes).is_err() || bytes.len() > 4_096 {
continue;
}
let Ok(command) = String::from_utf8(bytes) else {
continue;
};
let command = command.trim_end_matches('\0').replace('\0', " ");
let Ok(cwd) = std::fs::read_link(process.path().join("cwd")) else {
continue;
};
let cwd = cwd.to_string_lossy();
let Some(matched) = rules.matcher.matches(&command, &cwd, &parents) else {
continue;
};
let fd = Arc::new(fd);
// Keep the inspected process identity alive through dispatch.
// No raw command, cwd or environment crosses the dispatcher.
if sender
.try_send(IpcEvent::ActivityDetected(owner, Box::new(matched.clone()), fd.clone()))
.is_ok()
{
observed.insert(owner, (fd, matched));
}
}
}
std::thread::sleep(Duration::from_secs(2));
}
})?;
Ok(Self(stop))
}
}
+6
View File
@@ -33,6 +33,8 @@ use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)]
activities: ferese_config::activities::Settings,
#[serde(default)] #[serde(default)]
panels: Option<Vec<ferese_panel::Panel>>, panels: Option<Vec<ferese_panel::Panel>>,
#[serde(default)] #[serde(default)]
@@ -118,6 +120,7 @@ impl Config {
if let Some(panels) = &self.panels { if let Some(panels) = &self.panels {
ferese_panel::validate(panels).map_err(ConfigError::Binding)?; ferese_panel::validate(panels).map_err(ConfigError::Binding)?;
} }
self.activities.validate().map_err(ConfigError::Binding)?;
self.notifications.validate().map_err(ConfigError::Binding)?; self.notifications.validate().map_err(ConfigError::Binding)?;
self.xwayland.validate().map_err(ConfigError::Binding)?; self.xwayland.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart { for daemon in &self.autostart {
@@ -128,6 +131,9 @@ impl Config {
let input_settings = self.input_settings()?; let input_settings = self.input_settings()?;
let bindings = self.bindings(&input_settings)?; let bindings = self.bindings(&input_settings)?;
Ok(crate::RuntimeConfig { 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()?, lock_idle: self.lock_screen.validate()?,
idle_inhibit: self.idle_inhibit, idle_inhibit: self.idle_inhibit,
autostart: self.autostart.clone(), autostart: self.autostart.clone(),
+201 -13
View File
@@ -13,6 +13,7 @@ use std::{fs, io, thread};
use ferese_core::LayoutMode; use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION}; use ferese_ipc::{Request, Response, VERSION};
pub(crate) mod activity_owner;
mod transport; mod transport;
use ferese_layout::Direction; use ferese_layout::Direction;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -40,8 +41,13 @@ const _: () = assert!(
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL; const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
#[derive(Debug)] #[derive(Debug)]
struct IpcCall { pub(crate) struct IpcCall {
native_portal: bool, native_portal: bool,
activity: Option<(
activity_owner::VerifiedOwner,
ferese_ipc::activities::Publish,
Option<ferese_config::activities::Matched>,
)>,
owner: u64, owner: u64,
request: Request, request: Request,
response: SyncSender<Response>, response: SyncSender<Response>,
@@ -49,9 +55,15 @@ struct IpcCall {
} }
#[derive(Debug)] #[derive(Debug)]
enum IpcEvent { pub(crate) enum IpcEvent {
Call(IpcCall), Call(Box<IpcCall>),
Closed(u64), Closed(u64),
ActivityOwnerExited(ferese_activities::Owner),
ActivityDetected(
ferese_activities::Owner,
Box<ferese_config::activities::Matched>,
Arc<std::os::fd::OwnedFd>,
),
} }
struct IpcConnection { struct IpcConnection {
@@ -149,8 +161,28 @@ pub(crate) fn init(
event_loop.handle().insert_source(receiver, |event, _, state| { event_loop.handle().insert_source(receiver, |event, _, state| {
if let channel::Event::Msg(event) = event { if let channel::Event::Msg(event) = event {
let call = match 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) => { IpcEvent::Closed(owner) => {
state.activities.remove_connection(owner);
state.ipc_events.remove(owner); state.ipc_events.remove(owner);
for request in state.screenshot.terminate_owner(owner) { for request in state.screenshot.terminate_owner(owner) {
state state
@@ -170,7 +202,67 @@ pub(crate) fn init(
return; 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(|()| { let result = validate_request(&call.request).and_then(|()| {
if call.request.args["version"].as_u64() != Some(u64::from(ferese_ipc::events::VERSION)) { if call.request.args["version"].as_u64() != Some(u64::from(ferese_ipc::events::VERSION)) {
return Err(CommandError::new( return Err(CommandError::new(
@@ -317,10 +409,26 @@ pub(crate) fn init(
})?; })?;
let worker = Worker::spawn(encoded); 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(); let signal = event_loop.get_signal();
thread::Builder::new() thread::Builder::new()
.name("ferese-ipc-listener".to_owned()) .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 // 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 // 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 active_connections = Arc::new(AtomicUsize::new(0));
let connection_ids = AtomicU64::new(1); let connection_ids = AtomicU64::new(1);
@@ -448,11 +562,13 @@ fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEve
let sender = sender.clone(); let sender = sender.clone();
let owner = connection_ids.fetch_add(1, Ordering::Relaxed); let owner = connection_ids.fetch_add(1, Ordering::Relaxed);
let signal = signal.clone(); let signal = signal.clone();
let monitor = monitor.clone();
let rules = rules.clone();
if let Err(error) = thread::Builder::new() if let Err(error) = thread::Builder::new()
.name("ferese-ipc-client".to_owned()) .name("ferese-ipc-client".to_owned())
.spawn(move || { .spawn(move || {
let _permit = permit; 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"); 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() }) 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 native_portal = crate::handlers::window_capture::is_portal(&stream);
let _connection = IpcConnection { let _connection = IpcConnection {
owner, owner,
@@ -484,7 +610,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
let mut idle = Some(transport::FRAME_TIMEOUT); let mut idle = Some(transport::FRAME_TIMEOUT);
let mut owns_lease = false; let mut owns_lease = false;
loop { loop {
let request: Request = match transport::read_request(&stream, idle) { let mut request: Request = match transport::read_request(&stream, idle) {
Ok(request) => request, Ok(request) => request,
Err(ferese_ipc::FrameError::Io(error)) Err(ferese_ipc::FrameError::Io(error))
if matches!( if matches!(
@@ -512,6 +638,61 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
} }
continue; 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 (response, receiver) = sync_channel(1);
let event_stream_requested = request.command == "event-stream"; let event_stream_requested = request.command == "event-stream";
let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY); 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" | "begin-session-end"
); );
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window"); 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, native_portal,
activity,
owner, owner,
request, request,
response, response,
events: event_stream_requested.then_some(events), 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"); tracing::warn!("disconnecting IPC client because the request queue is full");
return; return;
} }
+379
View File
@@ -0,0 +1,379 @@
//! Authenticate the socket peer and its immediate parent before accepting any
//! activity request. pidfds bind monitoring to a process, not a reused PID.
use super::IpcEvent;
use ferese_activities::Owner;
use smithay::reexports::calloop::channel;
use std::collections::BTreeMap;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::{Arc, Condvar, Mutex};
/// Carries the kernel identity through the IPC queue. Monitoring begins only
/// when the compositor admits the request, so pruning cannot race a pending start.
#[derive(Debug)]
pub(crate) struct VerifiedOwner {
pub owner: Owner,
fd: OwnedFd,
ancestors: Vec<(u32, OwnedFd)>,
}
impl VerifiedOwner {
/// Run on the IPC worker, only for starts. Hold ancestor identities until
/// dispatch; a recycled PID must never select a window.
pub fn capture_ancestors(&mut self) {
let mut next = self.owner.pid;
let mut child = None;
for _ in 0..16 {
let Ok((birth, parent)) = identity(next) else { break };
if next == self.owner.pid && birth != self.owner.birth {
break;
}
let Ok(fd) = pidfd(next) else { break };
if identity(next).ok() != Some((birth, parent)) || exited(&fd) {
break;
}
if let Some((pid, expected)) = child
&& identity(pid).ok() != Some(expected)
{
break;
}
self.ancestors.push((next, fd));
child = Some((next, (birth, parent)));
if parent <= 1 || parent == next {
break;
}
next = parent;
}
}
pub fn ancestor_pids(&self) -> impl Iterator<Item = u32> + '_ {
self.ancestors
.iter()
.take_while(|(_, fd)| !exited(fd))
.map(|(pid, _)| *pid)
}
}
#[derive(Clone)]
pub(crate) struct Monitor(Arc<(Mutex<BTreeMap<Owner, OwnedFd>>, Condvar)>);
impl Monitor {
pub fn new(sender: channel::SyncSender<IpcEvent>) -> Self {
let shared = Arc::new((Mutex::new(BTreeMap::<Owner, OwnedFd>::new()), Condvar::new()));
let worker = shared.clone();
std::thread::Builder::new()
.name("ferese-activity-owners".into())
.spawn(move || {
loop {
let (lock, ready) = &*worker;
let mut owners = lock.lock().expect("activity owners");
while owners.is_empty() {
owners = ready.wait(owners).expect("activity owners");
}
let dead: Vec<_> = owners
.iter()
.filter_map(|(owner, fd)| {
let mut poll = libc::pollfd {
fd: fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll addresses one live, initialized descriptor.
(unsafe { libc::poll(&raw mut poll, 1, 0) } > 0).then_some(*owner)
})
.collect();
for owner in &dead {
owners.remove(owner);
}
drop(owners);
for owner in dead {
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
return;
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
})
.expect("could not start activity owner monitor");
Self(shared)
}
pub fn retain(&self, owners: &std::collections::BTreeSet<Owner>) {
self.0
.0
.lock()
.expect("activity owners")
.retain(|owner, _| owners.contains(owner));
}
pub fn verify(&self, stream: &UnixStream, parent: bool) -> io::Result<(VerifiedOwner, Vec<String>)> {
let peer = credentials(stream)?;
let peer_pid = u32::try_from(peer.pid).map_err(|_| io::Error::other("invalid peer PID"))?;
let peer_identity = identity(peer_pid)?;
let peer_fd = peer_pidfd(stream)?;
if identity(peer_pid)?.0 != peer_identity.0 || exited(&peer_fd) {
return Err(io::Error::other("peer identity changed"));
}
let pid = if parent { peer_identity.1 } else { peer_pid };
let (birth, _) = identity(pid)?;
let fd = pidfd(pid)?;
if identity(pid)?.0 != birth || exited(&fd) || (parent && identity(peer_pid)? != peer_identity) {
return Err(io::Error::other("owner identity changed"));
}
let metadata = std::fs::metadata(format!("/proc/{pid}"))?;
use std::os::unix::fs::MetadataExt;
if metadata.uid() != peer.uid {
return Err(io::Error::other("owner user differs from peer"));
}
let owner = Owner { pid, birth };
let mut parents = Vec::new();
let mut next = pid;
for _ in 0..16 {
let Ok((_, parent)) = identity(next) else {
break;
};
if let Ok(name) = std::fs::read_to_string(format!("/proc/{next}/comm")) {
parents.push(name.trim().chars().take(128).collect());
}
if parent == 0 || parent == next {
break;
}
next = parent;
}
Ok((
VerifiedOwner {
owner,
fd,
ancestors: Vec::new(),
},
parents,
))
}
pub fn register(&self, verified: VerifiedOwner) -> io::Result<Owner> {
let VerifiedOwner { owner, fd, .. } = verified;
if exited(&fd) {
return Err(io::Error::other("activity owner exited before admission"));
}
let (lock, ready) = &*self.0;
let mut owners = lock.lock().expect("activity owners");
if owners.len() >= 128 && !owners.contains_key(&owner) {
return Err(io::Error::other("owner monitor quota"));
}
owners.entry(owner).or_insert(fd);
ready.notify_one();
drop(owners);
Ok(owner)
}
}
pub(crate) fn exited(fd: &OwnedFd) -> bool {
let mut poll = libc::pollfd {
fd: fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll addresses one initialized descriptor retained by fd.
unsafe { libc::poll(&raw mut poll, 1, 0) != 0 }
}
fn peer_pidfd(stream: &UnixStream) -> io::Result<OwnedFd> {
let mut fd = -1_i32;
let mut len = std::mem::size_of::<i32>() as libc::socklen_t;
// SO_PEERPIDFD (Linux 6.5+) refers to the process captured when this socket
// connected, closing the credential-PID reuse race before authentication.
// SAFETY: both output buffers have the sizes expected by getsockopt.
if unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
77,
(&raw mut fd).cast(),
&raw mut len,
)
} != 0
{
return Err(io::Error::last_os_error());
}
if fd < 0 {
return Err(io::Error::other("missing peer pidfd"));
}
// SAFETY: SO_PEERPIDFD returned a new descriptor owned by this caller.
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
fn pidfd(pid: u32) -> io::Result<OwnedFd> {
// SAFETY: pidfd_open accepts numeric arguments and returns an owned fd.
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
if fd < 0 {
Err(io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) })
}
}
fn identity(pid: u32) -> io::Result<(u64, u32)> {
parse_stat(&std::fs::read_to_string(format!("/proc/{pid}/stat"))?)
}
fn parse_stat(stat: &str) -> io::Result<(u64, u32)> {
let end = stat
.rfind(')')
.ok_or_else(|| io::Error::other("invalid process stat"))?;
let fields: Vec<_> = stat[end + 1..].split_whitespace().collect();
let parent = fields
.get(1)
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other("missing parent identity"))?;
let birth = fields
.get(19)
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other("missing process birth"))?;
Ok((birth, parent))
}
fn credentials(stream: &UnixStream) -> io::Result<libc::ucred> {
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: both output buffers have the sizes expected by SO_PEERCRED.
if unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&raw mut credentials).cast(),
&raw mut len,
)
} == 0
{
Ok(credentials)
} else {
Err(io::Error::last_os_error())
}
}
pub(crate) fn inspect(pid: u32) -> io::Result<(Owner, OwnedFd, Vec<String>)> {
use std::os::unix::fs::MetadataExt;
if std::fs::metadata(format!("/proc/{pid}"))?.uid() != unsafe { libc::geteuid() } {
return Err(io::Error::other("process belongs to another user"));
}
let (birth, _) = identity(pid)?;
let fd = pidfd(pid)?;
if identity(pid)?.0 != birth || exited(&fd) {
return Err(io::Error::other("process disappeared"));
}
let mut next = pid;
let mut parents = Vec::new();
for _ in 0..16 {
if let Ok(name) = std::fs::read_to_string(format!("/proc/{next}/comm")) {
parents.push(name.trim().chars().take(128).collect());
}
let Ok((_, parent)) = identity(next) else {
break;
};
if parent == 0 || parent == next {
break;
}
next = parent;
}
Ok((Owner { pid, birth }, fd, parents))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn captured_ancestry_stops_at_an_exited_process() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let (owner, fd, _) = inspect(child.id()).unwrap();
let mut verified = VerifiedOwner {
owner,
fd,
ancestors: Vec::new(),
};
verified.capture_ancestors();
assert_eq!(verified.ancestor_pids().next(), Some(child.id()));
assert!(verified.ancestor_pids().any(|pid| pid == std::process::id()));
child.kill().unwrap();
child.wait().unwrap();
assert!(verified.ancestor_pids().next().is_none());
}
#[test]
fn pending_start_survives_monitor_pruning() {
use ferese_activities::{Data, Source, Template, Value};
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let mut engine = crate::activities::Engine::default();
engine.monitor = Some(monitor.clone());
let (peer, _other) = UnixStream::pair().unwrap();
let (verified, _) = monitor.verify(&peer, false).unwrap();
let owner = verified.owner;
// A policy timer or a different queued publication runs after verification,
// before this worker's Start reaches the compositor dispatcher.
engine.schedule();
engine
.publish_verified(
verified,
ferese_ipc::activities::Publish::Start {
local: "pending-start".into(),
data: Data {
source: Source::Publisher,
output: None,
priority: 100,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
},
lease_ms: None,
},
None,
Some("test".into()),
None,
)
.unwrap();
assert_eq!(engine.store.entries().count(), 1);
assert!(
monitor.0.0.lock().unwrap().contains_key(&owner),
"accepted lease-free activity has lost its owner monitor"
);
}
#[test]
fn owner_that_exits_in_the_dispatch_queue_is_rejected() {
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let (owner, fd, _) = inspect(child.id()).unwrap();
child.kill().unwrap();
child.wait().unwrap();
assert!(
monitor
.register(VerifiedOwner {
owner,
fd,
ancestors: Vec::new()
})
.is_err()
);
assert!(monitor.0.0.lock().unwrap().is_empty());
}
#[test]
fn process_names_with_spaces_or_parentheses_do_not_shift_birth_field() {
let fields = (0..20)
.map(|i| if i == 0 { "S".into() } else { i.to_string() })
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
parse_stat(&format!("42 (name ) with spaces) {fields}")).unwrap(),
(19, 1)
);
}
#[test]
fn verifies_kernel_peer_parent_and_monitors_actual_exit() {
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let (left, _) = UnixStream::pair().unwrap();
let (peer, _) = monitor.verify(&left, false).unwrap();
assert_eq!(peer.owner.pid, std::process::id());
let (parent, _) = monitor.verify(&left, true).unwrap();
assert_eq!(parent.owner.pid, unsafe { libc::getppid() } as u32);
let mut child = std::process::Command::new("sleep").arg("0.05").spawn().unwrap();
let fd = pidfd(child.id()).unwrap();
assert!(!exited(&fd));
child.wait().unwrap();
assert!(exited(&fd));
}
}
+2
View File
@@ -1,3 +1,5 @@
mod activities;
mod activities_process;
mod backends; mod backends;
mod config; mod config;
mod cursor; mod cursor;
+208
View File
@@ -810,3 +810,211 @@ fn corner_heavy_shader_timing_sample() {
} }
} }
} }
#[test]
#[ignore = "requires an EGL rendering device"]
fn window_tint_does_not_inherit_panel_geometry() {
use smithay::backend::renderer::gles::UniformValue;
use smithay::output::{Mode, PhysicalProperties, Subpixel};
fn paint(renderer: &mut GlesRenderer, element: &impl RenderElement<GlesRenderer>) -> Vec<u8> {
let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (96, 96).into()).unwrap();
let mut target = renderer.bind(&mut texture).unwrap();
let rect = Rectangle::from_size((96, 96).into());
let mut frame = renderer
.render(&mut target, (96, 96).into(), Transform::Normal)
.unwrap();
frame.clear([1.0, 1.0, 1.0, 1.0].into(), &[rect]).unwrap();
let geometry = element.geometry(1.0.into());
element
.draw(
&mut frame,
element.src(),
geometry,
&[Rectangle::from_size(geometry.size)],
&[],
)
.unwrap();
let _ = frame.finish().unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((96, 96).into()), Fourcc::Abgr8888)
.unwrap();
renderer.map_texture(&mapping).unwrap().to_vec()
}
let mut renderer = renderer();
let mut resources = RenderResources::default();
let output = Output::new(
"tint-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (96, 96).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
Some((0, 0).into()),
);
let geometry = Rectangle::new((6, 6).into(), (80, 80).into());
let corners = RoundedRect::from_logical(geometry, 1.0, 12.0).with_shape(CornerShape::Continuous);
for resize_fill in [false, true] {
let tint = window_tint_element(
&mut resources,
&mut renderer,
ferese_layout::WindowId(1),
geometry,
corners,
[0.0, 0.0, 0.0, 0.44],
resize_fill,
&output,
)
.unwrap();
let baseline = paint(&mut renderer, &tint);
let program = material_program(&mut resources, &mut renderer).unwrap();
let panel = SharedPixelShaderElement::new(
program.0,
Rectangle::from_size((96, 24).into()),
None,
1.0,
vec![
Uniform::new("visible_rect", [0.0_f32, 72.0, 96.0, 24.0]),
Uniform::new("material_radii", [12.0_f32; 4]),
Uniform::new("tint", [0.2_f32, 0.2, 0.2, 1.0]),
Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", [0.0_f32; 4]),
Uniform::new("shadow_values", [0.0_f32; 2]),
Uniform::new("panel_count", 1.0_f32),
Uniform::new("panel_fusion", 0.0_f32),
Uniform::new(
"panel_shapes[0]",
UniformValue::Matrix3x3 {
matrices: vec![[0.0, 72.0, 96.0, 24.0, 12.0, 12.0, 12.0, 12.0, 0.0]],
transpose: false,
},
),
],
RenderElementKind::Unspecified,
);
paint(&mut renderer, &panel);
let after_panel = paint(&mut renderer, &tint);
let changed = baseline
.chunks_exact(4)
.zip(after_panel.chunks_exact(4))
.filter(|(a, b)| a != b)
.count();
assert_eq!(
changed, 0,
"a panel draw changed {changed} window pixels (resize_fill={resize_fill})"
);
assert!(
baseline.chunks_exact(4).any(|p| p[0] < 200),
"fixture must draw the tint"
);
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn floating_blur_sampling_halo_does_not_paint_neighboring_islands() {
use smithay::backend::renderer::ImportMem;
use smithay::backend::renderer::gles::UniformValue;
let mut renderer = renderer();
let mut resources = RenderResources::default();
let program = blur_program(&mut resources, &mut renderer).unwrap();
for scale in [1.0_f64, 1.25, 1.5, 1.75, 2.0] {
let size: Size<i32, Physical> = ((256. * scale) as i32, (96. * scale) as i32).into();
let full = Rectangle::from_size(size);
let shapes: Vec<[f32; 9]> = [24., 112.]
.into_iter()
.map(|x| [x, 24., 80., 32., 16., 16., 16., 16., 0.].map(|v| v * scale as f32))
.collect();
// Each island captures an extra 32 logical pixels on every side for
// blur sampling. That capture includes part of its neighboring island.
for island in 0..2 {
let shape = shapes[island];
let visible = [shape[0], shape[1], shape[2], shape[3]];
let halo = 32. * scale as f32;
let capture = [
(visible[0] - halo).max(0.),
(visible[1] - halo).max(0.),
(visible[0] + visible[2] + halo).min(size.w as f32),
(visible[1] + visible[3] + halo).min(size.h as f32),
];
let rect = Rectangle::<i32, Physical>::new(
(capture[0] as i32, capture[1] as i32).into(),
((capture[2] - capture[0]) as i32, (capture[3] - capture[1]) as i32).into(),
);
let mut pixels = vec![128_u8; (rect.size.w * rect.size.h * 4) as usize];
for p in pixels.chunks_exact_mut(4) {
p[3] = 255;
}
let source = renderer
.import_memory(&pixels, Fourcc::Abgr8888, (rect.size.w, rect.size.h).into(), false)
.unwrap();
let uniforms = vec![
Uniform::new("visible_rect", visible),
Uniform::new("material_radii", [16. * scale as f32; 4]),
Uniform::new("texture_size", [rect.size.w as f32, rect.size.h as f32]),
Uniform::new("capture_origin", [capture[0], capture[1]]),
Uniform::new("blur_radius", halo),
Uniform::new("presentation_alpha", 1.0_f32),
Uniform::new("background_opacity", 0.6_f32),
Uniform::new("tint", [0.1_f32, 0.1, 0.1, 0.5]),
Uniform::new("panel_count", 2.0_f32),
Uniform::new("panel_fusion", 0.0_f32),
Uniform::new(
"panel_shapes[0]",
UniformValue::Matrix3x3 {
matrices: shapes.clone(),
transpose: false,
},
),
];
let mut texture: GlesTexture = renderer
.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into())
.unwrap();
let mut target = renderer.bind(&mut texture).unwrap();
let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap();
frame.clear([0.; 4].into(), &[full]).unwrap();
// OpenGL coordinates have the opposite Y direction from the frame.
let dst = Rectangle::new((rect.loc.x, size.h - rect.loc.y - rect.size.h).into(), rect.size);
frame
.render_texture_from_to(
&source,
Rectangle::from_size(source.size().to_f64()),
dst,
&[Rectangle::from_size(dst.size)],
&[],
Transform::Normal,
1.,
Some(&program.0),
&uniforms,
)
.unwrap();
let _ = frame.finish().unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((size.w, size.h).into()), Fourcc::Abgr8888)
.unwrap();
let rendered = renderer.map_texture(&mapping).unwrap().to_vec();
let mut painted = 0;
for pixel in rendered.chunks_exact(4).enumerate() {
let (index, rgba) = pixel;
let x = (index % size.w as usize) as f32 + 0.5;
if x < visible[0] || x >= visible[0] + visible[2] {
assert_eq!(
rgba[3], 0,
"island {island} paints outside its bounds at x={x}, scale={scale}"
);
} else if rgba[3] > 0 {
painted += 1;
}
}
assert!(painted > 100, "fixture must paint its own island");
}
}
}
+16 -10
View File
@@ -197,6 +197,21 @@ pub(super) fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'sta
] ]
} }
// The material program is shared with panels. GL uniforms survive between
// draws, so every ordinary tint must explicitly disable the panel mask.
pub(super) fn tint_uniforms(p: &BorderParameters) -> Vec<Uniform<'static>> {
vec![
Uniform::new("visible_rect", p.clip_rect),
Uniform::new("material_radii", [p.radius; 4]),
Uniform::new("tint", p.color),
Uniform::new("paint_mode", 0.0_f32),
Uniform::new("shadow_rect", p.clip_rect),
Uniform::new("shadow_values", [0.0_f32; 2]),
Uniform::new("panel_count", 0.0_f32),
Uniform::new("panel_fusion", 0.0_f32),
]
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn window_tint_element( pub(super) fn window_tint_element(
resources: &mut RenderResources, resources: &mut RenderResources,
@@ -231,16 +246,7 @@ pub(super) fn window_tint_element(
focus_gradient_line: [0.0; 4], focus_gradient_line: [0.0; 4],
focus_mix: 0.0, focus_mix: 0.0,
}; };
let uniforms = |p: &BorderParameters| { let uniforms = tint_uniforms;
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 context = renderer.context_id().erased(); let context = renderer.context_id().erased();
let buffers = if resize_fill { let buffers = if resize_fill {
&mut resources.windows.entry(id).or_default().resize_fill &mut resources.windows.entry(id).or_default().resize_fill
+2 -1
View File
@@ -691,7 +691,8 @@ mod region_geometry_tests {
#[test] #[test]
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() { fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
use crate::effects::SemanticRole; 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 opacity in [0., 0.4, 1.] {
for reduce in [false, true] { for reduce in [false, true] {
let source = format!( let source = format!(
+1 -8
View File
@@ -39,14 +39,7 @@ pub(super) fn overview_chrome_element(
if key.1 == OverviewChromePart::Outline { if key.1 == OverviewChromePart::Outline {
border_uniforms(p) border_uniforms(p)
} else { } else {
vec![ tint_uniforms(p)
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 context = renderer.context_id().erased(); let context = renderer.context_id().erased();
@@ -25,6 +25,9 @@ varying vec2 v_coords;
void main() { void main() {
float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii); 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); float coverage = alpha * presentation_alpha * edge_coverage(sdf);
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size; vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
+10
View File
@@ -490,6 +490,7 @@ pub struct Ferese {
pub(crate) idle_inhibit: crate::idle_inhibition::Settings, pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>, pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
pub(crate) media_engine: crate::idle_inhibition::media::Engine, pub(crate) media_engine: crate::idle_inhibition::media::Engine,
pub(crate) activities: crate::activities::Engine,
pub(crate) automatic_idle_inhibited: bool, pub(crate) automatic_idle_inhibited: bool,
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>, pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<DirectBackendState>, pub direct_backend: Option<DirectBackendState>,
@@ -548,6 +549,8 @@ pub struct Ferese {
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeConfig { 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) lock_idle: IdleSettings,
pub(crate) idle_inhibit: crate::idle_inhibition::Settings, pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
@@ -672,6 +675,8 @@ impl Ferese {
let default_cursor = crate::cursor::fallback_cursor(); let default_cursor = crate::cursor::fallback_cursor();
let named_cursors = HashMap::from([(CursorIcon::Default, default_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 { let mut state = Self {
session_lock_state, session_lock_state,
session_lock: crate::session_lock::Lock::default(), session_lock: crate::session_lock::Lock::default(),
@@ -772,6 +777,7 @@ impl Ferese {
idle_inhibit: config.idle_inhibit, idle_inhibit: config.idle_inhibit,
media_players: Vec::new(), media_players: Vec::new(),
media_engine: Default::default(), media_engine: Default::default(),
activities,
automatic_idle_inhibited: false, automatic_idle_inhibited: false,
active_shortcuts_inhibitor: None, active_shortcuts_inhibitor: None,
direct_backend: None, direct_backend: None,
@@ -850,6 +856,10 @@ impl Ferese {
// session-scoped, so a difference from the running service is reported // session-scoped, so a difference from the running service is reported
// as `restart_required` rather than applied behind the user's back. // as `restart_required` rather than applied behind the user's back.
self.xwayland_config = config.xwayland.clone(); 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 layout_changed = changed(&["layout", "scrolling", "workspaces"]);
let bindings_changed = changed(&["bindings", "commands"]); let bindings_changed = changed(&["bindings", "commands"]);
+11 -7
View File
@@ -33,11 +33,11 @@ struct PreviewWindow {
} }
impl Ferese { 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. /// 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}; 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; return bounds;
} }
let Some(output) = self let Some(output) = self
@@ -64,7 +64,7 @@ impl Ferese {
}; };
let top = f64::from(output_geometry.loc.y + geometry.loc.y); let top = f64::from(output_geometry.loc.y + geometry.loc.y);
let bottom = top + f64::from(geometry.size.h); 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. // Another reserved bar on this edge must retain its own spacing.
if policy.anchor.contains(Anchor::TOP) if policy.anchor.contains(Anchor::TOP)
&& !policy.anchor.contains(Anchor::BOTTOM) && !policy.anchor.contains(Anchor::BOTTOM)
@@ -91,7 +91,7 @@ impl Ferese {
} else { } else {
self.gap_config.outer 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> { pub(super) fn visible_workspace_ids(&self) -> HashSet<WorkspaceId> {
let mut visible = self let mut visible = self
@@ -321,7 +321,7 @@ impl Ferese {
}; };
let layout_bounds = self.workspace_layout_bounds(bounds, &workspace.layout); 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 workspace_fullscreen = workspace.fullscreen;
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_)); let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused; let workspace_focus = workspace.last_focused;
@@ -750,7 +750,7 @@ impl Ferese {
windows.sort_by_key(|w| w.id.0); windows.sort_by_key(|w| w.id.0);
let input = PreviewInput { let input = PreviewInput {
bounds: self.workspace_layout_bounds(bounds, &workspace.layout), 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), fullscreen_bounds: self.full_output_bounds_for(output).unwrap_or(bounds),
gaps: self.gap_config, gaps: self.gap_config,
focused: workspace.last_focused, focused: workspace.last_focused,
@@ -835,3 +835,7 @@ impl Ferese {
} }
} }
} }
#[cfg(test)]
#[path = "panel_clearance_tests.rs"]
mod panel_clearance_tests;
+7
View File
@@ -97,6 +97,13 @@ impl Ferese {
{ {
return; 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.display_presentation.remove_output(output);
self.output_redraw_pending.retain(|pending| pending != output); self.output_redraw_pending.retain(|pending| pending != output);
if let Some(id) = self.output_ids.get(output) { if let Some(id) = self.output_ids.get(output) {
@@ -0,0 +1,116 @@
use super::*;
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
use smithay::utils::Transform;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) {
let words = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode];
wire.write_all(
&words
.into_iter()
.chain(args.iter().copied())
.flat_map(u32::to_ne_bytes)
.collect::<Vec<_>>(),
)
.unwrap();
}
#[test]
fn every_panel_style_owns_clearance_at_its_reserved_edge() {
if !crate::startup_tests::private_runtime(
"state::layout::panel_clearance_tests::every_panel_style_owns_clearance_at_its_reserved_edge",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = Output::new(
"clearance".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
state.begin_desktop_transition();
let changes = state
.publish_desktop(vec![DesktopOutput {
output: output.clone(),
identity: "clearance".into(),
mode: Mode {
size: (800, 600).into(),
refresh: 60_000,
},
transform: Transform::Normal,
scale: Scale::Fractional(1.0),
position: (0, 0).into(),
}])
.unwrap();
state.finish_desktop_transition(changes);
let (mut wire, server) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(ClientState::default()))
.unwrap();
let compositor = client
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let layers = client
.create_resource::<ZwlrLayerShellV1, (), Ferese>(&state.display_handle, 4, ())
.unwrap();
request(&mut wire, compositor.id().protocol_id(), 0, &[2]);
let mut namespace = b"ferese-shell-top-bar\0".to_vec();
let length = namespace.len() as u32;
namespace.resize(namespace.len().div_ceil(4) * 4, 0);
let args = [3, 2, 0, 2, length]
.into_iter()
.chain(
namespace
.chunks_exact(4)
.map(|b| u32::from_ne_bytes(b.try_into().unwrap())),
)
.collect::<Vec<_>>();
request(&mut wire, layers.id().protocol_id(), 0, &args);
request(&mut wire, 3, 0, &[800, 36]);
request(&mut wire, 3, 1, &[1 | 4 | 8]); // Top, left and right.
for style in ferese_panel::PanelStyle::ALL {
state.panel_style = style;
for clearance in [0, 5, 24] {
let mut panel = ferese_panel::Panel::from_preset(style);
panel.geometry.height = Some(36.);
panel.geometry.window_clearance = Some(clearance);
panel.geometry.edge_margin = Some(8);
let geometry = panel.resolved_geometry();
request(&mut wire, 3, 2, &[panel.exclusive_zone(false) as u32]);
request(&mut wire, 3, 3, &[geometry.edge_margin as u32, 0, 0, 0]);
request(&mut wire, 2, 6, &[]);
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
let bounds = state.output_bounds_for(&output).unwrap();
let edge = f64::from(geometry.edge_margin + 36 + clearance);
assert_eq!(bounds.y, edge, "{style:?}, clearance={clearance}");
for outer in [0., 12.] {
let adjusted = state.panel_layout_bounds(bounds, outer);
let window = maximized_rect(adjusted, outer);
assert_eq!(window.y, edge, "{style:?}, clearance={clearance}, outer={outer}");
assert_eq!(window.x, bounds.x + outer);
assert_eq!(window.y + window.height, bounds.y + bounds.height - outer);
}
}
}
request(&mut wire, 3, 2, &[0]);
request(&mut wire, 2, 6, &[]);
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
let bounds = state.output_bounds_for(&output).unwrap();
assert_eq!(
state.panel_layout_bounds(bounds, 12.),
bounds,
"an unreserved panel must not remove gaps"
);
}
+6
View File
@@ -166,6 +166,12 @@ impl Ferese {
pub(crate) fn finish_desktop_transition(&mut self, mut changes: DesktopChanges) { pub(crate) fn finish_desktop_transition(&mut self, mut changes: DesktopChanges) {
self.desktop_transition.as_mut().unwrap().publishing = true; 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 { if changes.layout {
self.relayout_desktop(); self.relayout_desktop();
} }
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
https://github.com/rcaloras/bash-preexec
Commit: 5ae4758c36e8391fb3932e6ae68c283489fc813d
License: MIT (LICENSE.md)
Files are unmodified.
@@ -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 ([email protected])
# 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
+47
View File
@@ -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
+26
View File
@@ -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
+29
View File
@@ -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
+9
View File
@@ -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
+553
View File
@@ -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<u64> {
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<String> },
}
#[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<String>,
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<Time>,
pub result_deadline: Option<Time>,
pub result_message: Option<String>,
pub flash_on_fail: bool,
pub result_flash_ms: u64,
/// A presentation group only; every invocation retains its own lifetime.
pub group: Option<String>,
}
impl Activity {
pub fn eligible(&self, now: Time, dnd: bool) -> bool {
now >= self.eligible_at
&& match self.state {
State::Running => self.lease_deadline.is_none_or(|deadline| now < deadline),
State::Done | State::Failed => !dnd && self.result_deadline.is_some_and(|deadline| now < deadline),
State::Cancelled => false,
}
}
pub fn score(&self, now: Time, ceiling: u8) -> u16 {
u16::from(self.data.priority.min(ceiling))
+ if now < self.eligible_at.saturating_add(BOOST_MS) {
30
} else {
0
}
+ match self.state {
State::Failed => 100,
State::Done => 40,
_ => 0,
}
}
}
#[derive(Debug)]
pub struct Store {
pub limits: Limits,
session: u64,
generation: u64,
entries: BTreeMap<Id, Activity>,
}
impl Store {
pub fn new(session: u64) -> Self {
Self {
limits: Limits::default(),
session,
generation: 0,
entries: BTreeMap::new(),
}
}
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
self.entries.values()
}
pub fn resolve(&self, owner: Owner, local: &str) -> Option<&Id> {
self.entries.keys().find(|id| id.owner == owner && id.local == local)
}
#[allow(clippy::too_many_arguments)]
pub fn start(
&mut self,
owner: Owner,
local: String,
mut data: Data,
now: Time,
min_duration_ms: u64,
lease_ms: Option<u64>,
monitored: bool,
flash_on_fail: bool,
group: Option<String>,
) -> Result<Id, &'static str> {
self.advance(now);
text(&local)?;
if local.is_empty() {
return Err("activity ID must not be empty");
}
data.validate(now)?;
validate_lease(lease_ms, monitored)?;
if self.resolve(owner, &local).is_some() {
return Err("duplicate activity ID");
}
if self.entries.len() >= self.limits.max_total {
return Err("session activity quota exceeded");
}
if self.entries.keys().filter(|id| id.owner == owner).count() >= 3 {
return Err("owner activity quota exceeded");
}
self.generation = self.generation.checked_add(1).ok_or("generation exhausted")?;
let id = Id {
owner,
local,
generation: self.generation,
session: self.session,
};
self.entries.insert(
id.clone(),
Activity {
id: id.clone(),
data,
state: State::Running,
created_at: now,
eligible_at: now.saturating_add(self.limits.start_delay_ms.max(min_duration_ms)),
refreshed_at: now,
lease_deadline: lease_ms.map(|ms| now.saturating_add(ms)),
result_deadline: None,
result_message: None,
flash_on_fail,
result_flash_ms: self.limits.result_flash_ms,
group,
},
);
Ok(id)
}
fn entry(&mut self, owner: Owner, id: &Id) -> Result<&mut Activity, &'static str> {
if id.session != self.session {
return Err("stale activity session; register again");
}
if id.owner != owner {
return Err("activity owner rejected");
}
self.entries.get_mut(id).ok_or("stale activity generation")
}
pub fn update(
&mut self,
owner: Owner,
id: &Id,
value: Option<Value>,
lease_ms: Option<u64>,
now: Time,
) -> Result<(), &'static str> {
self.advance(now);
validate_lease(lease_ms, true)?;
let entry = self.entry(owner, id)?;
if entry.state != State::Running {
return Err("activity already completed");
}
if let Some(value) = value {
let mut candidate = entry.data.clone();
candidate.value = value;
candidate.validate(now)?;
entry.data = candidate;
}
if let Some(ms) = lease_ms {
validate_lease(Some(ms), true)?;
entry.lease_deadline = Some(now.saturating_add(ms));
} else if let Some(deadline) = entry.lease_deadline {
entry.lease_deadline = Some(now.saturating_add(deadline.saturating_sub(entry.refreshed_at)));
}
entry.refreshed_at = now;
Ok(())
}
pub fn finish(
&mut self,
owner: Owner,
id: &Id,
state: State,
message: Option<String>,
now: Time,
) -> Result<(), &'static str> {
self.advance(now);
if state == State::Running {
return Err("finish requires a terminal result");
}
if let Some(message) = &message {
text(message)?;
}
let entry = self.entry(owner, id)?;
if entry.state != State::Running {
return if entry.state == state {
Ok(())
} else {
Err("terminal result already accepted")
};
}
if state == State::Cancelled || now < entry.eligible_at || (state == State::Failed && !entry.flash_on_fail) {
self.entries.remove(id);
return Ok(());
}
entry.state = state;
if let Some(message) = message {
entry.result_message = Some(message);
}
entry.result_deadline = Some(now.saturating_add(entry.result_flash_ms));
Ok(())
}
pub fn owner_exit(&mut self, owner: Owner) {
self.entries
.retain(|id, entry| id.owner != owner || entry.state != State::Running);
}
pub fn remove_output(&mut self, output: &str, fallback: Option<&str>) {
for entry in self
.entries
.values_mut()
.filter(|entry| entry.data.output.as_deref() == Some(output))
{
entry.data.output = fallback.map(str::to_owned);
}
}
pub fn attach_output(&mut self, output: &str) -> bool {
let mut changed = false;
for entry in self.entries.values_mut().filter(|entry| entry.data.output.is_none()) {
entry.data.output = Some(output.into());
changed = true;
}
changed
}
pub fn advance(&mut self, now: Time) -> bool {
let before = self.entries.len();
self.entries.retain(|_, entry| match entry.state {
State::Running => entry.lease_deadline.is_none_or(|deadline| now < deadline),
_ => entry.result_deadline.is_some_and(|deadline| now < deadline),
});
before != self.entries.len()
}
pub fn next_deadline(&self, now: Time) -> Option<Time> {
self.entries
.values()
.flat_map(|entry| {
[
Some(entry.eligible_at),
Some(entry.eligible_at.saturating_add(BOOST_MS)),
entry.lease_deadline,
entry.result_deadline,
entry
.result_deadline
.map(|deadline| deadline.saturating_sub(entry.result_flash_ms).saturating_add(400)),
]
})
.flatten()
.filter(|deadline| *deadline > now)
.min()
}
}
fn validate_lease(lease: Option<u64>, monitored: bool) -> Result<(), &'static str> {
match lease {
Some(0) => Err("lease must be positive"),
Some(ms) if ms > MAX_LEASE_MS => Err("lease exceeds five minutes"),
None if !monitored => Err("unmonitored owner requires a lease"),
_ => Ok(()),
}
}
/// Recording/battery occupy a separate host-only class. Notifications always
/// follow every other ordinary source, even when a result boost is active.
pub fn ranked<'a>(
entries: impl IntoIterator<Item = &'a Activity>,
now: Time,
limits: Limits,
output: &str,
dnd: bool,
) -> Vec<&'a Activity> {
let mut entries: Vec<_> = entries
.into_iter()
.filter(|entry| entry.eligible(now, dnd) && entry.data.output.as_deref() == Some(output))
.collect();
entries.sort_by(|a, b| {
(a.data.source == Source::Notification)
.cmp(&(b.data.source == Source::Notification))
.then_with(|| {
b.score(now, limits.owner_ceiling)
.cmp(&a.score(now, limits.owner_ceiling))
})
.then_with(|| a.eligible_at.cmp(&b.eligible_at))
.then_with(|| a.id.cmp(&b.id))
});
entries
}
#[derive(Clone, Debug, Default)]
pub struct Selection {
pinned: Option<Vec<Id>>,
pub visible: Vec<Id>,
pub hidden: usize,
policy: Option<(bool, usize, usize)>,
}
impl Selection {
pub fn enter(&mut self) {
self.pinned = Some(self.visible.clone());
}
pub fn leave(&mut self) {
self.pinned = None;
}
pub fn override_pin(&mut self) {
self.pinned = None;
}
pub fn select(&mut self, ranked: &[&Activity], limits: Limits, dnd: bool, protected_slots: usize) {
let policy = (dnd, protected_slots, limits.max_visible);
if self.policy != Some(policy) {
self.override_pin();
self.policy = Some(policy);
}
let count = limits
.max_visible
.clamp(1, 2)
.saturating_sub(protected_slots)
.min(if dnd { 1 } else { 2 });
let eligible: Vec<_> = ranked
.iter()
.filter(|entry| !dnd || entry.state == State::Running)
.collect();
let mut groups = std::collections::BTreeSet::new();
let ids: Vec<_> = eligible
.iter()
.filter(|entry| {
let Some(group) = &entry.group else {
return true;
};
if entry.state != State::Running
&& eligible.iter().any(|other| {
other.id.owner == entry.id.owner
&& other.group.as_ref() == Some(group)
&& other.state == State::Running
})
{
return false;
}
groups.insert((entry.id.owner, group.clone()))
})
.map(|entry| entry.id.clone())
.collect();
self.visible = match &mut self.pinned {
Some(pinned) => {
pinned.retain(|id| ids.contains(id));
pinned.iter().take(count).cloned().collect()
}
None => ids.iter().take(count).cloned().collect(),
};
self.hidden = if dnd || !limits.show_badge {
0
} else {
eligible.len().saturating_sub(self.visible.len())
};
}
}
/// Focus survives reordered/updated rows by identity; removal chooses the next
/// surviving row at the former position.
pub fn reconcile_focus(focus: &mut Option<Id>, previous: &[Id], current: &[Id]) {
if focus.as_ref().is_some_and(|id| current.contains(id)) {
return;
}
let index = focus
.as_ref()
.and_then(|id| previous.iter().position(|old| old == id))
.unwrap_or(0);
*focus = current.get(index.min(current.len().saturating_sub(1))).cloned();
}
#[cfg(test)]
mod tests;
+323
View File
@@ -0,0 +1,323 @@
use super::*;
fn owner() -> Owner {
Owner { pid: 42, birth: 100 }
}
fn data(priority: u8) -> Data {
Data {
source: Source::Publisher,
output: Some("one".into()),
priority,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
}
}
fn start(store: &mut Store, local: &str, now: Time, priority: u8) -> Id {
store
.start(
owner(),
local.into(),
data(priority),
now,
0,
Some(10_000),
true,
true,
None,
)
.unwrap()
}
#[test]
fn badge_visibility_and_visible_limit_reload_while_hovered() {
let mut store = Store::new(1);
for name in ["a", "b", "c"] {
start(&mut store, name, 0, 100);
}
let entries = ranked(store.entries(), 500, store.limits, "one", false);
let mut limits = store.limits;
limits.max_visible = 1;
let mut selection = Selection::default();
selection.select(&entries, limits, false, 0);
assert_eq!(selection.visible.len(), 1);
assert_eq!(selection.hidden, 2);
selection.enter();
limits.show_badge = false;
selection.select(&entries, limits, false, 0);
assert_eq!(selection.visible.len(), 1);
assert_eq!(selection.hidden, 0);
assert_eq!(entries.len(), 3, "hiding the badge must keep the full list");
limits.max_visible = 2;
selection.select(&entries, limits, false, 0);
assert_eq!(selection.visible.len(), 2);
assert_eq!(selection.hidden, 0);
limits.show_badge = true;
selection.select(&entries, limits, false, 0);
assert_eq!(selection.hidden, 1);
}
#[test]
fn boundaries_and_updates_do_not_restart_delay_or_boost() {
let mut store = Store::new(1);
let id = start(&mut store, "a", 10, 255);
assert!(!store.entries().next().unwrap().eligible(309, false));
store
.update(owner(), &id, Some(Value::Progress(0.5)), None, 200)
.unwrap();
let entry = store.entries().next().unwrap();
assert_eq!(entry.eligible_at, 310);
assert!(entry.eligible(310, false));
assert_eq!(entry.score(5_309, 255), 285);
assert_eq!(entry.score(5_310, 255), 255);
assert_eq!(store.next_deadline(310), Some(5_310));
}
#[test]
fn fast_failures_cancel_and_suppressed_failure_never_flash() {
for state in [State::Done, State::Failed, State::Cancelled] {
let mut store = Store::new(1);
let id = start(&mut store, "a", 0, 100);
store.finish(owner(), &id, state, None, 299).unwrap();
assert_eq!(store.entries().count(), 0);
}
let mut store = Store::new(1);
let id = store
.start(owner(), "a".into(), data(100), 0, 0, None, true, false, None)
.unwrap();
store.finish(owner(), &id, State::Failed, None, 300).unwrap();
assert_eq!(store.entries().count(), 0);
}
#[test]
fn result_retry_and_owner_exit_preserve_original_deadline() {
let mut store = Store::new(1);
let id = start(&mut store, "a", 0, 255);
store
.finish(owner(), &id, State::Failed, Some("Failed".into()), 300)
.unwrap();
assert_eq!(store.entries().next().unwrap().score(300, 255), 385);
store.finish(owner(), &id, State::Failed, None, 1_000).unwrap();
store.owner_exit(owner());
assert_eq!(store.entries().next().unwrap().result_deadline, Some(1_800));
assert!(!store.advance(1_799));
assert!(store.advance(1_800));
assert_eq!(store.entries().count(), 0);
}
#[test]
fn leases_refresh_but_abandonment_never_claims_a_result() {
let mut store = Store::new(1);
let id = store
.start(owner(), "a".into(), data(100), 0, 0, Some(500), false, true, None)
.unwrap();
store.update(owner(), &id, None, None, 499).unwrap();
assert_eq!(store.entries().next().unwrap().lease_deadline, Some(999));
store.advance(998);
assert_eq!(store.entries().count(), 1);
store.advance(999);
assert_eq!(store.entries().count(), 0);
start(&mut store, "a", 1_000, 100);
store.owner_exit(owner());
assert_eq!(store.entries().count(), 0);
assert!(
store
.start(owner(), "b".into(), data(0), 0, 0, None, false, true, None)
.is_err()
);
}
#[test]
fn owner_session_generation_and_quotas_are_independent_of_connections() {
let mut store = Store::new(12);
let id = start(&mut store, "a", 0, 100);
let other = Owner { pid: 42, birth: 101 };
assert!(store.update(other, &id, None, None, 0).is_err());
let mut stale = id.clone();
stale.session = 11;
assert!(store.update(owner(), &stale, None, None, 0).is_err());
assert!(
store
.start(owner(), "a".into(), data(100), 0, 0, None, true, true, None)
.is_err()
);
start(&mut store, "b", 0, 0);
start(&mut store, "c", 0, 0);
assert!(
store
.start(owner(), "d".into(), data(100), 0, 0, None, true, true, None)
.is_err()
);
store.finish(owner(), &id, State::Cancelled, None, 0).unwrap();
let replacement = start(&mut store, "a", 0, 100);
assert_ne!(replacement.generation, id.generation);
assert!(store.update(owner(), &id, None, None, 0).is_err());
}
#[test]
fn stable_ties_boost_expiry_notification_priority_and_hover_pinning() {
let mut store = Store::new(1);
let a = start(&mut store, "a", 0, 100);
let b = start(&mut store, "b", 0, 100);
let c = start(&mut store, "c", 5_000, 110);
let mut selection = Selection::default();
selection.select(
&ranked(store.entries(), 300, store.limits, "one", false),
store.limits,
false,
0,
);
assert_eq!(selection.visible, [a.clone(), b.clone()]);
selection.enter();
selection.select(
&ranked(store.entries(), 5_300, store.limits, "one", false),
store.limits,
false,
0,
);
assert_eq!(selection.visible, [a.clone(), b.clone()]);
assert_eq!(selection.hidden, 1);
store.owner_exit(owner());
selection.select(
&ranked(store.entries(), 5_300, store.limits, "one", false),
store.limits,
false,
0,
);
assert!(selection.visible.is_empty());
selection.leave();
let _ = c;
}
#[test]
fn dnd_protected_slots_and_output_routing() {
let mut store = Store::new(1);
let a = start(&mut store, "a", 0, 100);
start(&mut store, "b", 0, 100);
store.finish(owner(), &a, State::Done, None, 300).unwrap();
let mut selection = Selection::default();
selection.select(
&ranked(store.entries(), 300, store.limits, "one", true),
store.limits,
true,
0,
);
assert_eq!(selection.visible.len(), 1);
assert_eq!(selection.hidden, 0);
selection.select(
&ranked(store.entries(), 300, store.limits, "one", false),
store.limits,
false,
2,
);
assert!(selection.visible.is_empty());
assert_eq!(selection.hidden, 2);
store.remove_output("one", Some("two"));
assert!(ranked(store.entries(), 300, store.limits, "one", false).is_empty());
assert_eq!(ranked(store.entries(), 300, store.limits, "two", false).len(), 2);
}
#[test]
fn typed_validation_countdown_clock_and_focus_identity() {
let mut value = data(0);
for progress in [f32::NAN, f32::INFINITY, -0.1, 1.1] {
value.value = Value::Progress(progress);
assert!(value.validate(0).is_err());
}
value.value = Value::Indeterminate;
value.icon = "/tmp/icon.svg".into();
assert!(value.validate(0).is_err());
value.icon = "unknown".into();
value.validate(0).unwrap();
assert_eq!(value.icon, "ferese");
value.template = Template::Timer;
value.value = Value::Countdown {
remaining_ms: 1_000,
sampled_at: u64::MAX,
};
value.validate(100).unwrap();
assert_eq!(value.value.remaining_ms(900), Some(200));
let mut store = Store::new(1);
let a = start(&mut store, "a", 0, 0);
let b = start(&mut store, "b", 0, 0);
let mut focus = Some(b.clone());
reconcile_focus(&mut focus, &[a.clone(), b.clone()], &[b.clone(), a.clone()]);
assert_eq!(focus, Some(b.clone()));
reconcile_focus(&mut focus, &[b, a.clone()], std::slice::from_ref(&a));
assert_eq!(focus, Some(a));
}
#[test]
fn notification_boost_cannot_outrank_ordinary_and_grouped_lifetimes_remain_independent() {
let mut store = Store::new(1);
let a = store
.start(
owner(),
"a".into(),
data(0),
0,
0,
None,
true,
true,
Some("cargo:directory".into()),
)
.unwrap();
let b = store
.start(
owner(),
"b".into(),
data(1),
0,
0,
None,
true,
true,
Some("cargo:directory".into()),
)
.unwrap();
let mut notification = data(255);
notification.source = Source::Notification;
let notice = store
.start(owner(), "notice".into(), notification, 0, 0, None, true, true, None)
.unwrap();
store.finish(owner(), &notice, State::Failed, None, 300).unwrap();
let entries = ranked(store.entries(), 300, store.limits, "one", false);
assert_eq!(entries.last().unwrap().id, notice);
let mut selection = Selection::default();
selection.select(&entries, store.limits, false, 0);
assert_eq!(selection.visible[0], b);
store.finish(owner(), &b, State::Done, None, 300).unwrap();
let entries = ranked(store.entries(), 301, store.limits, "one", false);
selection.select(&entries, store.limits, false, 0);
assert_eq!(selection.visible[0], a);
assert_eq!(
store.entries().find(|entry| entry.id == a).unwrap().state,
State::Running
);
store.remove_output("one", None);
assert!(store.attach_output("two"));
assert_eq!(ranked(store.entries(), 301, store.limits, "two", false).len(), 3);
}
#[test]
fn invalid_updates_are_atomic_and_suspend_aware_eligibility_hides_expired_records() {
let mut store = Store::new(1);
let id = store
.start(owner(), "atomic".into(), data(100), 0, 0, Some(1_000), true, true, None)
.unwrap();
assert!(
store
.update(owner(), &id, Some(Value::Progress(0.8)), Some(0), 300)
.is_err()
);
let entry = store.entries().next().unwrap();
assert_eq!(entry.data.value, Value::Indeterminate);
assert_eq!(entry.refreshed_at, 0);
assert_eq!(entry.lease_deadline, Some(1_000));
assert!(
!entry.eligible(5_000, false),
"a resumed host cannot present an expired lease before its timer callback"
);
}
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-activities = { path = "../ferese-activities" }
regex = "1.12"
ferese-panel = { path = "../ferese-panel" } ferese-panel = { path = "../ferese-panel" }
ferese-theme-model = { path = "../ferese-theme-model" } ferese-theme-model = { path = "../ferese-theme-model" }
jiff.workspace = true jiff.workspace = true
+60
View File
@@ -0,0 +1,60 @@
// Built-in rules. A same-named user rule replaces the complete definition.
activities {
sources { shell-hook #false; process-watch #false; notification-bridge #false; }
rule cargo {
match #"^cargo (build|check|test|run|clippy)\b"#
title "cargo {1}"
icon hammer
priority 100
progress indeterminate
min-duration-ms 1500
ignore-parents rust-analyzer
dedupe-by cwd
flash-on-fail #true
result ok=Finished fail="Command failed"
}
rule npm {
match #"^(npm|pnpm) (install|build|test|run)\b"#
title "{1} {2}"
icon hammer
priority 100
progress indeterminate
min-duration-ms 1500
dedupe-by cwd
flash-on-fail #true
result ok=Finished fail="Command failed"
}
rule make {
match #"^make(?:\s|$)"#
title Make
icon hammer
priority 100
progress indeterminate
min-duration-ms 300
dedupe-by cwd
flash-on-fail #true
result ok=Built fail="Command failed"
}
rule git-push {
match #"^git push(?:\s|$)"#
title "Git push"
icon hammer
priority 100
progress indeterminate
min-duration-ms 300
dedupe-by cwd
flash-on-fail #true
result ok=Pushed fail="Command failed"
}
rule docker-build {
match #"^docker build(?:\s|$)"#
title "Docker build"
icon hammer
priority 100
progress indeterminate
min-duration-ms 1500
dedupe-by cwd
flash-on-fail #true
result ok="Image built" fail="Command failed"
}
}
+401
View File
@@ -0,0 +1,401 @@
//! Activity rules are matched in declaration order, followed by unreplaced
//! built-ins. Matching metadata is bounded and never stored in the result.
use kdl::KdlNode;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
/// Enable installation defaults without overriding an explicit user opt-out.
pub fn setup_shell_hook(document: &mut crate::Document) -> Result<bool, crate::Error> {
let explicit = document
.doc
.get("activities")
.and_then(kdl::KdlNode::children)
.and_then(|sources| sources.get("sources"))
.and_then(kdl::KdlNode::children)
.and_then(|sources| sources.get("shell-hook"))
.and_then(|node| node.get(0))
.and_then(kdl::KdlValue::as_bool);
if explicit == Some(false) {
return Ok(false);
}
if explicit.is_none() {
document.set("activities.sources.shell_hook", json!(true))?;
}
Ok(true)
}
use crate::Error;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
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,
pub sources: Sources,
pub rules: Vec<Rule>,
}
impl Default for Settings {
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,
sources: Sources::default(),
rules: builtins(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Sources {
pub shell_hook: bool,
pub process_watch: bool,
pub notification_bridge: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub name: String,
#[serde(rename = "match")]
pub pattern: String,
pub title: String,
pub icon: String,
pub priority: u8,
pub progress: String,
pub min_duration_ms: u64,
pub ignore_parents: Vec<String>,
pub dedupe_by: Option<String>,
pub flash_on_fail: bool,
pub result: ResultText,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResultText {
pub ok: String,
pub fail: String,
}
#[derive(Clone, Debug)]
pub struct Matcher {
rules: Vec<(Rule, Regex)>,
}
#[derive(Clone, Debug)]
pub struct Matched {
pub rule: Rule,
pub title: String,
pub group: Option<String>,
}
impl Matcher {
pub fn new(settings: &Settings) -> Result<Self, String> {
let mut rules = Vec::new();
for rule in &settings.rules {
let regex = regex::RegexBuilder::new(&rule.pattern)
.size_limit(256 * 1024)
.dfa_size_limit(256 * 1024)
.build()
.map_err(|error| error.to_string())?;
validate_rule(rule, &regex)?;
rules.push((rule.clone(), regex));
}
Ok(Self { rules })
}
pub fn matches(&self, command: &str, cwd: &str, parents: &[String]) -> Option<Matched> {
if command.len() > 4_096 || cwd.len() > 1_024 || parents.len() > 16 {
return None;
}
for (rule, regex) in &self.rules {
if parents.iter().any(|parent| rule.ignore_parents.contains(parent)) {
continue;
}
if let Some(captures) = regex.captures(command) {
let mut title = rule.title.clone();
for index in 0..captures.len() {
title = title.replace(&format!("{{{index}}}"), captures.get(index).map_or("", |m| m.as_str()));
}
if title.len() > 256 || title.chars().any(char::is_control) {
return None;
}
return Some(Matched {
rule: rule.clone(),
title,
group: (rule.dedupe_by.as_deref() == Some("cwd")).then(|| {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
cwd.hash(&mut hash);
format!("{}:{:x}", rule.name, hash.finish())
}),
});
}
}
None
}
}
fn validate_rule(rule: &Rule, regex: &Regex) -> Result<(), String> {
for value in [&rule.name, &rule.title, &rule.result.ok, &rule.result.fail] {
if value.len() > 256 || value.chars().any(char::is_control) {
return Err("rule text exceeds its budget".into());
}
}
if rule.pattern.len() > 2_048
|| rule.min_duration_ms > 60_000
|| !matches!(rule.progress.as_str(), "indeterminate" | "cargo-json")
|| rule.dedupe_by.as_deref().is_some_and(|value| value != "cwd")
{
return Err("invalid rule duration, progress adapter or dedupe policy".into());
}
if rule.ignore_parents.len() > 16 || rule.ignore_parents.iter().any(|name| name.len() > 128) {
return Err("too many or too long parent names".into());
}
if rule.icon.len() > 64 || !rule.icon.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("invalid symbolic icon".into());
}
let capture = Regex::new(r"\{([^}]+)\}").expect("static pattern");
for reference in capture.captures_iter(&rule.title) {
if reference[1]
.parse::<usize>()
.ok()
.is_none_or(|index| index >= regex.captures_len())
{
return Err("invalid title capture reference".into());
}
}
Ok(())
}
impl Settings {
pub fn limits(&self) -> ferese_activities::Limits {
ferese_activities::Limits {
max_visible: self.max_visible,
show_badge: self.show_badge,
max_total: self.max_total,
owner_ceiling: self.owner_ceiling,
start_delay_ms: self.start_delay_ms,
result_flash_ms: self.result_flash_ms,
}
}
pub fn validate(&self) -> Result<(), String> {
if !(1..=2).contains(&self.max_visible)
|| !(1..=128).contains(&self.max_total)
|| self.start_delay_ms > 60_000
|| self.result_flash_ms > 10_000
|| self.rules.len() > 64
{
return Err("activity settings exceed supported limits".into());
}
let mut names = std::collections::HashSet::new();
if self.rules.iter().any(|rule| !names.insert(&rule.name)) {
return Err("duplicate activity rule".into());
}
Matcher::new(self).map(|_| ())
}
}
fn diagnostic(node: &KdlNode, message: impl std::fmt::Display) -> Error {
Error(format!("activities at byte {}: {message}", node.span().offset()))
}
pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
if !node.entries().is_empty() {
return Err(diagnostic(node, "section takes no properties"));
}
let mut values = Map::new();
let mut rules = Vec::new();
let mut names = std::collections::HashSet::new();
for child in node.children().into_iter().flat_map(|doc| doc.nodes()) {
let key = child.name().value();
if key == "rule" {
let Some(name) = child.get(0).and_then(kdl::KdlValue::as_string) else {
return Err(diagnostic(child, "rule requires a name"));
};
if child.entries().len() != 1 || !names.insert(name.to_owned()) {
return Err(diagnostic(child, "duplicate rule or extra rule properties"));
}
let mut value = Map::new();
value.insert("name".into(), json!(name));
for property in child.children().into_iter().flat_map(|doc| doc.nodes()) {
let name = property.name().value();
if !matches!(
name,
"match"
| "title"
| "icon"
| "priority"
| "progress"
| "min-duration-ms"
| "ignore-parents"
| "dedupe-by"
| "flash-on-fail"
| "result"
) {
return Err(diagnostic(property, "unknown rule node"));
}
let key = name.replace('-', "_");
let parsed = if name == "ignore-parents" {
if property.entries().iter().any(|entry| entry.name().is_some()) || property.entries().is_empty() {
return Err(diagnostic(property, "ignore-parents requires names"));
}
Value::Array(
property
.entries()
.iter()
.map(|entry| crate::scalar(entry.value()))
.collect::<Result<_, _>>()?,
)
} else {
crate::node_value(property, &key, "rule").map_err(|error| diagnostic(property, error))?
};
if value.insert(key, parsed).is_some() {
return Err(diagnostic(property, "duplicate rule setting"));
}
}
value.entry("icon").or_insert(json!("ferese"));
value.entry("priority").or_insert(json!(100));
value.entry("progress").or_insert(json!("indeterminate"));
value.entry("min_duration_ms").or_insert(json!(0));
value.entry("ignore_parents").or_insert(json!([]));
value.entry("dedupe_by").or_insert(Value::Null);
value.entry("flash_on_fail").or_insert(json!(true));
value
.entry("result")
.or_insert(json!({"ok":"Finished","fail":"Command failed"}));
let rule: Rule = serde_json::from_value(Value::Object(value)).map_err(|error| diagnostic(child, error))?;
let settings = Settings {
rules: vec![rule.clone()],
..Default::default()
};
settings.validate().map_err(|error| diagnostic(child, error))?;
rules.push(rule);
} else {
if !matches!(
key,
"max-visible"
| "show-badge"
| "max-total"
| "owner-ceiling"
| "start-delay-ms"
| "result-flash-ms"
| "sources"
) {
return Err(diagnostic(child, "unknown activity node"));
}
let field = key.replace('-', "_");
let value = crate::node_value(child, &field, "activities").map_err(|error| diagnostic(child, error))?;
if values.insert(field, value).is_some() {
return Err(diagnostic(child, "duplicate activity setting"));
}
}
}
rules.extend(builtins().into_iter().filter(|rule| !names.contains(&rule.name)));
values.insert("rules".into(), serde_json::to_value(rules).unwrap());
let settings: Settings = serde_json::from_value(Value::Object(values)).map_err(|error| diagnostic(node, error))?;
settings.validate().map_err(|error| diagnostic(node, error))?;
Ok(serde_json::to_value(settings).unwrap())
}
fn builtins() -> Vec<Rule> {
[
(
"cargo",
r"^cargo (build|check|test|run|clippy)\b",
"cargo {1}",
"Finished",
1_500,
),
(
"npm",
r"^(npm|pnpm) (install|build|test|run)\b",
"{1} {2}",
"Finished",
1_500,
),
("make", r"^make(?:\s|$)", "Make", "Built", 300),
("git-push", r"^git push(?:\s|$)", "Git push", "Pushed", 300),
(
"docker-build",
r"^docker build(?:\s|$)",
"Docker build",
"Image built",
1_500,
),
]
.into_iter()
.map(|(name, pattern, title, ok, min_duration_ms)| Rule {
name: name.into(),
pattern: pattern.into(),
title: title.into(),
icon: "hammer".into(),
priority: 100,
progress: "indeterminate".into(),
min_duration_ms,
ignore_parents: if name == "cargo" {
vec!["rust-analyzer".into()]
} else {
vec![]
},
dedupe_by: Some("cwd".into()),
flash_on_fail: true,
result: ResultText {
ok: ok.into(),
fail: "Command failed".into(),
},
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_are_opt_in_and_matching_discards_nonmatches() {
let settings = Settings::default();
assert!(!settings.sources.shell_hook);
assert!(!settings.sources.process_watch);
let matcher = Matcher::new(&settings).unwrap();
assert!(matcher.matches("secret command", "/tmp", &[]).is_none());
let matched = matcher.matches("cargo test", "/tmp", &[]).unwrap();
assert_eq!(matched.title, "cargo test");
assert!(
matcher
.matches("cargo test", "/tmp", &["rust-analyzer".into()])
.is_none()
);
}
#[test]
fn shipped_kdl_matches_the_builtin_catalog() {
let document = crate::Document::parse(include_str!("../activities.kdl")).unwrap();
assert_eq!(
document.value()["activities"]["rules"],
serde_json::to_value(Settings::default().rules).unwrap()
);
}
#[test]
fn complete_rule_replacement_and_diagnostics() {
let source = "activities { rule cargo { match \"^cargo\"; title Custom; }; }";
let doc = crate::Document::parse(source).unwrap();
let settings: Settings = serde_json::from_value(doc.value()["activities"].clone()).unwrap();
assert_eq!(settings.rules[0].title, "Custom");
assert_eq!(settings.rules[0].min_duration_ms, 0);
assert_eq!(settings.rules.len(), 5);
for invalid in [
"activities { unknown 1; }",
"activities { max-visible 3; }",
"activities { show-badge 1; }",
"activities { rule x { match \"[\"; title X; }; }",
"activities { rule x { match \"(a)\"; title \"{2}\"; }; }",
"activities { sources { typo #true; }; }",
] {
assert!(
crate::Document::parse(invalid)
.unwrap_err()
.to_string()
.contains("byte")
);
}
}
}
+6 -1
View File
@@ -1,3 +1,4 @@
pub mod activities;
pub mod families; pub mod families;
pub mod file; pub mod file;
pub mod notifications; pub mod notifications;
@@ -144,7 +145,11 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
continue; continue;
} }
let value = node_value(node, &key, parent)?; let value = if parent.is_empty() && key == "activities" {
activities::parse(node)?
} else {
node_value(node, &key, parent)?
};
if is_records(&key, parent) { if is_records(&key, parent) {
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new())); let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
records.as_array_mut().unwrap().push(value); records.as_array_mut().unwrap().push(value);
+24
View File
@@ -238,3 +238,27 @@ fn item_options_are_strict_and_metadata_is_explicit() {
.is_err() .is_err()
); );
} }
#[test]
fn activities_are_first_class_items_in_every_zone_and_keep_overflow_settings() {
for style in ["flat", "floating", "morph", "adaptive"] {
for zone in ["start", "center", "end"] {
let source = format!(
"panel main {{ style {style}; {zone} {{ group tasks {{ item builds kind=activities priority=20 overflow=always; }}; }}; }}"
);
let mut doc = ferese_config::Document::parse(&source).unwrap();
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
ferese_panel::validate(&panels).unwrap();
let parsed = match zone {
"start" => &panels[0].start,
"center" => &panels[0].center,
_ => &panels[0].end,
};
assert_eq!(parsed.groups[0].items[0].kind, ferese_panel::ItemKind::Activities);
assert_eq!(parsed.groups[0].items[0].overflow, ferese_panel::OverflowPolicy::Always);
doc.set("panels", serde_json::to_value(&panels).unwrap()).unwrap();
let restored = ferese_config::Document::parse(&doc.to_string()).unwrap();
assert_eq!(restored.get("panels"), doc.get("panels"));
}
}
}
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-activities = { path = "../ferese-activities" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
libc.workspace = true libc.workspace = true
+102
View File
@@ -0,0 +1,102 @@
//! Activity schema uses the session's existing versioned framing and socket.
use ferese_activities::{Activity, Data, Id, Limits, Value};
use serde::{Deserialize, Serialize};
pub const VERSION: u32 = 1;
pub fn now_ms() -> u64 {
let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: time is a correctly sized writable timespec. CLOCK_BOOTTIME
// includes suspend, unlike the renderer's animation clock.
if unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &raw mut time) } != 0 {
return 0;
}
(time.tv_sec.max(0) as u64)
.saturating_mul(1_000)
.saturating_add(time.tv_nsec.max(0) as u64 / 1_000_000)
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OwnerScope {
#[default]
Parent,
Publisher,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Publish {
Start {
local: String,
data: Data,
lease_ms: Option<u64>,
},
Update {
local: String,
handle: Option<Id>,
value: Option<Value>,
lease_ms: Option<u64>,
},
End {
local: String,
handle: Option<Id>,
result: ferese_activities::State,
message: Option<String>,
},
Cancel {
local: String,
handle: Option<Id>,
},
/// Opt-in rule matching. Raw metadata is discarded before dispatch.
Hook {
local: String,
command: String,
cwd: String,
},
Run {
local: String,
title: Option<String>,
command: String,
cwd: String,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub version: u32,
#[serde(default)]
pub owner: OwnerScope,
pub publish: Publish,
}
/// Assigned by the compositor, never supplied by publishers.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ActivityWindow {
pub activity: Id,
pub window: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Snapshot {
pub version: u32,
pub revision: u64,
pub now_ms: u64,
pub limits: Limits,
pub activities: Vec<Activity>,
#[serde(default)]
pub windows: Vec<ActivityWindow>,
}
impl Default for Snapshot {
fn default() -> Self {
Self {
version: VERSION,
revision: 0,
now_ms: now_ms(),
limits: Limits::default(),
activities: vec![],
windows: vec![],
}
}
}
+129
View File
@@ -0,0 +1,129 @@
use crate::{Request, Response, VERSION, read_frame, write_frame};
use serde_json::Value;
use std::io;
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
pub fn socket_path() -> io::Result<PathBuf> {
crate::socket::resolve(None)
}
pub struct Connection {
stream: UnixStream,
id: u64,
}
impl Connection {
pub fn connect() -> io::Result<Self> {
Self::connect_to(socket_path()?)
}
pub fn connect_to(path: impl AsRef<std::path::Path>) -> io::Result<Self> {
let stream = UnixStream::connect(path)?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
Ok(Self { stream, id: 0 })
}
pub fn cancellation(&self) -> io::Result<Cancellation> {
self.stream.try_clone().map(Cancellation)
}
pub fn call(&mut self, command: &str, args: Value) -> Result<Value, String> {
self.id = self.id.wrapping_add(1);
let request = Request {
version: VERSION,
id: self.id,
kind: "command".into(),
command: command.into(),
args,
};
let response = write_frame(&mut self.stream, &request).and_then(|()| read_frame::<Response>(&mut self.stream));
let response = match response {
Ok(response) => response,
Err(error) => {
let _ = self.stream.shutdown(Shutdown::Both);
return Err(error.to_string());
}
};
if response.id != self.id || response.version != VERSION {
let _ = self.stream.shutdown(Shutdown::Both);
return Err("Unexpected IPC response".into());
}
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or_else(|| "Missing IPC result".into())
}
/// Wait for a server-side change; dropping the cancellation handle interrupts it.
pub fn wait(&mut self, command: &str, args: Value) -> Result<Value, String> {
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
let result = self.call(command, args);
self.stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|e| e.to_string())?;
result
}
}
pub struct Cancellation(UnixStream);
impl Drop for Cancellation {
fn drop(&mut self) {
let _ = self.0.shutdown(Shutdown::Both);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::mpsc;
#[test]
fn cancelling_a_blocked_watch_releases_the_client_and_server() {
let (stream, mut server) = UnixStream::pair().unwrap();
let mut connection = Connection { stream, id: 0 };
let cancellation = connection.cancellation().unwrap();
let (send, receive) = mpsc::channel();
let worker = std::thread::spawn(move || {
send.send(connection.wait("activity-watch", json!({"since": 7})))
.unwrap();
});
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let request: Request = read_frame(&mut server).unwrap();
assert_eq!(request.command, "activity-watch");
assert_eq!(request.args, json!({"since": 7}));
drop(cancellation);
assert!(receive.recv_timeout(Duration::from_secs(2)).unwrap().is_err());
assert!(read_frame::<Request>(&mut server).is_err());
worker.join().unwrap();
}
#[test]
fn response_identity_is_checked_before_returning_values() {
for wrong_version in [false, true] {
let (stream, mut server) = UnixStream::pair().unwrap();
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let worker = std::thread::spawn(move || {
let request: Request = read_frame(&mut server).unwrap();
let mut response = Response::success(request.id, json!({}));
if wrong_version {
response.version += 1;
} else {
response.id += 1;
}
write_frame(&mut server, &response).unwrap();
});
let error = Connection { stream, id: 0 }
.call("activity-get", json!({}))
.unwrap_err();
assert_eq!(error, "Unexpected IPC response");
worker.join().unwrap();
}
}
}
+3
View File
@@ -1,3 +1,6 @@
pub mod activities;
mod connection;
pub use connection::{Cancellation, Connection, socket_path};
use std::error::Error; use std::error::Error;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::{fmt, io}; use std::{fmt, io};
+1 -125
View File
@@ -1,14 +1,8 @@
use std::io;
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
use ferese_theme_model::{Mode, ResolvedTheme, families::Family}; use ferese_theme_model::{Mode, ResolvedTheme, families::Family};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::{Request, Response, VERSION, read_frame, write_frame}; use crate::Connection;
pub const SCHEMA_VERSION: u32 = 2; pub const SCHEMA_VERSION: u32 = 2;
@@ -45,58 +39,7 @@ impl Snapshot {
} }
} }
pub fn socket_path() -> io::Result<PathBuf> {
crate::socket::resolve(None)
}
pub struct Connection {
stream: UnixStream,
id: u64,
}
impl Connection { impl Connection {
pub fn connect() -> io::Result<Self> {
Self::connect_to(socket_path()?)
}
pub fn connect_to(path: impl AsRef<std::path::Path>) -> io::Result<Self> {
let stream = UnixStream::connect(path)?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
Ok(Self { stream, id: 0 })
}
pub fn cancellation(&self) -> io::Result<Cancellation> {
self.stream.try_clone().map(Cancellation)
}
pub fn call(&mut self, command: &str, args: Value) -> Result<Value, String> {
self.id = self.id.wrapping_add(1);
let request = Request {
version: VERSION,
id: self.id,
kind: "command".into(),
command: command.into(),
args,
};
let response = write_frame(&mut self.stream, &request).and_then(|()| read_frame::<Response>(&mut self.stream));
let response = match response {
Ok(response) => response,
Err(error) => {
let _ = self.stream.shutdown(Shutdown::Both);
return Err(error.to_string());
}
};
if response.id != self.id || response.version != VERSION {
let _ = self.stream.shutdown(Shutdown::Both);
return Err("Unexpected theme IPC response".into());
}
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or_else(|| "Missing theme snapshot".into())
}
pub fn get(&mut self, fallback_families: impl FnOnce() -> Vec<Family>) -> Result<Snapshot, String> { pub fn get(&mut self, fallback_families: impl FnOnce() -> Vec<Family>) -> Result<Snapshot, String> {
Snapshot::decode(self.call("theme-get", json!({}))?, fallback_families) Snapshot::decode(self.call("theme-get", json!({}))?, fallback_families)
} }
@@ -108,71 +51,4 @@ impl Connection {
) -> Result<Snapshot, String> { ) -> Result<Snapshot, String> {
Snapshot::decode(self.wait("theme-watch", json!({"since": revision}))?, fallback_families) Snapshot::decode(self.wait("theme-watch", json!({"since": revision}))?, fallback_families)
} }
/// Wait for a server-side change; dropping the cancellation handle interrupts it.
pub fn wait(&mut self, command: &str, args: Value) -> Result<Value, String> {
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
let result = self.call(command, args);
self.stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|e| e.to_string())?;
result
}
}
pub struct Cancellation(UnixStream);
impl Drop for Cancellation {
fn drop(&mut self) {
let _ = self.0.shutdown(Shutdown::Both);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn cancelling_a_blocked_watch_releases_the_client_and_server() {
let (stream, mut server) = UnixStream::pair().unwrap();
let mut connection = Connection { stream, id: 0 };
let cancellation = connection.cancellation().unwrap();
let (send, receive) = mpsc::channel();
let worker = std::thread::spawn(move || {
send.send(connection.watch(7, Vec::new)).unwrap();
});
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let request: Request = read_frame(&mut server).unwrap();
assert_eq!(request.command, "theme-watch");
assert_eq!(request.args, json!({"since": 7}));
drop(cancellation);
assert!(receive.recv_timeout(Duration::from_secs(2)).unwrap().is_err());
assert!(read_frame::<Request>(&mut server).is_err());
worker.join().unwrap();
}
#[test]
fn response_identity_is_checked_before_decoding_theme_values() {
for wrong_version in [false, true] {
let (stream, mut server) = UnixStream::pair().unwrap();
server.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let worker = std::thread::spawn(move || {
let request: Request = read_frame(&mut server).unwrap();
let mut response = Response::success(request.id, json!({}));
if wrong_version {
response.version += 1;
} else {
response.id += 1;
}
write_frame(&mut server, &response).unwrap();
});
let error = Connection { stream, id: 0 }
.get(|| panic!("must not decode an unrelated response"))
.unwrap_err();
assert_eq!(error, "Unexpected theme IPC response");
worker.join().unwrap();
}
}
} }
+2 -1
View File
@@ -86,7 +86,7 @@ impl Profile {
Content::Workspaces => self.workspaces_height, Content::Workspaces => self.workspaces_height,
Content::Media => self.media_height, Content::Media => self.media_height,
Content::Calendar => self.calendar_height, Content::Calendar => self.calendar_height,
Content::Notifications => self.notifications_height, Content::Notifications | Content::Activities => self.notifications_height,
Content::Controls => self.controls_height, Content::Controls => self.controls_height,
}, },
], ],
@@ -96,6 +96,7 @@ impl Profile {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Content { pub enum Content {
Activities,
Workspaces, Workspaces,
Media, Media,
Calendar, Calendar,
+8 -1
View File
@@ -120,6 +120,7 @@ pub enum ItemKind {
}, },
FocusedWindow, FocusedWindow,
Media, Media,
Activities,
QuickSettings, QuickSettings,
Network, Network,
Audio, Audio,
@@ -141,6 +142,7 @@ impl ItemKind {
Self::Workspaces { .. } => "Workspaces", Self::Workspaces { .. } => "Workspaces",
Self::FocusedWindow => "Focused window", Self::FocusedWindow => "Focused window",
Self::Media => "Media", Self::Media => "Media",
Self::Activities => "Activities",
Self::QuickSettings => "Quick Settings", Self::QuickSettings => "Quick Settings",
Self::Network => "Network", Self::Network => "Network",
Self::Audio => "Audio", Self::Audio => "Audio",
@@ -155,7 +157,7 @@ impl ItemKind {
pub fn representations(self) -> &'static [Representation] { pub fn representations(self) -> &'static [Representation] {
use Representation::*; use Representation::*;
match self { match self {
Self::Media | Self::Clock => &[Wide, Compact, Icon], Self::Activities | Self::Media | Self::Clock => &[Wide, Compact, Icon],
Self::Battery { percentage: true } => &[Wide, Icon], Self::Battery { percentage: true } => &[Wide, Icon],
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide], Self::Workspaces { .. } | Self::FocusedWindow => &[Wide],
_ => &[Icon], _ => &[Icon],
@@ -283,6 +285,8 @@ impl Item {
}, },
priority: if matches!(kind, ItemKind::Overview | ItemKind::Workspaces { .. }) { priority: if matches!(kind, ItemKind::Overview | ItemKind::Workspaces { .. }) {
100 100
} else if kind == ItemKind::Activities {
20
} else { } else {
50 50
}, },
@@ -294,6 +298,7 @@ impl Item {
return false; return false;
} }
match self.kind { match self.kind {
ItemKind::Activities => availability.activities,
ItemKind::Media => availability.media, ItemKind::Media => availability.media,
ItemKind::Network => availability.network, ItemKind::Network => availability.network,
ItemKind::Audio => availability.audio, ItemKind::Audio => availability.audio,
@@ -308,6 +313,7 @@ impl Item {
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct Availability { pub struct Availability {
pub media: bool, pub media: bool,
pub activities: bool,
pub network: bool, pub network: bool,
pub audio: bool, pub audio: bool,
pub notifications: bool, pub notifications: bool,
@@ -792,6 +798,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
visible(Availability { visible(Availability {
activities: true,
media: true, media: true,
network: true, network: true,
audio: true, audio: true,
+3 -3
View File
@@ -2,7 +2,7 @@ use cosmic::iced::futures::SinkExt;
use ferese_ipc::theme::Snapshot; use ferese_ipc::theme::Snapshot;
pub fn current() -> Snapshot { pub fn current() -> Snapshot {
ferese_ipc::theme::Connection::connect() ferese_ipc::Connection::connect()
.ok() .ok()
.and_then(|mut connection| connection.get(ferese_config::families::builtins).ok()) .and_then(|mut connection| connection.get(ferese_config::families::builtins).ok())
.unwrap_or_else(fallback) .unwrap_or_else(fallback)
@@ -31,7 +31,7 @@ pub fn subscription() -> cosmic::iced::Subscription<Snapshot> {
fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> { fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> {
cosmic::iced::stream::channel(1, async |mut output| { cosmic::iced::stream::channel(1, async |mut output| {
loop { loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await; let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else { let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue; continue;
@@ -103,7 +103,7 @@ mod tests {
} }
// No compositor: return the same standalone values without connecting elsewhere. // No compositor: return the same standalone values without connecting elsewhere.
assert_eq!(current(), fallback()); assert_eq!(current(), fallback());
let path = ferese_ipc::theme::socket_path().unwrap(); let path = ferese_ipc::socket_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let listener = UnixListener::bind(path).unwrap(); let listener = UnixListener::bind(path).unwrap();
let (watch_send, watch_receive) = std::sync::mpsc::channel(); let (watch_send, watch_receive) = std::sync::mpsc::channel();
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h2C7 12 7 12 8 8C9 4 10 4 11 12C12 20 13 20 14.5 14C15.5 10 16 10 17 11C18 12 18 12 21 12"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 5h8C20 5 20 5 20 9v7C20 20 20 20 16 20H8C4 20 4 20 4 16V9C4 5 4 5 8 5ZM8 3v4m8-4v4M4 10h16"/>
<path d="M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01" stroke-width="2.3"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="m6.5 6.5 11 11m0-11-11 11"/>
</svg>

After

Width:  |  Height:  |  Size: 206 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h4m6 0h6M4 17h8m6 0h2M10.5 4h1C14 4 14 4 14 6.5v1C14 10 14 10 11.5 10h-1C8 10 8 10 8 7.5v-1C8 4 8 4 10.5 4ZM14.5 14h1C18 14 18 14 18 16.5v1C18 20 18 20 15.5 20h-1C12 20 12 20 12 17.5v-1C12 14 12 14 14.5 14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.5 4.5h9C21 4.5 21 4.5 21 9v5C21 18.5 21 18.5 16.5 18.5h-9C3 18.5 3 18.5 3 14V9C3 4.5 3 4.5 7.5 4.5ZM9 21h6"/><path d="M12 18.5V21"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 17V7C9 5.5 9 5.5 10.5 5.15L19 3.5V15M9 9l10-2"/>
<ellipse cx="6.5" cy="18" rx="2.5" ry="2"/><ellipse cx="16.5" cy="16" rx="2.5" ry="2"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 18h8C19 18 19 18 18 15.5C17 13.5 17 13 17 10.5C17 6.5 15.5 4 12 4S7 6.5 7 10.5C7 13 7 13.5 6 15.5C5 18 5 18 8 18ZM12 2.5V4m-2 16.5c.5 1.5 3.5 1.5 4 0"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.5 4.5h9C21 4.5 21 4.5 21 9v5C21 18.5 21 18.5 16.5 18.5h-9C3 18.5 3 18.5 3 14V9C3 4.5 3 4.5 7.5 4.5ZM9 21h6"/><circle cx="12" cy="11.5" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.350 12.000C21.350 13.632 18.530 14.390 17.932 15.425C17.334 16.460 18.088 19.281 16.675 20.097C15.262 20.913 13.196 18.850 12.000 18.850C10.804 18.850 8.738 20.913 7.325 20.097C5.912 19.281 6.666 16.460 6.068 15.425C5.470 14.390 2.650 13.632 2.650 12.000C2.650 10.368 5.470 9.610 6.068 8.575C6.666 7.540 5.912 4.719 7.325 3.903C8.738 3.087 10.804 5.150 12.000 5.150C13.196 5.150 15.262 3.087 16.675 3.903C18.088 4.719 17.334 7.540 17.932 8.575C18.530 9.610 21.350 10.368 21.350 12.000Z"/><circle cx="12" cy="12" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.5 4h1C10 4 10 4 10 6.5v1C10 10 10 10 7.5 10h-1C4 10 4 10 4 7.5v-1C4 4 4 4 6.5 4Z"/>
<path d="M16.5 4h1C20 4 20 4 20 6.5v1C20 10 20 10 17.5 10h-1C14 10 14 10 14 7.5v-1C14 4 14 4 16.5 4Z"/>
<path d="M6.5 14h1C10 14 10 14 10 16.5v1C10 20 10 20 7.5 20h-1C4 20 4 20 4 17.5v-1C4 14 4 14 6.5 14Z"/>
<path d="M16.5 14h1C20 14 20 14 20 16.5v1C20 20 20 20 17.5 20h-1C14 20 14 20 14 17.5v-1C14 14 14 14 16.5 14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 584 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="m14 4 6 6-3 3-3-3-8 10-3-3 10-8-3-3 4-2Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 219 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="13" r="8"/><path d="M9 2h6m-3 0v3m0 8V8m5-2 2-2"/></g></svg>

After

Width:  |  Height:  |  Size: 243 B

+18
View File
@@ -3,6 +3,24 @@ use cosmic::iced::{Color, ContentFit, Length};
use cosmic::theme; use cosmic::theme;
use cosmic::widget::icon; use cosmic::widget::icon;
/// Morph's compact outline set. Curved corners use tangent-continuous paths;
/// all glyphs share a 24-unit canvas and a 1.7-unit stroke.
pub mod morph {
pub const WORKSPACES: &[u8] = include_bytes!("../assets/icons/morph/workspaces.svg");
pub const MUSIC: &[u8] = include_bytes!("../assets/icons/morph/music.svg");
pub const CALENDAR: &[u8] = include_bytes!("../assets/icons/morph/calendar.svg");
pub const NOTIFICATIONS: &[u8] = include_bytes!("../assets/icons/morph/notifications.svg");
pub const CONTROLS: &[u8] = include_bytes!("../assets/icons/morph/controls.svg");
pub const SETTINGS: &[u8] = include_bytes!("../assets/icons/morph/settings.svg");
pub const ACTIVITIES: &[u8] = include_bytes!("../assets/icons/morph/activities.svg");
pub const CLOSE: &[u8] = include_bytes!("../assets/icons/morph/close.svg");
pub const RECORD: &[u8] = include_bytes!("../assets/icons/morph/record.svg");
pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/morph/display.svg");
}
pub const ACTIVITY_HAMMER: &[u8] = include_bytes!("../assets/icons/status/activity-hammer.svg");
pub const ACTIVITY_TIMER: &[u8] = include_bytes!("../assets/icons/status/activity-timer.svg");
pub const ACTIVITY_DOWNLOAD: &[u8] = include_bytes!("../assets/icons/status/activity-download.svg");
pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg"); pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg"); pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg"); pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg");
+1
View File
@@ -10,6 +10,7 @@
## Use the desktop ## Use the desktop
- [Live Activities](activities.md): show running commands in the panel, configure detection and troubleshoot missing tasks.
- [Shortcuts and gestures](shortcuts.md): keyboard navigation, moving and resizing with Super-drag, and window grouping. - [Shortcuts and gestures](shortcuts.md): keyboard navigation, moving and resizing with Super-drag, and window grouping.
- [Lock screen](locking.md): change its appearance, set up automatic locking and configure display sleep. - [Lock screen](locking.md): change its appearance, set up automatic locking and configure display sleep.
- [Screen sharing and recording](screen-sharing.md): choose a source and stop sharing. - [Screen sharing and recording](screen-sharing.md): choose a source and stop sharing.
+406
View File
@@ -0,0 +1,406 @@
# Live Activities
Live Activities shows running commands and other tasks in your panel. A build
can stay visible while you work in another window; click its activity to return
to the terminal when Ferese can identify that window. The panel supplies the
appearance, so tasks use your theme and animation settings.
## Start using it
After installing with `./scripts/install.sh`, open a new terminal and run a
command as usual:
```sh
cargo build
```
The installer detects Fish, Bash or Zsh, installs its shell hook, enables shell
activities and adds an Activities item to your panel. A Cargo build appears
once it has run for **1.5 seconds**. A cached build that finishes sooner stays
hidden, including its completion message.
These command rules are included:
| Command | Delay before appearing |
| --- | --- |
| `cargo build`, `check`, `test`, `run`, `clippy` | 1.5 seconds |
| `npm` or `pnpm` followed by `install`, `build`, `test`, `run` | 1.5 seconds |
| `make` | 300 ms |
| `git push` | 300 ms |
| `docker build` | 1.5 seconds |
Rules match the command text at the start of the line. Commands such as
`cargo +nightly build`, `npm ci`, or `env RUSTFLAGS=... cargo build` need a custom
rule or an explicit wrapper. Commands that don't match a rule are discarded
without being logged.
To check the display without waiting for a build, run:
```sh
feresectl run --title "Activity check" -- sleep 5
```
This publishes directly and doesn't need a shell hook, but the panel still needs
an Activities item. The check appears after 300 ms and finishes after five
seconds.
### Existing installations
From an updated Ferese checkout, rerun user setup as your normal user:
```sh
python3 scripts/installer/install.py user-setup
```
This needs the updated `feresectl` installed. Setup backs up changed config and
shell startup files. It preserves an explicit `shell-hook #false` setting and
an existing Activities item, including one you've hidden. After the first
successful setup, later installs update the hook files without restoring panel
items you've removed or changing your activity settings.
Open a new terminal to load the hook. In an existing Fish terminal, you can
load it immediately with:
```fish
source ~/.config/fish/conf.d/ferese-activities.fish
```
The paths above use the default configuration directory. If you set
`XDG_CONFIG_HOME`, use that directory in place of `~/.config`.
### Add Activities to your panel
Open **Settings → Panel & Shell → Edit panel**, select a group in Arrange, and
choose **Add item → Activities**. Keep its visibility enabled. This is also how
to restore the item after removing it.
Activities can live in Start, Center or End. Regular panels use that position
and open the activity list in a popup. Morph presents tasks inside its existing
island and adds an Activities tab. Changing a group's position doesn't create
another Morph island. See [Panel configuration](panel-configuration.md) for item
placement and overflow controls.
For manual panel configuration, add an `activities` item to an existing group.
This complete panel example places it at the end of a floating panel; don't add
a second `panel` block if your config already has one:
```kdl
panel "main" {
style "floating"
end {
group "tasks" {
item "activities" kind="activities" priority=20
}
}
}
```
### Manual shell setup
Without installer setup, hooks and process watching are off by default. Add an
Activities item to the panel and enable `activities.sources.shell-hook` before
loading a hook. From a checkout, source `contrib/activities/ferese.fish` in Fish,
`contrib/activities/ferese.zsh` in Zsh, or the following in Bash:
```sh
source contrib/activities/bash-preexec/bash-preexec.sh
source contrib/activities/ferese.bash
```
Use absolute paths when adding these lines to shell startup files. Zsh uses
`add-zsh-hook`, Fish uses named pre/postexec events, and Bash composes with
[bash-preexec](https://github.com/rcaloras/bash-preexec)'s hook arrays. Load
bash-preexec before the Bash hook; the hook does not replace an existing DEBUG
trap. Hook files never execute captured command text and preserve command status.
The installer copies hooks into `$XDG_CONFIG_HOME/ferese/activities`, defaulting
to `~/.config/ferese/activities`. Fish loads through its `conf.d` directory; Bash
and Zsh get a marked block in `.bashrc` or `.zshrc`. Zsh setup honors an exported
`ZDOTDIR`. Bash shells started in login mode must also load `.bashrc` from their
login profile for the installed hook to run.
Process watching is an opt-in alternative when hooks are unavailable. It performs
bounded sampling on one worker, and disappearance means expiry, never inferred
success or failure. Shell hooks observe interactive commands; they do not
automatically wrap them or parse their output. The
[Cargo adapter](#cargo-progress) requires `feresectl run`.
## Run a command explicitly
Use `feresectl run` for commands outside the built-in rules, unsupported shells,
or scripts where an interactive hook doesn't run:
```sh
feresectl run --title "Backup" -- ./backup.sh
feresectl run --title "Tests" -- npm test
```
Arguments after `--` are passed to the command. It keeps its terminal input,
output, working directory, environment, terminal process group and exit/signal
status, and still runs if activity publishing fails. Completion follows the command's exit. A background descendant holding
stdout open does not keep the wrapper alive indefinitely.
### Cargo progress
A normal `cargo build` shows that the command is running and whether it finishes
successfully. It doesn't report a percentage. To show the number of compilation
units completed, use the Cargo adapter:
```sh
feresectl run --cargo-json -- cargo build
```
The adapter reads Cargo's JSON output while keeping diagnostics readable in the
terminal. It reports completed units as text because Cargo doesn't supply a
reliable total for a percentage. Setting `progress "cargo-json"` in a rule enables
this adapter for matching `feresectl run` commands; it does not wrap plain
`cargo build` automatically. A `build-finished` message never completes the
activity; completion follows the actual child exit. The adapter also preserves
non-JSON output. See the
[Cargo JSON contract](https://doc.rust-lang.org/cargo/reference/external-tools.html).
## Use the activity list
Morph shows up to two activities in its collapsed and peek states, with a `+n`
badge for additional tasks. Higher-priority tasks appear first, with temporary
boosts for new tasks and completion results. Hovering holds the current selection
in place while its values continue updating. Recording and low battery take
priority over ordinary activities.
Click the activity strip or open the Activities tab to see the list. Arrow keys
select a row and Enter activates it. Selecting a linked task focuses its terminal
and closes the list. If the terminal has closed, or one terminal process owns
several possible windows, Ferese leaves the list open. It cannot select a
particular tmux pane.
Tasks with reported progress show a percentage inside a ring. Tasks without a
percentage show an animated arc around their icon. Reduced motion makes progress
changes immediate and uses a static track. Activities do not automatically open
the expanded panel.
A visible task's result stays for 1.5 seconds by default. Do not disturb limits
ordinary running activities to one, hides the badge and suppresses result
flashes. When there are no eligible tasks, the regular Activities item has no
content and Morph returns to its other content.
## Configure Activities
In **Settings → Panel & Shell → Appearance**, **Visible activities** chooses one
or two slots in Morph's collapsed and peek states. **Show activity count** controls
the `+n` badge; hiding it still lets you open the full list.
For source and timing settings, edit the top-level `activities` block in
`~/.config/ferese/config.kdl`. Merge these fields into an existing block rather
than adding a second one:
```kdl
activities {
max-visible 2
show-badge #true
start-delay-ms 300
result-flash-ms 1500
sources {
shell-hook #true
process-watch #false
notification-bridge #false
}
}
```
| Setting | Effect |
| --- | --- |
| `max-visible` | One or two compact activity slots. |
| `show-badge` | Show `+n` for tasks beyond those slots. |
| `start-delay-ms` | Minimum delay for a new task; a rule can require a longer delay. |
| `result-flash-ms` | How long an eligible completion result remains visible. |
| `shell-hook` | Accept command start/end events from an installed shell hook. |
| `process-watch` | Opt-in process sampling when hooks aren't available; process disappearance removes the task without inferring success or failure. |
| `notification-bridge` | Present notifications as activities instead of duplicate toasts, respecting Do not disturb and notification popup settings. |
The process watcher and notification bridge stay off unless you enable them.
Media comes from MPRIS; downloads and call statuses need an integration that
publishes them. Microphone or camera use alone does not create a call activity.
Changes reload live. Invalid edits leave the last valid configuration in use;
check an edit with `ferese --check-config ~/.config/ferese/config.kdl`.
For command patterns, custom titles, per-rule delays and completion messages,
see [Command rules](#command-rules).
### Turn off automatic command detection
Set `shell-hook #false` inside `activities.sources`. Leave `process-watch` off
as well if you don't want process detection. You can still use `feresectl run`
and direct publishers.
To skip shell and Activities setup during installation, pass
`./scripts/install.sh --no-activities`. This skips setup for that installation;
it doesn't remove an existing hook or disable an already configured source.
### Command rules
```kdl
activities {
max-visible 2
show-badge #true
start-delay-ms 300
result-flash-ms 1500
sources {
shell-hook #true
process-watch #false
notification-bridge #false
}
rule "cargo" {
match #"^cargo (build|check|test|run|clippy)\b"#
title "cargo {1}"
icon "hammer"
priority 100
progress "indeterminate"
min-duration-ms 1500
ignore-parents "rust-analyzer"
dedupe-by "cwd"
flash-on-fail #true
result ok="Finished" fail="Command failed"
}
}
```
User rules run in declaration order, then unreplaced built-ins in Cargo,
npm/pnpm, make, git-push, docker-build order. A user rule replaces the entire
built-in with the same name. One command selects one rule, pinned for its lifetime.
`ignore-parents` accepts multiple names. Regex input/pattern/program sizes are
bounded. Command and cwd metadata are used only during matching; nonmatches are
discarded and never logged. Grouping hashes cwd within owner/rule. Invocations
retain independent lifetimes and progress is never averaged.
The global/rule visibility delay is their maximum. Completion before that delay,
including failure, never flashes. Eligible results last 1500 ms by default;
`flash-on-fail #false` removes failures directly. Configuration is validated
before application; an invalid reload preserves the last valid configuration.
Diagnostics include source offsets for activity configuration errors.
## If nothing appears
1. **Try the five-second check above.** If it appears, the display and broker
work; check shell setup and command matching next.
2. **Check the panel item.** In Arrange, confirm that Activities exists and is
visible. On a regular panel, also check its overflow and available space.
3. **Open a new terminal.** Installing a hook doesn't load it into terminals
that are already open. Confirm `shell-hook #true` in your config too.
4. **Check the command and duration.** Cargo and npm tasks must last at least
1.5 seconds under the built-in rules. A custom rule can increase that delay.
Prefixes and different subcommands may not match the built-in patterns.
5. **Inspect the broker while the task runs:**
```sh
feresectl -j activity list
```
An empty `activities` array means no task is registered in that session.
A registered task can still be waiting for its delay, ranked below another
task, or hidden by panel settings. In Morph, also check for recording or a
low-battery state taking priority.
6. **Check the session and build.** A nested preview has its own broker; use the
same [session socket](ipc.md#choose-a-session) for publishing and listing.
If `activity` or `run` is an unknown command, update the installed binaries.
Log out and back in after upgrading the compositor and shell.
Shell hooks suppress publishing errors so they don't interrupt your commands.
For an ownership or permission error, use a direct publication from the same
terminal to see the response:
```sh
feresectl activity start diagnostic --title "Activity check" --lease-ms 10000
feresectl activity cancel diagnostic
```
The lease removes this diagnostic after ten seconds if you omit the cancel.
Ownership verification needs Linux 6.5 or newer. See
[Publisher ownership and lifecycle](#publisher-ownership-and-lifecycle)
for ownership, limits and expiry behavior.
## Publish your own status
Run these commands from the same shell. The ID `download` belongs to that shell,
so another terminal cannot update it:
```sh
feresectl activity start download --source download --icon download \
--title "Downloading update" --progress 0.2
sleep 2
feresectl activity update download --progress 0.7
feresectl activity end download --result ok --message "Downloaded"
```
This example publishes status only; it doesn't perform a download. Your program
supplies real progress and completion results.
## Publisher ownership and lifecycle
Use the same socket selection as every other control command: `--socket`, then
`FERESE_SOCKET`, then the session runtime default. Separate nested sessions keep
separate brokers. One-shot commands use their verified immediate parent (normally
the shell), not the CLI connection, as lifetime owner. IDs are local to that owner.
A direct publisher and `run` use the publishing process itself. The host obtains
kernel peer credentials and a socket-bound pidfd, checks the parent relationship,
process birth and UID, and monitors owner exit. This requires Linux 6.5 or newer;
an unverifiable owner is rejected instead of trusting a JSON PID.
```sh
feresectl activity start build --icon hammer --title "Build"
feresectl activity update build --progress 0.6
feresectl activity end build --result ok --message "Built"
feresectl activity start tea --icon timer --title "Tea" --remaining-ms 180000
feresectl run --title "cargo test" -- cargo test
```
`start` prints a JSON handle containing owner, session and generation. The
`--handle` option on update/end/cancel checks that exact invocation. Omitting it
resolves the local ID only within the same verified owner. Duplicate starts are
rejected. Repeated identical accepted results keep the original result deadline;
a removed or superseded generation is stale. Register again after broker restart.
The compositor assigns a terminal window at activity creation using the
publisher's process ancestry. List selection follows the activity ID across
reordering. Recording and low battery keep their protected priority, including
the combined recording/low-battery presentation.
Each owner may hold three records, including delayed and terminal entries. The
session defaults to 128 records, a priority ceiling of 160 and two compact slots.
New publications are limited to ten per owner per second. Replaceable updates
are limited to 30 per owner per second; rejected updates get
an explicit error. The bar coalesces snapshots through a bounded latest-value
subscription while the broker retains accepted results. Terminal/cancel dispatch waits for bounded dispatcher capacity
on the socket worker. A CLI disconnect does not remove an owner's records.
Running owner death and expired leases remove entries without assigning a result.
Results survive owner exit until their original deadline. Leases are positive and
at most five minutes; valid refreshes renew only the lease.
Activities stay on their creation output, initially the focused output. On output
removal they move to the first surviving output, or become unpresented until an
output is available. Host timestamps use `CLOCK_BOOTTIME`, including suspend.
Countdown wire timestamps are ignored and replaced with host sample time.
## Extensions and templates
The version-1 activity schema is carried inside the existing versioned,
length-prefixed JSON `activity-publish` command. `activity-get` and long-poll
`activity-watch` provide snapshots. See `ferese_ipc::activities` and
`ferese_activities::Data`; there is no second socket or JSON-lines protocol.
Extensions publish typed Progress, Timer, Media, or List data. The bar controls
rendering. Values are indeterminate, finite progress in `[0,1]`, bounded text, or
countdown durations. Titles/text are limited to 256 UTF-8 bytes; lists to eight
rows. Icons are symbolic names; unknown names use the Ferese symbol, and paths
are rejected. No extension widgets, markup, colors, shaders or drawing programs
are accepted.
Use `--template` with a typed JSON template, e.g.
`'{"kind":"list","data":{"rows":["Indexing files","Waiting for server"]}}'`.
Capture/call integrations use `--source capture` and describe the state they
actually observe. Process names, microphone use, or camera use do not infer calls
or downloads. Existing MPRIS state supplies Media rows and transport controls;
recording/battery reuse the shell's existing protected state and watchers.
The optional notification bridge uses existing notification state, ranks below
all other ordinary sources, and respects DND and popup privacy. When enabled, a
notice becomes an activity rather than a duplicate toast. It is off by default.
+2
View File
@@ -7,6 +7,8 @@ and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness
keys need `brightnessctl` and audio keys need `wpctl`. keys need `brightnessctl` and audio keys need `wpctl`.
This reference covers layouts, appearance, input, and desktop behavior. This reference covers layouts, appearance, input, and desktop behavior.
See [Desktop portals](portals.md) for application integration options. See [Desktop portals](portals.md) for application integration options.
The [Live Activities guide](activities.md#configure-activities) covers task
visibility, shell detection, command rules and notification bridging.
## KDL syntax ## KDL syntax
+25
View File
@@ -27,12 +27,37 @@ host. Hardware checks remain separate.
## Test guides ## Test guides
- [Live Activities checks](#live-activities-checks): core, CLI, shell hooks, installer setup and native rendering.
- [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership. - [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership.
- [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits. - [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits.
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness. - [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
## Live Activities checks
See [Live Activities](activities.md) for setup, configuration and the publisher
protocol.
Hidden items and measurement widgets do not animate. Reduced motion snaps
progress and uses a static track. Workspace buttons retain their own actions;
activity selection does not replace workspace navigation.
```sh
cargo test --locked -p ferese-activities -p ferese-config -p ferese-ipc -p feresectl
cargo test --locked -p ferese-shell
python3 scripts/tests/test_activity_hooks.py -v
python3 -m unittest discover -s scripts/tests -p 'test_portal_install.py' -v
FERESE_TEST_ACTIVITIES=1 python3 scripts/tests/test_activities_isolated.py -v
FERESE_REVIEW_BACKEND=tiny-skia cargo test --locked -p ferese-shell \
render_hub_review_states -- --ignored --nocapture
```
The isolated check starts a private Weston host and broker. Render captures live
in `/tmp/ferese-hub-review`; they include single/split/list content, long titles,
translucency and 1×/1.25×/1.5× scales. Direct DRM, physical multi-monitor hotplug,
suspend and live capture hardware still need hardware validation.
## Portal checks ## Portal checks
Run backend unit tests and compare D-Bus contracts against installed portal XML: Run backend unit tests and compare D-Bus contracts against installed portal XML:
+15 -5
View File
@@ -145,16 +145,23 @@ entry, icons, default wallpaper and example config. Releases live under
`/usr/local/lib/ferese/releases/` and commands under `/usr/local/bin/`, with `current` `/usr/local/lib/ferese/releases/` and commands under `/usr/local/bin/`, with `current`
and `previous` links for switching between releases during updates and rollback. and `previous` links for switching between releases during updates and rollback.
The installer preserves your configuration, other desktop sessions and existing PAM On first user setup, the installer detects Fish, Bash or Zsh, installs its
policy. Ferese starts its authentication agent and portal services with the session. [Live Activities hook](activities.md#start-using-it), and adds Activities to your
panel. It backs up changed config and shell startup files, preserves an explicit
`shell-hook #false`, and leaves later activity and panel edits alone. Open a new
terminal to load the hook, or pass `--no-activities` to skip this setup.
Other desktop sessions and existing PAM policy are preserved. Ferese starts its
authentication agent and portal services with the session.
The authentication agent registers for the login session, so requests from desktop The authentication agent registers for the login session, so requests from desktop
apps use the Ferese dialog. Remove other polkit agents from your Ferese autostart apps use the Ferese dialog. Remove other polkit agents from your Ferese autostart
configuration to avoid competing session registrations. configuration to avoid competing session registrations.
### Initial configuration ### Initial configuration
You can start with Ferese’s built-in defaults, or copy the example config if you’d like Activities setup creates a config file if one doesn't exist. You can edit that
a file to customize: file in Settings. If you skipped setup, Ferese uses its built-in defaults; you
can copy the example config to start a file yourself:
```sh ```sh
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/ferese" mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/ferese"
@@ -318,7 +325,9 @@ python3 scripts/installer/install.py user-setup
This backs up the exact old development portal override when present and reloads This backs up the exact old development portal override when present and reloads
user systemd configuration. It preserves custom overrides and does not restart the user systemd configuration. It preserves custom overrides and does not restart the
running desktop or portal. running desktop or portal. It also installs the detected shell's Activities hook
and performs first-time Activities configuration if needed. See
[existing installations](activities.md#existing-installations) for details.
Installer options: Installer options:
@@ -326,6 +335,7 @@ Installer options:
./scripts/install.sh --dry-run ./scripts/install.sh --dry-run
./scripts/install.sh --offline --release-id my-build ./scripts/install.sh --offline --release-id my-build
./scripts/install.sh --resize-metrics ./scripts/install.sh --resize-metrics
./scripts/install.sh --no-activities
./scripts/install.sh --bundle-only ./target/bundles/my-build --release-id my-build ./scripts/install.sh --bundle-only ./target/bundles/my-build --release-id my-build
./scripts/install.sh --bundle ./target/bundles/my-build ./scripts/install.sh --bundle ./target/bundles/my-build
``` ```
+13
View File
@@ -20,6 +20,7 @@ feresectl output-profiles
feresectl theme get feresectl theme get
feresectl xwayland status feresectl xwayland status
feresectl media feresectl media
feresectl activity list
``` ```
The `get-*` spellings remain hidden aliases for the corresponding queries and The `get-*` spellings remain hidden aliases for the corresponding queries and
@@ -51,6 +52,18 @@ width/height with left/up and grows them with right/down by 32 logical pixels,
subject to client size limits. Fullscreen and maximized floating windows ignore subject to client size limits. Fullscreen and maximized floating windows ignore
these actions. these actions.
## Live Activities
Use `feresectl run --title "Tests" -- cargo test` to publish a command's lifetime,
or `feresectl activity start`, `update`, `end` and `cancel` to publish status from
an integration. The [Live Activities guide](activities.md) covers setup and
examples; its [publisher section](activities.md#publisher-ownership-and-lifecycle) explains ownership,
leases and the `activity-publish`, `activity-get` and `activity-watch` commands.
`feresectl activity setup --config PATH` updates a local config for shell
activities and the panel item without connecting to a session. It preserves an
explicit `shell-hook #false`; the installer uses it during first-time user setup.
## Choose a session ## Choose a session
Every feresectl IPC command, including theme commands and subscriptions, resolves Every feresectl IPC command, including theme commands and subscriptions, resolves
+17 -7
View File
@@ -41,6 +41,10 @@ Other controls save automatically, with sliders saving when you release them.
**Undo** restores the previous save, **Reload configuration** reads external **Undo** restores the previous save, **Reload configuration** reads external
edits, and **Back to Panels** returns to the appearance controls. edits, and **Back to Panels** returns to the appearance controls.
Add **Activities** to show running commands and published task progress. Its
visibility and overflow follow the same item controls. The
[Live Activities guide](activities.md) covers shell setup and command detection.
**Remove custom panel** restores the built-in composition, style and geometry; **Remove custom panel** restores the built-in composition, style and geometry;
Undo brings back the composition you removed. Undo brings back the composition you removed.
@@ -94,7 +98,7 @@ style supply it.
| `height` | 36; Morph uses 34 | Number 24–128 | Panel height, or Morph's collapsed height | | `height` | 36; Morph uses 34 | Number 24–128 | Panel height, or Morph's collapsed height |
| `edge-margin` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Distance from the selected screen edge | | `edge-margin` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Distance from the selected screen edge |
| `side-margins` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Requested inset at both ends of the panel | | `side-margins` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Requested inset at both ends of the panel |
| `window-clearance` | 0 | Integer 0–4096 | Additional reserved space between the panel and windows | | `window-clearance` | 0 | Integer 0–4096 | Space between the panel and tiled or maximized windows |
| `inner-padding` | 12 | Number 0–64 | Space inside the row before its first and after its last group | | `inner-padding` | 12 | Number 0–64 | Space inside the row before its first and after its last group |
Notch and Edge to edge use zero edge and side margins even if you have saved Notch and Edge to edge use zero edge and side margins even if you have saved
@@ -108,10 +112,10 @@ more content appears, so the margins do not cause additional overflow. Settings
limits the slider to the available display and preview measurements; KDL accepts limits the slider to the available display and preview measurements; KDL accepts
the wider range shown above. the wider range shown above.
Morph reserves space for its collapsed height plus window clearance. Its All panel styles reserve their height plus window clearance. At the panel edge,
expanded views overlay windows, so increasing their height does not push windows clearance replaces the layout's outer gap; the other edges keep their usual gaps.
down. At the panel edge, Morph accounts for the layout's outer gap rather than Morph reserves its collapsed height, and expanded views overlay windows without
adding the two gaps together. pushing them down.
## Background, outline and corners ## Background, outline and corners
@@ -247,6 +251,11 @@ content. Hovering can expand a preview, and clicking opens the hub's Workspaces,
Music, Calendar, Music, Calendar,
Notifications or Controls view. Escape or the close button collapses it. Notifications or Controls view. Escape or the close button collapses it.
With an Activities item configured, running tasks can occupy the collapsed and
peek states, and the hub gains an Activities tab. **Visible activities** and
**Show activity count** in Appearance control its compact slots and `+n` badge;
see [Activities configuration](activities.md#configure-activities).
The Workspaces view shows miniature window layouts. Click a tile to switch The Workspaces view shows miniature window layouts. Click a tile to switch
workspace, use Tab and the arrow keys to navigate it, or drag a miniature window workspace, use Tab and the arrow keys to navigate it, or drag a miniature window
to another tile on the same output. Scrolling over the collapsed hub changes to another tile on the same output. Scrolling over the collapsed hub changes
@@ -255,8 +264,9 @@ workspace, with a brief workspace preview after a switch.
Morph keeps your saved composition for when you switch back to a regular panel. Morph keeps your saved composition for when you switch back to a regular panel.
The hub uses its own layouts and compact workspace dots, so Start/Center/End The hub uses its own layouts and compact workspace dots, so Start/Center/End
positions, group padding and the regular workspace indicator style do not apply positions, group padding and the regular workspace indicator style do not apply
inside it. Item settings still determine which configured controls appear in inside it. `inner-padding` and `side-margins` also belong to the regular panel;
its Controls view and shortcuts. Morph uses its own content spacing and width budgets. Item settings still
determine which configured controls appear in its Controls view and shortcuts.
Turn on **Attach to screen edge** to join the hub to the chosen edge with curved Turn on **Attach to screen edge** to join the hub to the chosen edge with curved
shoulders, or set `morph { attached #true; }`. **Preview on hover** maps to shoulders, or set `morph { attached #true; }`. **Preview on hover** maps to
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-activities = { path = "../crates/ferese-activities" }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
ferese-config = { path = "../crates/ferese-config" } ferese-config = { path = "../crates/ferese-config" }
ferese-ipc = { path = "../crates/ferese-ipc" } ferese-ipc = { path = "../crates/ferese-ipc" }
+412
View File
@@ -0,0 +1,412 @@
mod output;
mod progress;
pub(super) mod setup;
mod transport;
use clap::{Subcommand, ValueEnum};
use ferese_activities::{Data, Id, Source, State, Template, Value};
use ferese_ipc::activities::{OwnerScope, Publish, Request, VERSION};
use serde_json::json;
use std::error::Error;
use std::io::{self, BufRead, Read, Write};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::Path;
use std::process::{Command, Stdio};
#[derive(Debug, Subcommand)]
pub enum Action {
/// Enable shell activities and add the panel item if not already configured.
Setup {
#[arg(long)]
config: std::path::PathBuf,
},
Start {
local: String,
#[arg(long, default_value = "ferese")]
icon: String,
#[arg(long)]
title: String,
#[arg(long)]
progress: Option<f32>,
#[arg(long)]
message: Option<String>,
#[arg(long)]
source: Option<String>,
/// Typed Progress, Timer, Media, or List payload as JSON.
#[arg(long)]
template: Option<String>,
#[arg(long)]
remaining_ms: Option<u64>,
#[arg(long)]
output: Option<String>,
#[arg(long, default_value_t = 100)]
priority: u8,
#[arg(long)]
lease_ms: Option<u64>,
},
Update {
local: String,
#[arg(long)]
progress: Option<f32>,
#[arg(long)]
message: Option<String>,
#[arg(long)]
remaining_ms: Option<u64>,
#[arg(long)]
lease_ms: Option<u64>,
#[arg(long)]
handle: Option<String>,
},
End {
local: String,
#[arg(long)]
result: ResultKind,
#[arg(long)]
message: Option<String>,
#[arg(long)]
handle: Option<String>,
},
Cancel {
local: String,
#[arg(long)]
handle: Option<String>,
},
/// Report an observed command to opt-in rules without executing it.
Hook {
local: String,
#[arg(long)]
command: String,
#[arg(long)]
cwd: String,
},
/// Show all current activity records.
List,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum ResultKind {
Ok,
Fail,
Cancelled,
}
fn handle(value: &Option<String>) -> Result<Option<Id>, Box<dyn Error>> {
value
.as_ref()
.map(|value| serde_json::from_str(value).map_err(Into::into))
.transpose()
}
fn value(
progress: Option<f32>,
message: &Option<String>,
remaining_ms: Option<u64>,
) -> Result<Option<Value>, Box<dyn Error>> {
if usize::from(progress.is_some()) + usize::from(message.is_some()) + usize::from(remaining_ms.is_some()) > 1 {
return Err("choose one activity value".into());
}
Ok(progress
.map(Value::Progress)
.or_else(|| message.clone().map(Value::Text))
.or_else(|| {
remaining_ms.map(|remaining_ms| Value::Countdown {
remaining_ms,
sampled_at: 0,
})
}))
}
fn publish(socket: &Path, owner: OwnerScope, publish: Publish) -> Result<serde_json::Value, Box<dyn Error>> {
publish_with_timeout(socket, owner, publish, std::time::Duration::from_secs(2))
}
fn publish_with_timeout(
socket: &Path,
owner: OwnerScope,
publish: Publish,
timeout: std::time::Duration,
) -> Result<serde_json::Value, Box<dyn Error>> {
let mut stream = transport::connect(socket, timeout)?;
super::request(
&mut stream,
"activity-publish",
json!(Request {
version: VERSION,
owner,
publish
}),
)
}
pub fn command(action: &Action, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> {
let request = match action {
Action::Setup { .. } => unreachable!("setup is an offline command"),
Action::Start {
local,
icon,
title,
progress,
remaining_ms,
message,
source,
template,
output,
priority,
lease_ms,
} => {
let value = value(*progress, message, *remaining_ms)?.unwrap_or(Value::Indeterminate);
let data = Data {
source: if let Some(source) = source {
serde_json::from_value(json!(source))?
} else if remaining_ms.is_some() {
Source::Timer
} else {
Source::Publisher
},
output: output.clone(),
priority: *priority,
icon: icon.clone(),
title: title.clone(),
template: if let Some(template) = template {
if template.len() > 4_096 {
return Err("template exceeds message budget".into());
}
serde_json::from_str(template)?
} else if remaining_ms.is_some() {
Template::Timer
} else {
Template::Progress
},
value,
};
Publish::Start {
local: local.clone(),
data,
lease_ms: *lease_ms,
}
}
Action::Update {
local,
progress,
message,
remaining_ms,
lease_ms,
handle: token,
} => Publish::Update {
local: local.clone(),
handle: handle(token)?,
value: value(*progress, message, *remaining_ms)?,
lease_ms: *lease_ms,
},
Action::End {
local,
result,
message,
handle: token,
} => Publish::End {
local: local.clone(),
handle: handle(token)?,
result: match result {
ResultKind::Ok => State::Done,
ResultKind::Fail => State::Failed,
ResultKind::Cancelled => State::Cancelled,
},
message: message.clone(),
},
Action::Cancel { local, handle: token } => Publish::Cancel {
local: local.clone(),
handle: handle(token)?,
},
Action::Hook { local, command, cwd } => Publish::Hook {
local: local.clone(),
command: command.clone(),
cwd: cwd.clone(),
},
Action::List => {
let mut stream = std::os::unix::net::UnixStream::connect(socket)?;
return super::output::print(&super::request(&mut stream, "activity-get", json!({}))?, json_output);
}
};
let result = publish(socket, OwnerScope::Parent, request)?;
// Handles are machine-readable even when general queries use tabular output.
super::output::print(&result, true)
}
static CHILD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
extern "C" fn terminate(signal: i32) {
let pid = CHILD.load(std::sync::atomic::Ordering::Relaxed);
// SAFETY: kill is async-signal-safe; the PID belongs to this wrapper's child.
if pid > 0 {
unsafe {
libc::kill(pid, signal);
}
}
}
pub fn run(
title: Option<&str>,
args: &[String],
socket: Option<&Path>,
cargo_json: bool,
) -> Result<(), Box<dyn Error>> {
let Some(program) = args.first() else {
return Err("run requires a command after --".into());
};
let local = format!("run-{}", std::process::id());
let mut token = None;
let mut configured_adapter = false;
if let Some(socket) = socket {
let metadata = args.join(" ");
if metadata.len() <= 4_096 {
let result = publish(
socket,
OwnerScope::Publisher,
Publish::Run {
local: local.clone(),
title: title.map(str::to_owned),
command: metadata,
cwd: std::env::current_dir()
.ok()
.map(|path| path.to_string_lossy().into_owned())
.filter(|cwd| cwd.len() <= 1_024)
.unwrap_or_default(),
},
)
.ok();
if let Some(result) = result {
configured_adapter = result["progress"] == "cargo-json";
token = serde_json::from_value(result["handle"].clone()).ok();
}
}
}
let adapter = (configured_adapter || cargo_json)
&& program == "cargo"
&& args
.get(1)
.is_some_and(|operation| matches!(operation.as_str(), "build" | "check" | "test" | "run" | "clippy"));
let mut command = Command::new(program);
command.args(&args[1..]);
if adapter && !args.iter().any(|arg| arg.starts_with("--message-format")) {
// Cargo options must precede the program arguments following `--`.
let split = args[1..]
.iter()
.position(|arg| arg == "--")
.map(|index| index + 1)
.unwrap_or(args.len());
command = Command::new(program);
command
.args(&args[1..split])
.arg("--message-format=json-render-diagnostics")
.args(&args[split..]);
}
command
.stdin(Stdio::inherit())
.stderr(Stdio::inherit())
.stdout(if adapter { Stdio::piped() } else { Stdio::inherit() });
// Terminal-generated signals reach both processes in the shell's job group.
// The wrapper waits for the actual child result; the child resets inherited
// dispositions before exec. SIGTSTP/SIGCONT retain normal job control.
unsafe {
libc::signal(libc::SIGINT, libc::SIG_IGN);
libc::signal(libc::SIGQUIT, libc::SIG_IGN);
libc::signal(libc::SIGTERM, terminate as *const () as usize);
command.pre_exec(|| {
libc::signal(libc::SIGINT, libc::SIG_DFL);
libc::signal(libc::SIGQUIT, libc::SIG_DFL);
libc::signal(libc::SIGTERM, libc::SIG_DFL);
Ok(())
});
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
if let (Some(socket), Some(handle)) = (socket, token) {
let _ = publish(
socket,
OwnerScope::Publisher,
Publish::End {
local,
handle: Some(handle),
result: State::Failed,
message: Some("Could not start command".into()),
},
);
}
return Err(error.into());
}
};
CHILD.store(child.id() as i32, std::sync::atomic::Ordering::Relaxed);
let publisher = socket
.zip(token)
.map(|(socket, handle)| progress::Publisher::new(socket.to_owned(), local, handle));
let drain_deadline = output::Deadline::default();
let reader = child.stdout.take().map(|stdout| {
let deadline = drain_deadline.clone();
let updates = publisher.as_ref().map(|publisher| publisher.updates.clone());
let (done, finished) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let mut input = io::BufReader::new(output::Output { pipe: stdout, deadline });
let mut stdout = io::stdout().lock();
let mut units = 0_u64;
let mut last = std::time::Instant::now();
loop {
let mut bytes = Vec::new();
// Read bounded chunks, preserving arbitrary non-JSON output.
let Ok(count) = input.by_ref().take(65_537).read_until(b'\n', &mut bytes) else {
break;
};
if count == 0 {
break;
}
if bytes.len() <= 65_536
&& bytes.first() == Some(&b'{')
&& let Ok(message) = serde_json::from_slice::<serde_json::Value>(&bytes)
{
if message["reason"] == "compiler-message" {
if let Some(rendered) = message["message"]["rendered"].as_str() {
let _ = stdout.write_all(rendered.as_bytes());
}
} else if message["reason"] == "compiler-artifact" {
units = units.saturating_add(1);
} else if message["reason"] != "build-finished" && message["reason"] != "build-script-executed" {
let _ = stdout.write_all(&bytes);
}
if last.elapsed() >= std::time::Duration::from_millis(200) {
if let Some(updates) = &updates {
updates.progress(units);
}
last = std::time::Instant::now();
}
continue;
}
if stdout.write_all(&bytes).and_then(|_| stdout.flush()).is_err() {
break;
}
}
let _ = stdout.flush();
let _ = done.send(());
});
finished
});
let status = child.wait()?;
CHILD.store(0, std::sync::atomic::Ordering::Relaxed);
*drain_deadline.lock().unwrap() = Some(std::time::Instant::now() + std::time::Duration::from_millis(250));
if let Some(reader) = reader {
// Descendants may inherit the pipe after the direct child exits. Drain
// buffered output briefly, but never wait for those descendants' EOF.
let _ = reader.recv_timeout(std::time::Duration::from_millis(350));
}
if let Some(publisher) = publisher {
let result = if matches!(status.signal(), Some(libc::SIGINT | libc::SIGTERM | libc::SIGQUIT)) {
State::Cancelled
} else if status.success() {
State::Done
} else {
State::Failed
};
publisher.finish(result);
}
if let Some(signal) = status.signal() {
// Preserve signal termination as well as the conventional shell status.
unsafe {
libc::signal(signal, libc::SIG_DFL);
libc::raise(signal);
}
std::process::exit(128 + signal);
}
std::process::exit(status.code().unwrap_or(1));
}
+47
View File
@@ -0,0 +1,47 @@
//! Drain a child's pipe without waiting for inherited descriptors indefinitely.
use std::io::{self, Read};
use std::os::fd::AsRawFd;
use std::process::ChildStdout;
use std::sync::{Arc, Mutex};
use std::time::Instant;
pub(super) type Deadline = Arc<Mutex<Option<Instant>>>;
pub(super) struct Output {
pub pipe: ChildStdout,
pub deadline: Deadline,
}
impl Read for Output {
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
if bytes.is_empty() {
return Ok(0);
}
loop {
if self
.deadline
.lock()
.unwrap()
.is_some_and(|deadline| Instant::now() >= deadline)
{
// EOF lets BufReader return its final unterminated line too.
return Ok(0);
}
let mut fd = libc::pollfd {
fd: self.pipe.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// The pipe has exactly one reader. Once readable (or hung up), a
// pipe read returns available bytes (or EOF) without filling bytes.
let result = unsafe { libc::poll(&mut fd, 1, 20) };
if result > 0 {
return self.pipe.read(bytes);
}
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! One replaceable progress update, independent of the child's stdout reader.
use super::{Id, OwnerScope, Publish, State, Value, publish_with_timeout};
use std::path::PathBuf;
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::Duration;
#[derive(Default)]
struct Pending {
units: Option<u64>,
end: Option<State>,
closed: bool,
}
#[derive(Clone, Default)]
pub(super) struct Updates(Arc<(Mutex<Pending>, Condvar)>);
impl Updates {
pub fn progress(&self, units: u64) {
let (lock, wake) = &*self.0;
let mut pending = lock.lock().unwrap();
if !pending.closed {
pending.units = Some(units);
wake.notify_one();
}
}
}
pub(super) struct Publisher {
pub updates: Updates,
finished: mpsc::Receiver<()>,
}
impl Publisher {
pub fn new(socket: PathBuf, local: String, handle: Id) -> Self {
let updates = Updates::default();
let worker = updates.clone();
let (done, finished) = mpsc::sync_channel(1);
std::thread::spawn(move || {
loop {
let (lock, wake) = &*worker.0;
let mut pending = lock.lock().unwrap();
while pending.units.is_none() && pending.end.is_none() {
pending = wake.wait(pending).unwrap();
}
let end = pending.end.take();
let units = pending.units.take();
drop(pending); // Never hold the reader's lock during socket I/O.
let request = if let Some(result) = end {
Publish::End {
local: local.clone(),
handle: Some(handle.clone()),
result,
message: None,
}
} else {
Publish::Update {
local: local.clone(),
handle: Some(handle.clone()),
value: Some(Value::Text(format!("{} units compiled", units.unwrap()))),
lease_ms: None,
}
};
let timeout = if end.is_some() {
Duration::from_secs(2)
} else {
Duration::from_millis(200)
};
let _ = publish_with_timeout(&socket, OwnerScope::Publisher, request, timeout);
if end.is_some() {
break;
}
}
let _ = done.send(());
});
Self { updates, finished }
}
pub fn finish(self, result: State) {
let (lock, wake) = &*self.updates.0;
{
let mut pending = lock.lock().unwrap();
pending.closed = true;
pending.units = None;
pending.end = Some(result);
wake.notify_one();
}
// Completion has a fixed budget even if the broker stops responding.
let _ = self.finished.recv_timeout(Duration::from_secs(2));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ferese_ipc::{Request, Response, read_frame, write_frame};
use std::os::unix::net::UnixListener;
#[test]
fn blocked_publication_coalesces_updates_and_completion_is_last() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("broker.sock");
let listener = UnixListener::bind(&socket).unwrap();
let (seen, received) = mpsc::channel();
let (release, released) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut first, _) = listener.accept().unwrap();
first.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
let request: Request = read_frame(&mut first).unwrap();
seen.send(()).unwrap();
released.recv_timeout(Duration::from_secs(1)).unwrap();
// The progress timeout can already have elapsed on a loaded host.
let _ = write_frame(&mut first, &Response::success(request.id, serde_json::json!({})));
let (mut latest, _) = listener.accept().unwrap();
let request: Request = read_frame(&mut latest).unwrap();
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
assert!(
matches!(wire.publish, Publish::Update { value: Some(Value::Text(ref text)), .. } if text == "1000 units compiled")
);
write_frame(&mut latest, &Response::success(request.id, serde_json::json!({}))).unwrap();
seen.send(()).unwrap();
let (mut end, _) = listener.accept().unwrap();
let request: Request = read_frame(&mut end).unwrap();
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
assert!(matches!(
wire.publish,
Publish::End {
result: State::Done,
..
}
));
write_frame(&mut end, &Response::success(request.id, serde_json::json!({}))).unwrap();
});
let publisher = Publisher::new(
socket,
"test".into(),
Id {
owner: ferese_activities::Owner { pid: 1, birth: 1 },
local: "test".into(),
session: 1,
generation: 1,
},
);
publisher.updates.progress(1);
received.recv_timeout(Duration::from_secs(1)).unwrap();
for units in 2..=1000 {
publisher.updates.progress(units);
}
release.send(()).unwrap();
received.recv_timeout(Duration::from_secs(1)).unwrap();
publisher.finish(State::Done);
server.join().unwrap();
}
}
+124
View File
@@ -0,0 +1,124 @@
use ferese_config::{Document, file::ConfigFile, panel};
use serde_json::json;
use std::{error::Error, path::Path};
fn prepare(source: &str) -> Result<String, Box<dyn Error>> {
let mut doc = Document::parse(source)?;
if !ferese_config::activities::setup_shell_hook(&mut doc)? {
return Ok(source.into());
}
if doc.get("panels").is_none() {
doc.set("panels", serde_json::to_value(vec![panel::Panel::default()])?)?;
}
let panels: Vec<panel::Panel> = serde_json::from_value(doc.get("panels").unwrap().clone())?;
panel::validate(&panels)?;
for (index, bar) in panels.iter().enumerate() {
let groups = [&bar.start, &bar.center, &bar.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.collect::<Vec<_>>();
if groups
.iter()
.flat_map(|group| &group.items)
.any(|item| item.kind == panel::ItemKind::Activities)
{
continue;
}
let mut id = "activities".to_string();
while groups.iter().flat_map(|group| &group.items).any(|item| item.id.0 == id) {
id.push('-');
}
let item = serde_json::to_value(panel::Item::new(&id, panel::ItemKind::Activities))?;
// Append within an existing group, preserving its position and every other item.
let zone = ["end", "center", "start"].into_iter().find(|zone| {
doc.get(&format!("panels.{index}.{zone}.groups"))
.and_then(serde_json::Value::as_array)
.is_some_and(|groups| !groups.is_empty())
});
if let Some(zone) = zone {
doc.add(
&format!("panels.{index}.{zone}.groups.0.items"),
item.as_object().unwrap().clone().into_iter().collect(),
)?;
} else {
doc.add(
&format!("panels.{index}.end.groups"),
vec![("id".into(), json!("activities")), ("items".into(), json!([item]))],
)?;
}
}
let panels = serde_json::from_value::<Vec<panel::Panel>>(doc.get("panels").unwrap().clone())?;
panel::validate(&panels)?;
Ok(doc.to_string())
}
pub fn configure(path: &Path) -> Result<(), Box<dyn Error>> {
let file = ConfigFile::open(path)?;
let desired = prepare(file.source())?;
if desired == file.source() {
return Ok(());
}
let backup = path.with_file_name(format!(
"{}.before-activities-{}",
path.file_name().unwrap().to_string_lossy(),
ferese_ipc::activities::now_ms()
));
file.replace(&desired, Some(&backup), |_| Ok(()))?;
println!("Configured Live Activities; previous config: {}", backup.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setup_preserves_layout_comments_rules_and_explicit_disable() {
let source =
"// keep my layout\npanel main { style morph; end { group tools { item activities kind=clock; }; }; }\n";
let next = prepare(source).unwrap();
assert!(next.contains("// keep my layout"));
let doc = Document::parse(&next).unwrap();
assert_eq!(doc.get("panels.0.style"), Some(&json!("morph")));
assert_eq!(doc.get("panels.0.end.groups.0.items.0.kind"), Some(&json!("clock")));
assert_eq!(doc.get("panels.0.end.groups.0.items.1.id"), Some(&json!("activities-")));
assert_eq!(prepare(&next).unwrap(), next);
let disabled = "activities { sources { shell-hook #false; }; }\n";
assert_eq!(prepare(disabled).unwrap(), disabled);
let hidden = "panel main { end { group tools { item tasks kind=activities visible=#false; }; }; }\n";
let hidden = Document::parse(&prepare(hidden).unwrap()).unwrap();
assert_eq!(hidden.get("panels.0.end.groups.0.items.0.visible"), Some(&json!(false)));
assert_eq!(
hidden
.get("panels.0.end.groups.0.items")
.unwrap()
.as_array()
.unwrap()
.len(),
1
);
assert!(prepare("").is_ok());
let partial = Document::parse(&prepare("activities { max-visible 1; }\n").unwrap()).unwrap();
assert_eq!(partial.get("activities.max_visible"), Some(&json!(1)));
assert_eq!(partial.get("activities.sources.shell_hook"), Some(&json!(true)));
assert!(prepare("activities { unknown 1; }").is_err());
}
#[test]
fn configuration_is_backed_up_and_invalid_input_is_not_replaced() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
std::fs::write(&path, "// original\n").unwrap();
configure(&path).unwrap();
configure(&path).unwrap();
let backups: Vec<_> = std::fs::read_dir(directory.path())
.unwrap()
.flatten()
.filter(|file| file.file_name().to_string_lossy().contains(".before-activities-"))
.collect();
assert_eq!(backups.len(), 1);
assert_eq!(std::fs::read_to_string(backups[0].path()).unwrap(), "// original\n");
std::fs::write(&path, "activities { bad #true; }\n").unwrap();
assert!(configure(&path).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "activities { bad #true; }\n");
}
}
+131
View File
@@ -0,0 +1,131 @@
//! A complete exchange has one deadline, including a full Unix listen backlog.
use std::io::{self, Read, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::{ffi::OsStrExt, net::UnixStream};
use std::path::Path;
use std::time::{Duration, Instant};
pub(super) struct Stream {
socket: UnixStream,
deadline: Instant,
}
pub(super) fn connect(path: &Path, timeout: Duration) -> io::Result<Stream> {
let deadline = Instant::now() + timeout;
let path = path.as_os_str().as_bytes();
// SAFETY: an all-zero sockaddr_un is valid to initialize before connect.
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
if path.is_empty() || path.len() >= address.sun_path.len() || path.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid activity socket path",
));
}
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
for (to, from) in address.sun_path.iter_mut().zip(path) {
*to = *from as libc::c_char;
}
// SAFETY: socket creates a new descriptor; nonblocking connect cannot wait
// indefinitely for room in a Unix listener's queue.
let fd = unsafe {
libc::socket(
libc::AF_UNIX,
libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK,
0,
)
};
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: this function takes sole ownership of the descriptor just created.
let socket = unsafe { UnixStream::from_raw_fd(fd) };
let length = std::mem::offset_of!(libc::sockaddr_un, sun_path) + path.len() + 1;
// SAFETY: address contains a checked, NUL-terminated pathname of length bytes.
if unsafe {
libc::connect(
socket.as_raw_fd(),
(&raw const address).cast(),
length as libc::socklen_t,
)
} != 0
{
// Unix connect returns EAGAIN for a full backlog. Activity publishing is
// best effort, so fail promptly instead of delaying the wrapped command.
return Err(io::Error::last_os_error());
}
socket.set_nonblocking(false)?;
Ok(Stream { socket, deadline })
}
impl Stream {
fn remaining(&self) -> io::Result<Duration> {
self.deadline
.checked_duration_since(Instant::now())
.filter(|remaining| !remaining.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "activity publication deadline expired"))
}
}
impl Read for Stream {
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
self.socket.set_read_timeout(Some(self.remaining()?))?;
self.socket.read(bytes)
}
}
impl Write for Stream {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.socket.set_write_timeout(Some(self.remaining()?))?;
self.socket.write(bytes)
}
fn flush(&mut self) -> io::Result<()> {
self.socket.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::UnixListener;
use std::sync::mpsc;
#[test]
fn trickled_response_expires_and_closes_the_socket_without_a_detached_reader() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broker.sock");
let listener = UnixListener::bind(&path).unwrap();
let (done, closed) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let _: ferese_ipc::Request = ferese_ipc::read_frame(&mut socket).unwrap();
socket.write_all(&1000_u32.to_be_bytes()).unwrap();
for _ in 0..50 {
if socket.write_all(b" ").is_err() {
done.send(()).unwrap();
return;
}
std::thread::sleep(Duration::from_millis(20));
}
});
{
let mut stream = connect(&path, Duration::from_millis(100)).unwrap();
assert!(super::super::super::request(&mut stream, "activity-get", serde_json::json!({})).is_err());
}
closed
.recv_timeout(Duration::from_millis(400))
.expect("timed-out socket remained open");
server.join().unwrap();
}
#[test]
fn full_listener_backlog_does_not_block_connect() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broker.sock");
let listener = UnixListener::bind(&path).unwrap();
// SAFETY: the listener owns this socket; reducing its backlog does not
// affect the descriptor or the sockaddr used by subsequent clients.
assert_eq!(unsafe { libc::listen(listener.as_raw_fd(), 0) }, 0);
let _first = connect(&path, Duration::from_secs(1)).unwrap();
let started = Instant::now();
assert!(connect(&path, Duration::from_secs(1)).is_err());
assert!(started.elapsed() < Duration::from_millis(200));
}
}
+15
View File
@@ -19,6 +19,20 @@ pub struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum Command { pub enum Command {
/// Publish bounded activity data owned by the invoking shell.
Activity {
#[command(subcommand)]
action: crate::activities::Action,
},
/// Run a command with activity publishing; preserve its actual exit status.
Run {
#[arg(long)]
title: Option<String>,
#[arg(long)]
cargo_json: bool,
#[arg(last = true, required = true)]
command: Vec<String>,
},
/// Show the active window. /// Show the active window.
#[command(alias = "get-focused-window")] #[command(alias = "get-focused-window")]
FocusedWindow, FocusedWindow,
@@ -214,6 +228,7 @@ fn value_name(value: &impl ValueEnum) -> String {
impl Command { impl Command {
pub fn ipc(&self) -> Option<(&'static str, Value)> { pub fn ipc(&self) -> Option<(&'static str, Value)> {
let (command, args) = match self { let (command, args) = match self {
Self::Activity { .. } | Self::Run { .. } => return None,
Self::FocusedWindow => ("get-focused-window", json!({})), Self::FocusedWindow => ("get-focused-window", json!({})),
Self::Windows => ("get-windows", json!({})), Self::Windows => ("get-windows", json!({})),
Self::IdleInhibition => ("get-idle-inhibition", json!({})), Self::IdleInhibition => ("get-idle-inhibition", json!({})),
+19 -2
View File
@@ -1,3 +1,4 @@
mod activities;
mod autostart; mod autostart;
mod cli; mod cli;
mod output; mod output;
@@ -18,6 +19,12 @@ fn main() -> Result<(), Box<dyn Error>> {
if matches!(cli.command, Command::Autostart) { if matches!(cli.command, Command::Autostart) {
return autostart::run(); return autostart::run();
} }
if let Command::Activity {
action: activities::Action::Setup { config },
} = &cli.command
{
return activities::setup::configure(config);
}
for alias in env::args().skip(1).filter(|arg| { for alias in env::args().skip(1).filter(|arg| {
matches!( matches!(
arg.as_str(), arg.as_str(),
@@ -35,9 +42,19 @@ fn main() -> Result<(), Box<dyn Error>> {
alias.trim_start_matches("get-") alias.trim_start_matches("get-")
); );
} }
if let Command::Run {
title,
command,
cargo_json,
} = &cli.command
{
let socket = ferese_ipc::socket::resolve(cli.socket.as_deref()).ok();
return activities::run(title.as_deref(), command, socket.as_deref(), *cargo_json);
}
let socket = ferese_ipc::socket::resolve(cli.socket.as_deref())?; let socket = ferese_ipc::socket::resolve(cli.socket.as_deref())?;
match &cli.command { match &cli.command {
Command::Theme { action } => theme_command(action, &socket, cli.json), Command::Theme { action } => theme_command(action, &socket, cli.json),
Command::Activity { action } => activities::command(action, &socket, cli.json),
Command::EventStream => event_stream(&socket), Command::EventStream => event_stream(&socket),
command => { command => {
let (command, args) = command.ipc().expect("IPC command"); let (command, args) = command.ipc().expect("IPC command");
@@ -52,7 +69,7 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
} }
fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value, Box<dyn Error>> { fn request(stream: &mut (impl io::Read + io::Write), command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
write_frame( write_frame(
stream, stream,
&Request { &Request {
@@ -74,7 +91,7 @@ fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value,
} }
fn theme_command(action: &ThemeAction, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> { fn theme_command(action: &ThemeAction, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> {
let mut connection = ferese_ipc::theme::Connection::connect_to(socket)?; let mut connection = ferese_ipc::Connection::connect_to(socket)?;
let result = match action { let result = match action {
ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?, ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?,
ThemeAction::Subscribe => { ThemeAction::Subscribe => {
+281
View File
@@ -189,3 +189,284 @@ fn event_stream_uses_the_selected_socket_and_prints_complete_json_lines() {
stop.send(()).unwrap(); stop.send(()).unwrap();
worker.join().unwrap(); worker.join().unwrap();
} }
#[test]
fn activity_start_uses_versioned_framing_and_parent_scope() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("activity.sock");
let listener = UnixListener::bind(&socket).unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request: Request = read_frame(&mut stream).unwrap();
assert_eq!(request.version, ferese_ipc::VERSION);
assert_eq!(request.command, "activity-publish");
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
assert!(matches!(wire.owner, ferese_ipc::activities::OwnerScope::Parent));
match wire.publish {
ferese_ipc::activities::Publish::Start { local, data, .. } => {
assert_eq!(local, "build");
assert_eq!(data.value, ferese_activities::Value::Indeterminate);
}
_ => panic!("wrong activity action"),
}
write_frame(
&mut stream,
&Response::success(request.id, json!({"handle":{"generation":1}})),
)
.unwrap();
});
success(
ctl(&[
"--socket",
socket.to_str().unwrap(),
"activity",
"start",
"build",
"--title",
"Build",
])
.output()
.unwrap(),
);
server.join().unwrap();
}
#[test]
fn run_preserves_arguments_cwd_environment_output_and_exit_when_publishing_fails() {
let dir = tempfile::tempdir().unwrap();
let output = ctl(&[
"--socket",
"/missing/ferese.sock",
"run",
"--title",
"Test",
"--",
"sh",
"-c",
"printf '%s|%s|%s' \"$1\" \"$ACTIVITY_TEST_VALUE\" \"$PWD\"; printf stderr >&2; exit 17",
"sh",
"argument with spaces;$literal",
])
.env("ACTIVITY_TEST_VALUE", "preserved")
.current_dir(dir.path())
.output()
.unwrap();
assert_eq!(output.status.code(), Some(17));
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("argument with spaces;$literal|preserved|{}", dir.path().display())
);
assert_eq!(output.stderr, b"stderr");
}
#[test]
fn run_preserves_child_exit_and_signal_status() {
use std::os::unix::process::ExitStatusExt;
let output = ctl(&[
"run",
"--",
"sh",
"-c",
"printf '{\"reason\":\"build-finished\",\"success\":true}\\n'; exit 19",
])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(19));
let output = ctl(&["run", "--", "sh", "-c", "kill -TERM $$"]).output().unwrap();
assert_eq!(output.status.signal(), Some(libc::SIGTERM));
}
#[test]
fn wrapper_forwards_termination_and_preserves_job_control_group() {
use std::os::unix::process::{CommandExt, ExitStatusExt};
let mut command = ctl(&["run", "--", "sh", "-c", "kill -STOP $$; sleep 30"]);
unsafe {
command.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
let mut child = command.spawn().unwrap();
std::thread::sleep(std::time::Duration::from_millis(150));
let pid = child.id() as i32;
assert_eq!(unsafe { libc::kill(-pid, libc::SIGCONT) }, 0);
assert_eq!(unsafe { libc::kill(pid, libc::SIGTERM) }, 0);
assert_eq!(child.wait().unwrap().signal(), Some(libc::SIGTERM));
}
#[test]
fn terminal_ctrl_c_reaches_child_and_wrapper_returns_sigint() {
use std::os::unix::process::{CommandExt, ExitStatusExt};
let mut command = ctl(&["run", "--", "sleep", "30"]);
unsafe {
command.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
let mut child = command.spawn().unwrap();
std::thread::sleep(std::time::Duration::from_millis(150));
assert_eq!(unsafe { libc::kill(-(child.id() as i32), libc::SIGINT) }, 0);
assert_eq!(child.wait().unwrap().signal(), Some(libc::SIGINT));
}
#[test]
fn a_silent_broker_cannot_prevent_or_change_command_execution() {
let directory = tempfile::tempdir().unwrap();
let socket = directory.path().join("blocked.sock");
let listener = UnixListener::bind(&socket).unwrap();
let worker = thread::spawn(move || {
let (_stream, _) = listener.accept().unwrap();
std::thread::sleep(std::time::Duration::from_secs(3));
});
let began = std::time::Instant::now();
let result = ctl(&[
"--socket",
socket.to_str().unwrap(),
"run",
"--",
"sh",
"-c",
"printf executed; exit 23",
])
.output()
.unwrap();
assert_eq!(result.stdout, b"executed");
assert_eq!(result.status.code(), Some(23));
assert!(began.elapsed() < std::time::Duration::from_secs(3));
worker.join().unwrap();
}
fn fake_cargo(dir: &Path, script: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("cargo");
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
format!("{}:{}", dir.display(), std::env::var("PATH").unwrap_or_default())
}
#[test]
fn cargo_adapter_completion_does_not_wait_for_descendant_stdout() {
let dir = tempfile::tempdir().unwrap();
let path = fake_cargo(
dir.path(),
"#!/bin/sh\nsleep 5 &\necho $! > descendant\nprintf captured\nexit 23\n",
);
let output = dir.path().join("output");
let mut child = ctl(&[
"--socket",
"/missing/activity.sock",
"run",
"--cargo-json",
"--",
"cargo",
"build",
])
.env("PATH", path)
.current_dir(dir.path())
.stdout(std::fs::File::create(&output).unwrap())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break Some(status);
}
if std::time::Instant::now() >= deadline {
child.kill().unwrap();
child.wait().unwrap();
break None;
}
thread::sleep(std::time::Duration::from_millis(10));
};
let descendant: i32 = std::fs::read_to_string(dir.path().join("descendant"))
.unwrap()
.trim()
.parse()
.unwrap();
unsafe {
libc::kill(descendant, libc::SIGTERM);
}
assert_eq!(
status.and_then(|status| status.code()),
Some(23),
"waited for descendant EOF"
);
assert_eq!(std::fs::read(&output).unwrap(), b"captured");
}
#[test]
fn stalled_progress_acknowledgements_do_not_block_cargo_stdout() {
let dir = tempfile::tempdir().unwrap();
let path = fake_cargo(
dir.path(),
r##"#!/usr/bin/env python3
import json, time, sys
start = time.monotonic()
time.sleep(.3)
for _ in range(20000):
print('{"reason":"compiler-artifact"}')
sys.stdout.flush()
with open('elapsed', 'w') as f:
f.write(str(time.monotonic() - start))
print('{"reason":"build-finished","success":true}')
print('child output')
print(json.dumps({'reason': 'compiler-message', 'message': {'rendered': 'diagnostic\n'}}))
sys.exit(19)
"##,
);
let socket = dir.path().join("broker.sock");
let listener = UnixListener::bind(&socket).unwrap();
listener.set_nonblocking(true).unwrap();
let server = thread::spawn(move || {
let mut stalled = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
let Ok((mut stream, _)) = listener.accept() else {
thread::sleep(std::time::Duration::from_millis(5));
continue;
};
stream
.set_read_timeout(Some(std::time::Duration::from_secs(1)))
.unwrap();
let request: Request = read_frame(&mut stream).unwrap();
match request.args["publish"]["action"].as_str().unwrap() {
"run" => {
let handle = ferese_activities::Id {
owner: ferese_activities::Owner { pid: 1, birth: 1 },
local: request.args["publish"]["local"].as_str().unwrap().into(),
session: 1,
generation: 1,
};
write_frame(
&mut stream,
&Response::success(request.id, json!({"handle":handle,"progress":"cargo-json"})),
)
.unwrap();
}
"update" => stalled.push(stream), // Accept the update but never acknowledge it.
"end" => {
assert_eq!(request.args["publish"]["result"], "failed");
write_frame(&mut stream, &Response::success(request.id, json!({}))).unwrap();
return stalled.len();
}
_ => panic!("unexpected publication"),
}
}
panic!("completion was not published");
});
let output = ctl(&["--socket", socket.to_str().unwrap(), "run", "--", "cargo", "build"])
.env("PATH", path)
.current_dir(dir.path())
.output()
.unwrap();
assert_eq!(output.status.code(), Some(19));
assert_eq!(output.stdout, b"child output\ndiagnostic\n");
assert!(server.join().unwrap() > 0, "probe must exercise progress publishing");
let elapsed: f64 = std::fs::read_to_string(dir.path().join("elapsed"))
.unwrap()
.parse()
.unwrap();
assert!(elapsed < 1.5, "stdout reader stalled the child for {elapsed}s");
}
+2 -2
View File
@@ -53,7 +53,7 @@ pub(crate) struct Settings(Arc<RwLock<Appearance>>);
impl Settings { impl Settings {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
let theme = ferese_ipc::theme::Connection::connect() let theme = ferese_ipc::Connection::connect()
.ok() .ok()
.and_then(|mut connection| connection.get(ferese_config::families::builtins).ok()) .and_then(|mut connection| connection.get(ferese_config::families::builtins).ok())
.map(|snapshot| snapshot.theme); .map(|snapshot| snapshot.theme);
@@ -78,7 +78,7 @@ impl Settings {
pub(crate) async fn watch(self, connection: Connection) { pub(crate) async fn watch(self, connection: Connection) {
let emitter = SignalEmitter::new(&connection, PATH).unwrap(); let emitter = SignalEmitter::new(&connection, PATH).unwrap();
loop { loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await; let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
if let Ok(Ok(mut connection)) = connection if let Ok(Ok(mut connection)) = connection
&& let Ok(cancellation) = connection.cancellation() && let Ok(cancellation) = connection.cancellation()
{ {
+1
View File
@@ -61,6 +61,7 @@ def main():
("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}), ("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}),
("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}), ("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}),
("invalid-config", "startup_config", {"FERESE_TEST_STARTUP_CONFIG": "1"}), ("invalid-config", "startup_config", {"FERESE_TEST_STARTUP_CONFIG": "1"}),
("activities", "activities", {"FERESE_TEST_ACTIVITIES": "1"}),
] ]
if args.suite == "desktop": if args.suite == "desktop":
cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"})) cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"}))
+7 -2
View File
@@ -14,6 +14,7 @@ Build, bundle and install Ferese. Run as your normal user.
--replace-portal-config Back up and replace modified Ferese portal config --replace-portal-config Back up and replace modified Ferese portal config
--offline Build using cached Cargo dependencies --offline Build using cached Cargo dependencies
--resize-metrics Build with resize-barrier measurements --resize-metrics Build with resize-barrier measurements
--no-activities Skip automatic shell hook and Activities setup
--dry-run Show commands without building or installing --dry-run Show commands without building or installing
-h, --help Show this help -h, --help Show this help
@@ -33,6 +34,7 @@ replace_portal_config=false
offline=false offline=false
resize_metrics=false resize_metrics=false
dry_run=false dry_run=false
no_activities=false
while (($#)); do while (($#)); do
case $1 in case $1 in
--release-id|--bundle|--bundle-only) --release-id|--bundle|--bundle-only)
@@ -46,6 +48,7 @@ while (($#)); do
--replace-portal-config) replace_portal_config=true; shift ;; --replace-portal-config) replace_portal_config=true; shift ;;
--offline) offline=true; shift ;; --offline) offline=true; shift ;;
--resize-metrics) resize_metrics=true; shift ;; --resize-metrics) resize_metrics=true; shift ;;
--no-activities) no_activities=true; shift ;;
--dry-run) dry_run=true; shift ;; --dry-run) dry_run=true; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) fail "unknown option: $1 (see --help)" ;; *) fail "unknown option: $1 (see --help)" ;;
@@ -100,8 +103,10 @@ if $dry_run; then
exit 0 exit 0
fi fi
user_setup=(python3 "$helper" user-setup)
if $no_activities; then user_setup+=(--no-activities); fi
if [[ -z $bundle_only ]] && ((EUID != 0)); then if [[ -z $bundle_only ]] && ((EUID != 0)); then
python3 "$helper" user-setup --check "${user_setup[@]}" --check
fi fi
if [[ ${prepare+x} ]]; then if [[ ${prepare+x} ]]; then
for tool in cargo desktop-file-validate dbus-run-session; do for tool in cargo desktop-file-validate dbus-run-session; do
@@ -118,7 +123,7 @@ fi
python3 "$helper" verify "$bundle_path" python3 "$helper" verify "$bundle_path"
"${installer[@]}" "${installer[@]}"
if ((EUID != 0)); then if ((EUID != 0)); then
if ! python3 "$helper" user-setup; then if ! "${user_setup[@]}"; then
echo 'System installation succeeded; user setup is incomplete.' >&2 echo 'System installation succeeded; user setup is incomplete.' >&2
echo "Retry as your normal user: python3 '$helper' user-setup" >&2 echo "Retry as your normal user: python3 '$helper' user-setup" >&2
exit 1 exit 1
+73
View File
@@ -6,6 +6,7 @@ import fcntl
import hashlib import hashlib
import json import json
import os import os
import pwd
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
@@ -173,6 +174,7 @@ def validate_bundle(bundle, *, installed=False):
# release's recorded files without requiring these later additions. # release's recorded files without requiring these later additions.
required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png', required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png',
'licenses/Ferese-LICENSE.txt'} 'licenses/Ferese-LICENSE.txt'}
required = {target for target in required if not target.startswith('activities/')}
if not required <= seen or actual != seen | {'manifest.json'}: if not required <= seen or actual != seen | {'manifest.json'}:
fail('Bundle is incomplete or contains unlisted files') fail('Bundle is incomplete or contains unlisted files')
@@ -536,6 +538,73 @@ def system_command(args):
print(f'System {args.command} complete: {manifest["release"]}') print(f'System {args.command} complete: {manifest["release"]}')
def user_file(path, content):
# Respect dotfile symlinks and retain the original permissions.
path = path.resolve()
old = path.read_text() if path.exists() else None
if old == content:
return
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
if old is not None:
backup = path.with_name(path.name + '.before-activities-' + uuid.uuid4().hex)
atomic_bytes(backup, old.encode(), mode)
print(f'Backed up {path}: {backup}')
atomic_bytes(path, content.encode(), mode)
def setup_activities(config, *, ctl=None, assets=None):
shell = Path(os.environ.get('SHELL') or pwd.getpwuid(os.getuid()).pw_shell).name
if shell not in {'fish', 'zsh', 'bash'}:
print(f'Live Activities: no automatic hook for {shell}; use feresectl run.')
return
root = config / 'ferese/activities'
marker = root / 'installed.json'
assets = assets or Path(PREFIX) / 'current/activities'
if not assets.is_dir():
assets = REPO / 'contrib/activities'
ctl = ctl or Path(PREFIX) / 'current/feresectl'
hook = assets / f'ferese.{shell}'
content = hook.read_text()
# Stage shell files before enabling the configuration. Repeated installs
# update our hooks but do not undo a user's later panel/source edits.
user_file(root / hook.name, content)
if shell == 'fish':
startup = config / 'fish/conf.d/ferese-activities.fish'
# Fish accepts single-quoted paths with escaped backslashes and quotes.
quoted = "'" + str(root / hook.name).replace('\\', '\\\\').replace("'", "\\'") + "'"
loader = f'# Ferese Live Activities\nif status is-interactive; and test -r {quoted}\n source {quoted}\nend\n'
user_file(startup, loader)
else:
import shlex
quoted = shlex.quote(str(root / hook.name))
if shell == 'bash':
for name in ('bash-preexec.sh', 'LICENSE.md'):
user_file(root / 'bash-preexec' / name, (assets / 'bash-preexec' / name).read_text())
dependency = shlex.quote(str(root / 'bash-preexec/bash-preexec.sh'))
loader = (f'if [[ $- == *i* && -r {quoted} ]]; then\n'
f' [[ -n ${{bash_preexec_imported:-}} ]] || source {dependency}\n'
f' source {quoted}\nfi')
startup = Path.home() / '.bashrc'
else:
loader = f'if [[ -o interactive && -r {quoted} ]]; then\n source {quoted}\nfi'
startup = Path(os.environ.get('ZDOTDIR') or Path.home()) / '.zshrc'
begin, end = '# >>> Ferese Live Activities >>>', '# <<< Ferese Live Activities <<<'
block = f'{begin}\n{loader}\n{end}\n'
old = startup.read_text() if startup.exists() else ''
if begin in old or end in old:
if old.count(begin) != 1 or old.count(end) != 1 or old.index(end) < old.index(begin):
fail(f'Malformed Ferese hook block in {startup}; file preserved')
start, finish = old.index(begin), old.index(end) + len(end)
updated = old[:start] + block.rstrip('\n') + old[finish:]
else:
updated = old + ('\n' if old and not old.endswith('\n') else '') + block
user_file(startup, updated)
if not marker.exists():
subprocess.run([str(ctl), 'activity', 'setup', '--config', str(config / 'ferese/config.kdl')], check=True)
atomic_json(marker, {'format': 1})
print(f'Live Activities configured for {shell}. Open a new terminal to load the hook.')
def user_setup(args): def user_setup(args):
if os.geteuid() == 0: if os.geteuid() == 0:
fail('Run user-setup as your normal user') fail('Run user-setup as your normal user')
@@ -553,6 +622,8 @@ def user_setup(args):
if not args.check: if not args.check:
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True) subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
if not args.no_activities:
setup_activities(config, ctl=args.activity_ctl)
print('User setup complete. Log out and select Ferese to use the installed release.') print('User setup complete. Log out and select Ferese to use the installed release.')
@@ -569,6 +640,8 @@ def parser():
verify.add_argument('bundle', type=Path) verify.add_argument('bundle', type=Path)
user = commands.add_parser('user-setup') user = commands.add_parser('user-setup')
user.add_argument('--check', action='store_true') user.add_argument('--check', action='store_true')
user.add_argument('--no-activities', action='store_true', help='Skip shell hook and Activities setup')
user.add_argument('--activity-ctl', type=Path, help=argparse.SUPPRESS)
for name in ('install', 'rollback', 'recover', 'uninstall'): for name in ('install', 'rollback', 'recover', 'uninstall'):
command = commands.add_parser(name) command = commands.add_parser(name)
command.add_argument('--root', type=Path, default=Path('/'), help='Destination filesystem root (for packaging/tests)') command.add_argument('--root', type=Path, default=Path('/'), help='Destination filesystem root (for packaging/tests)')
+25
View File
@@ -182,6 +182,31 @@
"source": "packaging/pam.d/ferese-lock.generic", "source": "packaging/pam.d/ferese-lock.generic",
"target": "pam/generic", "target": "pam/generic",
"mode": "0644" "mode": "0644"
},
{
"source": "contrib/activities/ferese.fish",
"target": "activities/ferese.fish",
"mode": "0644"
},
{
"source": "contrib/activities/ferese.zsh",
"target": "activities/ferese.zsh",
"mode": "0644"
},
{
"source": "contrib/activities/ferese.bash",
"target": "activities/ferese.bash",
"mode": "0644"
},
{
"source": "contrib/activities/bash-preexec/bash-preexec.sh",
"target": "activities/bash-preexec/bash-preexec.sh",
"mode": "0644"
},
{
"source": "contrib/activities/bash-preexec/LICENSE.md",
"target": "activities/bash-preexec/LICENSE.md",
"mode": "0644"
} }
] ]
} }
+190
View File
@@ -0,0 +1,190 @@
"""Opt-in real broker/CLI checks on an isolated software-rendered desktop."""
import json
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import struct
import subprocess
import tempfile
import time
import unittest
from session_socket import ipc_socket, ipc_environment
ROOT = Path(__file__).resolve().parents[2]
@unittest.skipUnless(os.environ.get("FERESE_TEST_ACTIVITIES") == "1", "opt-in isolated activities test")
class Activities(unittest.TestCase):
@classmethod
def setUpClass(cls):
host = os.environ.get("WAYLAND_DISPLAY")
if host and not Path(host).is_absolute():
host = str(Path(os.environ["XDG_RUNTIME_DIR"]) / host)
cls.temporary = tempfile.TemporaryDirectory(prefix="ferese-activities-")
cls.root = Path(cls.temporary.name)
cls.runtime = cls.root / "runtime"
cls.runtime.mkdir(mode=0o700)
cls.config = cls.root / "config/ferese"
cls.config.mkdir(parents=True)
(cls.config / "config.kdl").write_text('status { keybinding-guide #false; }\npanel main { style morph; end { group tasks { item activities kind=activities; }; }; }\nactivities { sources { shell-hook #true; }; }\n')
cls.env = dict(os.environ, XDG_RUNTIME_DIR=str(cls.runtime), XDG_CONFIG_HOME=str(cls.root / "config"), XDG_STATE_HOME=str(cls.root / "state"), XDG_CACHE_HOME=str(cls.root / "cache"), LIBGL_ALWAYS_SOFTWARE="1")
cls.env.pop("FERESE_SOCKET", None)
cls.env.pop("WAYLAND_SOCKET", None)
for key in ("FERESE_SHELL_CONTROL_SOCKET", "DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_PID"):
cls.env.pop(key, None)
cls.log = (cls.root / "native.log").open("w+")
cls.children = []
cls.addClassCleanup(cls.cleanup)
cls.ctl = ROOT / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
if not host:
cls.launch([os.environ.get("FERESE_TEST_WESTON", "weston"), "--backend=headless-backend.so", "--renderer=pixman", "--shell=kiosk-shell.so", "--socket=activity-host", "--idle-time=0", "--no-config"])
cls.wait(lambda: (cls.runtime / "activity-host").is_socket())
host = str(cls.runtime / "activity-host")
cls.env["WAYLAND_DISPLAY"] = host
bus_config = cls.root / "bus.conf"
bus_config.write_text('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>'
'<policy context="default"><allow send_destination="*"/>'
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
bus = subprocess.Popen(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
env=cls.env, stdout=subprocess.PIPE, stderr=cls.log, text=True, start_new_session=True)
cls.children.append(bus)
cls.env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
bus.stdout.close()
binary = ROOT / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
shell = ROOT / os.environ.get("FERESE_TEST_SHELL", "target/debug/ferese-shell")
cls.launch([str(binary), "--backend", "nested", "--grant-shell-control", "--grant-effects", "--",
"env", "ICED_BACKEND=tiny-skia", "WAYLAND_DEBUG=client", str(shell)])
cls.wait(lambda: ipc_socket(cls.runtime).is_socket())
cls.env = ipc_environment(cls.env)
cls.wait(lambda: cls.query("outputs"))
cls.wait(lambda: '"ferese-shell-top-bar"' in (cls.root / "native.log").read_text())
@classmethod
def launch(cls, args, **kwargs):
child = subprocess.Popen(args, env=cls.env, stdout=cls.log, stderr=cls.log, start_new_session=True, **kwargs)
cls.children.append(child)
return child
@classmethod
def wait(cls, predicate, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(.025)
cls.log.flush()
raise AssertionError((cls.root / "native.log").read_text()[-8000:])
@classmethod
def call(cls, *args, check=True):
result = subprocess.run([str(cls.ctl), *args], env=cls.env, text=True, capture_output=True, timeout=5)
if check and result.returncode:
raise AssertionError(result.stderr)
return result
@classmethod
def query(cls, *args):
result = cls.call("--json", *args, check=False)
return json.loads(result.stdout) if result.returncode == 0 else None
@classmethod
def cleanup(cls):
for child in reversed(cls.children):
if child.poll() is None:
os.killpg(child.pid, signal.SIGINT)
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
if not cls.log.closed:
cls.log.close()
if (destination := os.environ.get("FERESE_TEST_LOG_DIR")) and (cls.root / "native.log").exists():
Path(destination).mkdir(parents=True, exist_ok=True)
shutil.copyfile(cls.root / "native.log", Path(destination) / "activities-native.log")
cls.temporary.cleanup()
def test_live_watch_and_native_bar_render_progress_without_resizing(self):
def bar():
trace = (self.root / "native.log").read_text()
matches = re.findall(r'get_layer_surface\(new id zwlr_layer_surface_v1[@#](\d+), wl_surface[@#](\d+)[^\n]*"ferese-shell-top-bar"', trace)
if not matches:
return None
layer, surface = matches[-1]
sizes = re.findall(r'zwlr_layer_surface_v1[@#]' + layer + r'\.set_size\((\d+), (\d+)\)', trace)
frames = len(re.findall(r'wl_surface[@#]' + surface + r'\.frame\(', trace))
return (tuple(map(int, sizes[-1])), frames) if sizes else None
self.call("activity", "start", "render", "--title", "Native Cargo build", "--progress", "0.2")
try:
self.wait(lambda: any(entry["id"]["local"] == "render" for entry in self.query("activity", "list")["activities"]))
time.sleep(1.2) # Eligibility delay and shell morph settlement.
before = self.wait(bar)
snapshot = self.query("activity", "list")
with socket.socket(socket.AF_UNIX) as watch:
watch.settimeout(5)
watch.connect(str(ipc_socket(self.runtime)))
request = json.dumps({"version": 1, "id": 77, "type": "command", "command": "activity-watch", "args": {"since": snapshot["revision"]}}).encode()
watch.sendall(struct.pack(">I", len(request)) + request)
self.call("activity", "update", "render", "--progress", "0.8")
def read_exact(count):
data = b""
while len(data) < count:
part = watch.recv(count - len(data))
self.assertTrue(part, "watch disconnected")
data += part
return data
response = json.loads(read_exact(struct.unpack(">I", read_exact(4))[0]))
self.assertEqual(response["id"], 77)
current = next(entry for entry in response["result"]["activities"] if entry["id"]["local"] == "render")
self.assertAlmostEqual(current["data"]["value"]["value"], .8)
after = self.wait(lambda: (value := bar()) and value[1] > before[1] and value)
self.assertEqual(after[0], before[0], "progress should not resize the island")
self.assertTrue(all(child.poll() is None for child in self.children))
finally:
self.call("activity", "cancel", "render", check=False)
def test_owner_scope_quota_terminal_stale_handles_and_atomic_reload(self):
for local in ("one", "two", "three"):
self.call("activity", "start", local, "--title", "Build", "--progress", "0.2")
failed = self.call("activity", "start", "four", "--title", "Build", check=False)
self.assertNotEqual(failed.returncode, 0)
snapshot = self.query("activity", "list")
self.assertEqual(len(snapshot["activities"]), 3)
handle = next(entry["id"] for entry in snapshot["activities"] if entry["id"]["local"] == "one")
other = subprocess.run(["sh", "-c", '"$1" activity update one --progress 0.8; status=$?; exit "$status"', "sh", str(self.ctl)], env=self.env, capture_output=True)
self.assertNotEqual(other.returncode, 0, "another caller must not update this owner's ID")
stale = dict(handle, generation=handle["generation"] + 10)
self.assertNotEqual(self.call("activity", "update", "one", "--handle", json.dumps(stale), "--progress", "0.8", check=False).returncode, 0)
self.call("activity", "cancel", "two")
self.call("activity", "cancel", "three")
time.sleep(.35)
self.call("activity", "end", "one", "--result", "ok")
entry = self.query("activity", "list")["activities"][0]
deadline = entry["result_deadline"]
self.call("activity", "end", "one", "--result", "ok")
self.assertEqual(self.query("activity", "list")["activities"][0]["result_deadline"], deadline)
(self.config / "config.kdl").write_text("activities { max-visible 9; }\n")
self.assertNotEqual(self.call("reload-config", check=False).returncode, 0)
self.assertEqual(self.query("activity", "list")["limits"]["max_visible"], 2)
self.wait(lambda: not self.query("activity", "list")["activities"])
def test_hook_nonmatches_are_discarded_and_owner_death_is_not_completion(self):
self.call("activity", "hook", "nonmatch", "--command", "private unmatched value", "--cwd", "/private/cwd")
self.assertNotIn("private", json.dumps(self.query("activity", "list")))
wrapper = subprocess.Popen([str(self.ctl), "run", "--title", "Observed", "--", "sleep", "30"], env=self.env, start_new_session=True)
try:
self.wait(lambda: any(entry["data"]["title"] == "Observed" for entry in self.query("activity", "list")["activities"]))
os.killpg(wrapper.pid, signal.SIGKILL)
wrapper.wait()
self.wait(lambda: not any(entry["data"]["title"] == "Observed" for entry in self.query("activity", "list")["activities"]))
finally:
if wrapper.poll() is None:
os.killpg(wrapper.pid, signal.SIGKILL)
wrapper.wait()
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
"""Hook composition/status checks use a fake publisher and never run metadata."""
from pathlib import Path
import fcntl
import json
import os
import pty
import select
import shutil
import subprocess
import tempfile
import termios
import time
import unittest
ROOT = Path(__file__).resolve().parents[2]
class ActivityHooks(unittest.TestCase):
def test_real_interactive_commands_publish_start_and_exit_status(self):
for shell in ('bash', 'fish'):
if not shutil.which(shell):
continue
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
publisher = root / 'feresectl'
publisher.write_text('#!/usr/bin/env python3\nimport json, os, sys\n'
'with open(os.environ["ACTIVITY_TEST_LOG"], "a") as f:\n'
' f.write(json.dumps(sys.argv[1:]) + "\\n")\n')
publisher.chmod(0o755)
log = root / 'calls'
startup = root / 'startup'
dependency = f'source "{ROOT}/contrib/activities/bash-preexec/bash-preexec.sh"\n' if shell == 'bash' else ''
startup.write_text(dependency + f'source "{ROOT}/contrib/activities/ferese.{shell}"\n')
master, slave = pty.openpty()
env = dict(os.environ, HOME=directory, XDG_CONFIG_HOME=directory, HISTFILE='/dev/null',
PATH=str(root) + ':' + os.environ['PATH'], ACTIVITY_TEST_LOG=str(log), TERM='dumb', PS1='ACTIVITY_PROMPT> ')
command = (['bash', '--noprofile', '--rcfile', str(startup), '-i'] if shell == 'bash' else
['fish', '--no-config', '-i', '--init-command', f'function fish_prompt; printf "ACTIVITY_PROMPT> "; end; source {startup}'])
process = subprocess.Popen(command, env=env, stdin=slave, stdout=slave, stderr=slave,
start_new_session=True, preexec_fn=lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0))
os.close(slave)
trace = b''
def wait_for(predicate):
nonlocal trace
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if select.select([master], [], [], .05)[0]:
trace += os.read(master, 65536)
if predicate():
return
self.fail(trace.decode(errors='replace'))
def calls():
return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
try:
# Wait for the first prompt before sending an interactive command.
wait_for(lambda: b'# ' in trace or b'$ ' in trace or b'> ' in trace)
os.write(master, b'false\n')
wait_for(lambda: any(call[:2] == ['activity', 'end'] for call in calls()))
records = calls()
start = next(call for call in records if call[:2] == ['activity', 'hook'])
end = next(call for call in records if call[:2] == ['activity', 'end'])
self.assertEqual(start[start.index('--command') + 1], 'false')
self.assertEqual(end[2], start[2])
self.assertEqual(end[end.index('--result') + 1], 'fail')
finally:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
os.close(master)
@unittest.skipUnless(shutil.which("fish"), "fish unavailable")
def test_fish_preserves_status_and_does_not_execute_metadata(self):
script = f'''function feresectl; return 71; end
source "{ROOT}/contrib/activities/ferese.fish"
__ferese_preexec 'echo SHOULD_NOT_EXECUTE'
false
__ferese_postexec
set saved $status
printf 'status=%s\\n' $saved
'''
result = subprocess.run(["fish", "--no-config", "-i", "-c", script], capture_output=True, text=True, timeout=10)
self.assertIn("status=1", result.stdout)
self.assertNotIn("SHOULD_NOT_EXECUTE", result.stdout)
def test_bash_preserves_status_and_never_executes_captured_command(self):
script = f'''
feresectl() {{ return 71; }}
source "{ROOT}/contrib/activities/bash-preexec/bash-preexec.sh"
source "{ROOT}/contrib/activities/ferese.bash"
_ferese_arm
_ferese_preexec 'echo SHOULD_NOT_EXECUTE'
false
_ferese_finish
saved=$?
printf 'status=%s\\n' "$saved"
'''
result = subprocess.run(["bash", "--norc", "-i", "-c", script], capture_output=True, text=True, timeout=10)
self.assertIn("status=1", result.stdout)
self.assertNotIn("SHOULD_NOT_EXECUTE", result.stdout)
def test_bash_preserves_existing_debug_trap(self):
script = f'''trap ':' DEBUG
before=$(trap -p DEBUG)
source "{ROOT}/contrib/activities/ferese.bash"
after=$(trap -p DEBUG)
[[ "$before" == "$after" ]]
'''
result = subprocess.run(["bash", "--norc", "-i", "-c", script], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
@unittest.skipUnless(shutil.which("zsh"), "zsh unavailable")
def test_zsh_composes_hooks_and_preserves_status(self):
script = f'''feresectl() {{ return 71; }}
existing() {{ :; }}
autoload -Uz add-zsh-hook
add-zsh-hook precmd existing
source "{ROOT}/contrib/activities/ferese.zsh"
(( ${{precmd_functions[(Ie)existing]}} > 0 )) || exit 4
_ferese_preexec 'echo SHOULD_NOT_EXECUTE'
false
_ferese_precmd
saved=$?
printf 'status=%s\\n' "$saved"
'''
result = subprocess.run(["zsh", "-f", "-i", "-c", script], capture_output=True, text=True, timeout=10)
self.assertIn("status=1", result.stdout)
self.assertNotIn("SHOULD_NOT_EXECUTE", result.stdout)
if __name__ == "__main__":
unittest.main()
+47 -1
View File
@@ -517,6 +517,52 @@ class FrontendTest(unittest.TestCase):
class UserSetupTest(unittest.TestCase): class UserSetupTest(unittest.TestCase):
def test_activity_setup_detects_shell_and_preserves_later_config_edits(self):
for shell in ('fish', 'bash', 'zsh'):
with self.subTest(shell=shell), tempfile.TemporaryDirectory(prefix="ferese user's ") as directory:
home = Path(directory)
config = home / 'custom config'
startup = (config / 'fish/conf.d/ferese-activities.fish' if shell == 'fish' else
home / ('.bashrc' if shell == 'bash' else 'zdir/.zshrc'))
startup.parent.mkdir(parents=True, exist_ok=True)
if shell != 'fish':
original = '# My existing shell configuration\nexport MY_VALUE=kept\n'
real = home / 'dotfile'
real.write_text(original)
startup.symlink_to(real)
env = dict(HOME=str(home), SHELL='/usr/bin/' + shell, ZDOTDIR=str(home / 'zdir'))
with mock.patch.dict(os.environ, env), mock.patch.object(installer.subprocess, 'run') as run:
installer.setup_activities(config, ctl=Path('/test/feresectl'), assets=REPO / 'contrib/activities')
first = startup.read_text()
installer.setup_activities(config, ctl=Path('/test/feresectl'), assets=REPO / 'contrib/activities')
self.assertEqual(run.call_count, 1, 'later installs must not reset edited config')
self.assertEqual(startup.read_text(), first)
self.assertTrue((config / f'ferese/activities/ferese.{shell}').exists())
if shell != 'fish':
self.assertTrue(first.startswith(original))
self.assertTrue(startup.is_symlink())
self.assertEqual(len(list(home.glob('dotfile.before-activities-*'))), 1)
if shutil.which(shell):
check = subprocess.run([shell, '-n', str(startup)], capture_output=True)
self.assertEqual(check.returncode, 0, check.stderr)
if shell == 'bash':
check = subprocess.run(['bash', '--rcfile', str(startup), '-i', '-c',
'[[ ${preexec_functions[*]} == *_ferese_preexec* ]]'],
env=dict(os.environ, **env), capture_output=True)
self.assertEqual(check.returncode, 0, check.stderr)
def test_failed_activity_configuration_can_be_retried(self):
with tempfile.TemporaryDirectory() as directory:
config = Path(directory)
with mock.patch.dict(os.environ, SHELL='/usr/bin/fish'), mock.patch.object(installer.subprocess, 'run') as run:
run.side_effect = subprocess.CalledProcessError(1, 'feresectl')
with self.assertRaises(subprocess.CalledProcessError):
installer.setup_activities(config, assets=REPO / 'contrib/activities')
self.assertFalse((config / 'ferese/activities/installed.json').exists())
run.side_effect = None
installer.setup_activities(config, assets=REPO / 'contrib/activities')
self.assertTrue((config / 'ferese/activities/installed.json').exists())
def test_repeatable_migration_and_reload_after_failure(self): def test_repeatable_migration_and_reload_after_failure(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
home = Path(directory) home = Path(directory)
@@ -530,7 +576,7 @@ class UserSetupTest(unittest.TestCase):
systemctl.chmod(0o755) systemctl.chmod(0o755)
env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / '.config'), PATH=str(bin_dir)) env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / '.config'), PATH=str(bin_dir))
# User setup intentionally refuses root; emulate the user identity in root-run CI. # User setup intentionally refuses root; emulate the user identity in root-run CI.
command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup'] command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup', '--no-activities']
result = subprocess.run(command, env=env, capture_output=True) result = subprocess.run(command, env=env, capture_output=True)
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertFalse(override.exists()) self.assertFalse(override.exists())
+1 -1
View File
@@ -34,7 +34,7 @@ impl Appearance {
let mut theme = ferese_theme_client::service::current().presented; let mut theme = ferese_theme_client::service::current().presented;
if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path)) if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
&& let Some(document) = &doc && let Some(document) = &doc
&& let Ok(mut connection) = ferese_ipc::theme::Connection::connect() && let Ok(mut connection) = ferese_ipc::Connection::connect()
{ {
let args = serde_json::json!({ let args = serde_json::json!({
"source": document.to_string(), "source": document.to_string(),
+1 -1
View File
@@ -43,7 +43,7 @@ pub struct Display {
} }
pub fn load() -> Result<Vec<Display>, String> { pub fn load() -> Result<Vec<Display>, String> {
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|error| error.to_string())?; let mut connection = ferese_ipc::Connection::connect().map_err(|error| error.to_string())?;
let value = connection.call("get-outputs", json!({}))?; let value = connection.call("get-outputs", json!({}))?;
let values = value.as_array().ok_or("Invalid display response")?; let values = value.as_array().ok_or("Invalid display response")?;
Ok(values Ok(values
+1 -1
View File
@@ -800,7 +800,7 @@ impl cosmic::Application for App {
suffix += 1; suffix += 1;
} }
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?; snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?; let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
let value = connection.call( let value = connection.call(
"theme-preview", "theme-preview",
serde_json::json!({"source": snapshot.source, "directory": directory}), serde_json::json!({"source": snapshot.source, "directory": directory}),
+53 -1
View File
@@ -34,13 +34,14 @@ impl std::fmt::Display for StyleChoice {
} }
} }
const CATALOG: [CatalogItem; 12] = [ const CATALOG: [CatalogItem; 13] = [
CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces { CatalogItem(ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::Dots, style: ferese_panel::WorkspaceStyle::Dots,
}), }),
CatalogItem(ItemKind::FocusedWindow), CatalogItem(ItemKind::FocusedWindow),
CatalogItem(ItemKind::Media), CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::Activities),
CatalogItem(ItemKind::QuickSettings), CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio), CatalogItem(ItemKind::Audio),
@@ -197,7 +198,45 @@ impl App {
.into(), .into(),
), ),
); );
let maximum = self.draft.number("activities.max_visible", 2.) as u64;
let choices = widget::row([1_u64, 2].into_iter().map(|count| {
widget::button::custom(self.label(count.to_string(), 12.))
.name(format!("Show up to {count} activities"))
.padding([8, 10])
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::panel_button(palette, count == maximum))
.on_press_maybe(
(count != maximum)
.then(|| Message::Change(store::set("activities.max_visible", count))),
)
.into()
}))
.spacing(2);
appearance = appearance.push(self.panel_divider()).push(
self.panel_appearance_row(
"Visible activities",
"Maximum activities in the collapsed and peek island.",
widget::container(choices)
.padding(3)
.class(crate::visuals::surface(palette.sidebar, 9.))
.into(),
),
);
} }
let show_badge = self.draft.boolean("activities.show_badge", true);
appearance = appearance.push(self.panel_divider()).push(
self.panel_appearance_row(
"Show activity count",
"Show +n for additional activities.",
widget::container(
ferese_theme::controls::switch(show_badge, palette)
.name("Show activity count")
.on_press(Message::Change(store::set("activities.show_badge", !show_badge))),
)
.align_right(cosmic::iced::Length::Fill)
.into(),
),
);
rows = rows.push( rows = rows.push(
widget::container(appearance) widget::container(appearance)
.id("panel-appearance-settings") .id("panel-appearance-settings")
@@ -327,6 +366,18 @@ impl App {
fn panel_opacity_controls(&self) -> Element<'static, Message> { fn panel_opacity_controls(&self) -> Element<'static, Message> {
use cosmic::iced::{Alignment, Length}; use cosmic::iced::{Alignment, Length};
if self.draft.boolean("theme.accessibility.reduce_transparency", false) {
return self.panel_appearance_row(
"Background opacity",
"Reduce transparency is on. Turn it off in Accessibility to change opacity.",
self.settings_button(
"Accessibility",
"M9 18l6-6-6-6",
Some(Message::Page(Page::Accessibility)),
false,
),
);
}
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented); let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
let tokens = &self.resolved.presented.tokens; let tokens = &self.resolved.presented.tokens;
let inherited = if tokens.material.style == "translucent" { let inherited = if tokens.material.style == "translucent" {
@@ -809,6 +860,7 @@ pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::ic
ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY, ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA, ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER, ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL, ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Audio => icons::VOLUME_HIGH,
+56 -13
View File
@@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame};
use crate::{App, Element, Message, panel_controls, visuals}; use crate::{App, Element, Message, panel_controls, visuals};
const SAMPLE_AVAILABILITY: Availability = Availability { const SAMPLE_AVAILABILITY: Availability = Availability {
activities: true,
media: true, media: true,
network: true, network: true,
audio: true, audio: true,
@@ -19,6 +20,18 @@ fn available(item: &Item) -> bool {
item.available(SAMPLE_AVAILABILITY) item.available(SAMPLE_AVAILABILITY)
} }
fn panel_background(theme: &ferese_config::theme::ResolvedTheme, panel: &Panel) -> cosmic::iced::Color {
let fill = if panel.style == ferese_panel::PanelStyle::Morph {
&theme.tokens.colors.surface_raised
} else {
&theme.tokens.surface.bar.background
};
cosmic::iced::Color {
a: theme.panel_opacity(panel.background_opacity),
..visuals::color(fill, cosmic::iced::Color::TRANSPARENT)
}
}
impl App { impl App {
pub(super) fn panel_preview(&self) -> Element<'_, Message> { pub(super) fn panel_preview(&self) -> Element<'_, Message> {
let panel = match self.preview_panel() { let panel = match self.preview_panel() {
@@ -115,14 +128,7 @@ impl App {
groups: Vec::new(), groups: Vec::new(),
scale: self.render_scale, scale: self.render_scale,
high_contrast: palette.high_contrast, high_contrast: palette.high_contrast,
background: cosmic::iced::Color { background: panel_background(&self.resolved.presented, &panel),
a: if self.resolved.presented.accessibility.reduce_transparency {
1.
} else {
panel.background_opacity.unwrap_or(palette.card.a)
},
..palette.card
},
border: if panel.border { border: if panel.border {
visuals::color( visuals::color(
&self.resolved.presented.tokens.colors.border, &self.resolved.presented.tokens.colors.border,
@@ -194,7 +200,7 @@ impl App {
ferese_panel::shape::ZonePosition::End, ferese_panel::shape::ZonePosition::End,
))) )))
.collect(); .collect();
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity); let background = panel_background(&self.resolved.presented, &panel);
let height = panel.resolved_geometry().height; let height = panel.resolved_geometry().height;
let padding = panel.resolved_geometry().inner_padding; let padding = panel.resolved_geometry().inner_padding;
let border = panel.border; let border = panel.border;
@@ -265,10 +271,7 @@ impl App {
groups, groups,
scale: self.render_scale, scale: self.render_scale,
high_contrast: palette.high_contrast, high_contrast: palette.high_contrast,
background: cosmic::iced::Color { background,
a: opacity,
..palette.card
},
border: if border { border: if border {
visuals::color( visuals::color(
&self.resolved.presented.tokens.colors.border, &self.resolved.presented.tokens.colors.border,
@@ -367,6 +370,7 @@ impl App {
ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY, ItemKind::FocusedWindow => icons::DISPLAY,
ItemKind::Media => icons::MEDIA, ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER, ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::Network => icons::WIFI_FULL, ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Audio => icons::VOLUME_HIGH,
@@ -380,6 +384,14 @@ impl App {
let label = match (item.kind, representation) { let label = match (item.kind, representation) {
(ItemKind::Workspaces { .. }, _) => "1 2 3", (ItemKind::Workspaces { .. }, _) => "1 2 3",
(ItemKind::FocusedWindow, _) => "Settings", (ItemKind::FocusedWindow, _) => "Settings",
(ItemKind::Activities, Representation::Wide) => {
if self.draft.boolean("activities.show_badge", true) {
"Build · 62% +1"
} else {
"Build · 62%"
}
}
(ItemKind::Activities, Representation::Compact) => "Build",
(ItemKind::Media, Representation::Wide) => "Song title · Artist ▷", (ItemKind::Media, Representation::Wide) => "Song title · Artist ▷",
(ItemKind::Media, Representation::Compact) => "Song title", (ItemKind::Media, Representation::Compact) => "Song title",
(ItemKind::Battery { percentage: true }, Representation::Wide) => "75%", (ItemKind::Battery { percentage: true }, Representation::Wide) => "75%",
@@ -478,6 +490,37 @@ mod tests {
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget}; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget};
use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size}; use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size};
#[test]
fn preview_uses_bar_tokens_and_inherited_opacity_for_every_available_style() {
let mut theme = ferese_config::theme::default_theme();
theme.tokens.colors.surface_raised = "#112233".into();
theme.tokens.surface.bar.background = "#abcdef".into();
theme.tokens.material.style = "translucent".into();
theme.tokens.material.opacity = 0.35;
for style in ferese_panel::PanelStyle::AVAILABLE {
let mut panel = Panel::from_preset(style);
let expected = if style == ferese_panel::PanelStyle::Morph {
cosmic::iced::Color::from_rgb8(0x11, 0x22, 0x33)
} else {
cosmic::iced::Color::from_rgb8(0xab, 0xcd, 0xef)
};
for (override_opacity, opacity) in [(None, 0.35), (Some(0.), 0.), (Some(0.7), 0.7)] {
panel.background_opacity = override_opacity;
for reduce in [false, true] {
theme.accessibility.reduce_transparency = reduce;
assert_eq!(
panel_background(&theme, &panel),
cosmic::iced::Color {
a: if reduce { 1. } else { opacity },
..expected
},
"{style:?}, opacity={override_opacity:?}, reduce={reduce}",
);
}
}
}
}
#[derive(Default)] #[derive(Default)]
struct Bounds { struct Bounds {
targets: Vec<(widget::Id, ItemId)>, targets: Vec<(widget::Id, ItemId)>,
+27
View File
@@ -232,6 +232,33 @@ pub fn reload_running() -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn activity_display_settings_roundtrip_without_changing_rules() {
for source in [
"",
"// keep this rule\nactivities { rule cargo { match \"^cargo\"; title Custom; }; }\n",
] {
let mut snapshot = Snapshot::parse(source.into()).unwrap();
let rules = snapshot.doc.value()["activities"]["rules"].clone();
snapshot.edit(&set("activities.max_visible", 1)).unwrap();
snapshot.edit(&set("activities.show_badge", false)).unwrap();
let loaded = Snapshot::parse(snapshot.source.clone()).unwrap();
let settings: ferese_config::activities::Settings =
serde_json::from_value(loaded.doc.value()["activities"].clone()).unwrap();
assert_eq!(settings.limits().max_visible, 1);
assert!(!settings.limits().show_badge);
if !source.is_empty() {
assert_eq!(loaded.doc.value()["activities"]["rules"], rules);
assert!(loaded.source.contains("// keep this rule"));
}
snapshot.edit(&set("activities.max_visible", 2)).unwrap();
snapshot.edit(&set("activities.show_badge", true)).unwrap();
let loaded = Snapshot::parse(snapshot.source).unwrap();
assert_eq!(loaded.number("activities.max_visible", 0.), 2.);
assert!(loaded.boolean("activities.show_badge", false));
}
}
#[test] #[test]
fn edits_preserve_comments_custom_keys_and_other_rules() { fn edits_preserve_comments_custom_keys_and_other_rules() {
let source = r#"// personal theme let source = r#"// personal theme
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-activities = { path = "../../crates/ferese-activities" }
ferese-layout = { path = "../../crates/ferese-layout" } ferese-layout = { path = "../../crates/ferese-layout" }
ferese-panel = { path = "../../crates/ferese-panel" } ferese-panel = { path = "../../crates/ferese-panel" }
ferese-animation = { path = "../../crates/ferese-animation" } ferese-animation = { path = "../../crates/ferese-animation" }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,88 @@
use std::collections::BTreeMap;
use cosmic::font::Font;
use cosmic::iced::advanced::text::{self, Paragraph};
use ferese_activities::{Activity, Id, Selection};
pub(super) const PADDING: f32 = 14.;
pub(super) const ICON: f32 = 26.;
pub(super) const TEXT_GAP: f32 = 6.;
pub(super) const ITEM_GAP: f32 = 8.;
#[derive(Default)]
pub(super) struct Compact {
font: Option<Font>,
titles: BTreeMap<Id, (String, f32)>,
badge: Option<(usize, f32)>,
text_scale: f32,
pub width: f32,
}
impl Compact {
pub fn update(&mut self, entries: &[&Activity], selection: &Selection, font: Font, available: f32) {
if self.font != Some(font) {
self.font = Some(font);
self.titles.clear();
self.badge = None;
}
self.titles.retain(|id, _| selection.visible.contains(id));
for entry in entries.iter().filter(|entry| selection.visible.contains(&entry.id)) {
let title = &entry.data.title;
if self.titles.get(&entry.id).is_none_or(|(cached, _)| cached != title) {
self.titles.insert(
entry.id.clone(),
(title.clone(), text_width(title, 12., font).ceil().clamp(48., 100.)),
);
}
}
let hidden = selection.hidden;
if self.badge.is_none_or(|(count, _)| count != hidden) {
self.badge = Some((
hidden,
if hidden > 0 {
text_width(&format!("+{hidden}"), 11., font).ceil()
} else {
0.
},
));
}
let count = self.titles.len();
let fixed = 2. * PADDING
+ count as f32 * (ICON + TEXT_GAP)
+ count.saturating_sub(1) as f32 * (2. * ITEM_GAP + 1.)
+ if hidden > 0 {
ITEM_GAP + self.badge.unwrap().1
} else {
0.
};
let text: f32 = self.titles.values().map(|(_, width)| width).sum();
self.text_scale = if text > 0. {
((available - fixed).max(0.) / text).min(1.)
} else {
1.
};
self.width = (fixed + text * self.text_scale).ceil();
}
pub fn text_width(&self, id: &Id) -> f32 {
self.titles.get(id).map_or(48., |(_, width)| width * self.text_scale)
}
}
// Use the same font and shaping as the displayed labels; measure only when a
// title, font or badge count changes. Progress and countdown ticks do not resize.
fn text_width(content: &str, size: f32, font: Font) -> f32 {
<cosmic::Renderer as text::Renderer>::Paragraph::with_text(text::Text {
content,
bounds: cosmic::iced::Size::INFINITE,
size: size.into(),
line_height: text::LineHeight::Relative(1.1),
font,
align_x: Default::default(),
align_y: cosmic::iced::alignment::Vertical::Top,
shaping: Default::default(),
wrapping: text::Wrapping::None,
ellipsize: Default::default(),
})
.min_width()
}
+162
View File
@@ -0,0 +1,162 @@
//! A small ring using Iced's native canvas and Ferese's shared spring. The
//! stock progress widget owns an unconditional easing loop, so this version
//! exposes reduced-motion and visibility policy explicitly.
use crate::Message;
use cosmic::iced::widget::{Action, Canvas, canvas};
use cosmic::iced::{Color, Point, Radians, Rectangle, mouse, window};
use cosmic::{Element, Theme};
use ferese_animation::{AnimatedValue, SpringConfig};
use std::time::{Duration, Instant};
pub(super) fn view<'a>(
progress: Option<f32>,
animate: bool,
reduced: bool,
color: Color,
) -> Element<'a, cosmic::Action<Message>> {
Canvas::new(Ring {
progress,
animate,
reduced,
color,
})
.width(26)
.height(26)
.into()
}
struct Ring {
progress: Option<f32>,
animate: bool,
reduced: bool,
color: Color,
}
pub(crate) struct RingState {
value: AnimatedValue,
sampled: Option<Instant>,
elapsed: Duration,
}
impl Default for RingState {
fn default() -> Self {
Self {
value: AnimatedValue::new(0.),
sampled: None,
elapsed: Duration::ZERO,
}
}
}
impl RingState {
fn advance(&mut self, progress: Option<f32>, animate: bool, reduced: bool, now: Instant) -> bool {
let elapsed = self
.sampled
.map_or(Duration::ZERO, |before| now.saturating_duration_since(before));
let initial = self.sampled.is_none();
self.sampled = Some(now);
self.elapsed = self.elapsed.saturating_add(elapsed);
self.value.retarget_preserving_motion(f64::from(progress.unwrap_or(0.)));
if initial || reduced || !animate {
self.value.snap();
} else {
self.value.advance(
elapsed,
SpringConfig {
stiffness: 700.,
damping: 45.,
position_tolerance: 0.002,
velocity_tolerance: 0.002,
..Default::default()
},
);
}
animate && !reduced && (progress.is_none() || self.value.is_animating())
}
}
impl canvas::Program<cosmic::Action<Message>, Theme, cosmic::Renderer> for Ring {
type State = RingState;
fn update(
&self,
state: &mut RingState,
event: &canvas::Event,
_bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Option<Action<cosmic::Action<Message>>> {
if let canvas::Event::Window(window::Event::RedrawRequested(now)) = event
&& state.advance(self.progress, self.animate, self.reduced, *now)
{
return Some(Action::request_redraw());
}
None
}
fn draw(
&self,
state: &RingState,
renderer: &cosmic::Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<cosmic::Renderer>> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let center = Point::new(bounds.width / 2., bounds.height / 2.);
let radius = bounds.width.min(bounds.height) / 2. - 3.;
frame.stroke(
&canvas::Path::circle(center, radius),
canvas::Stroke::default()
.with_color(self.color.scale_alpha(0.15))
.with_width(1.8),
);
let (start, fraction) = if self.progress.is_some() {
(
-std::f32::consts::FRAC_PI_2,
if state.sampled.is_none() {
self.progress.unwrap_or(0.)
} else {
state.value.current as f32
}
.clamp(0., 1.),
)
} else if self.animate && !self.reduced {
(state.elapsed.as_secs_f32() / 3. * std::f32::consts::TAU, 0.23)
} else {
(0., 0.)
};
if fraction > 0. {
let arc = canvas::Path::new(|path| {
path.arc(canvas::path::Arc {
center,
radius,
start_angle: Radians(start),
end_angle: Radians(start + fraction * std::f32::consts::TAU),
})
});
frame.stroke(
&arc,
canvas::Stroke::default()
.with_color(self.color)
.with_width(1.8)
.with_line_cap(canvas::LineCap::Round),
);
}
vec![frame.into_geometry()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_stops_when_settled_hidden_or_reduced_and_keeps_interrupted_velocity() {
let now = Instant::now();
let mut state = RingState::default();
assert!(!state.advance(Some(0.1), true, false, now));
assert!(state.advance(Some(0.6), true, false, now + Duration::from_millis(50)));
let velocity = state.value.velocity;
state.value.retarget_preserving_motion(0.9);
assert_eq!(state.value.velocity, velocity);
for frame in 1..=200 {
state.advance(Some(0.9), true, false, now + Duration::from_millis(50 + frame * 16));
}
assert!(!state.value.is_animating());
assert!(!state.advance(None, false, false, now + Duration::from_secs(5)));
assert!(!state.advance(Some(0.2), true, true, now + Duration::from_secs(6)));
assert_eq!(state.value.current, 0.2_f32 as f64);
assert!(state.advance(None, true, false, now + Duration::from_secs(7)));
}
}
@@ -0,0 +1,62 @@
use std::sync::Arc;
use std::time::Duration;
use cosmic::iced::Subscription;
use cosmic::iced::futures::SinkExt;
use ferese_ipc::activities::Snapshot;
use serde_json::json;
pub(crate) fn subscription() -> Subscription<Arc<Snapshot>> {
Subscription::run(stream)
}
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
cosmic::iced::stream::channel(1, async |mut output| {
loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
};
let Ok(cancellation) = connection.cancellation() else {
return;
};
let (send, mut receive) = tokio::sync::watch::channel(None);
tokio::task::spawn_blocking(move || {
let Ok(value) = connection.call("activity-get", json!({})) else {
return;
};
let Ok(mut snapshot) = serde_json::from_value::<Snapshot>(value) else {
return;
};
loop {
let revision = snapshot.revision;
if send.send(Some(Arc::new(snapshot))).is_err() {
return;
}
match connection
.wait("activity-watch", json!({"since":revision}))
.and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
{
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while receive.changed().await.is_ok() {
let snapshot = receive.borrow_and_update().clone();
if let Some(snapshot) = snapshot
&& output.send(snapshot).await.is_err()
{
return;
}
}
drop(cancellation);
if output.send(Arc::new(Snapshot::default())).await.is_err() {
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
})
}
+4
View File
@@ -54,6 +54,7 @@ impl FereseShell {
let effects = output.and_then(|output| output.effects.as_ref()); let effects = output.and_then(|output| output.effects.as_ref());
let compositor_material = effects.is_some(); let compositor_material = effects.is_some();
let availability = Availability { let availability = Availability {
activities: self.activity_available(id),
media: self.media.snapshot.selected.is_some(), media: self.media.snapshot.selected.is_some(),
network: self.status.network.is_some(), network: self.status.network.is_some(),
audio: self.status.audio.is_some(), audio: self.status.audio.is_some(),
@@ -340,6 +341,9 @@ impl FereseShell {
.into(); .into();
return (container(element).max_width(360).into(), !title.is_empty()); return (container(element).max_width(360).into(), !title.is_empty());
} }
ItemKind::Activities => {
return self.view_activity_item(id, item, representation, selected, width, control_height);
}
ItemKind::Media => self.view_media_item(representation, selected, width), ItemKind::Media => self.view_media_item(representation, selected, width),
ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected), ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected),
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected), ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
+5
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig, pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) animations: crate::motion::Settings, pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>, pub(crate) font_family: Option<String>,
@@ -22,6 +23,7 @@ impl Default for ShellConfig {
fn default() -> Self { fn default() -> Self {
let status = StatusConfig::default(); let status = StatusConfig::default();
Self { Self {
activities: Default::default(),
notifications: Default::default(), notifications: Default::default(),
animations: Default::default(), animations: Default::default(),
font_family: None, font_family: None,
@@ -183,6 +185,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct FereseConfig { struct FereseConfig {
#[serde(default)]
activities: ferese_config::activities::Settings,
#[serde(default)] #[serde(default)]
panels: Option<Vec<crate::panel::Panel>>, panels: Option<Vec<crate::panel::Panel>>,
#[serde(default)] #[serde(default)]
@@ -398,6 +402,7 @@ fn parse_document(
theme.bar_radius = theme.material_radius; theme.bar_radius = theme.material_radius;
Ok(ShellConfig { Ok(ShellConfig {
activities: config.activities,
notifications: config.notifications, notifications: config.notifications,
animations: config.animations, animations: config.animations,
font_family: config.theme.typography.font_family, font_family: config.theme.typography.font_family,
-4
View File
@@ -211,10 +211,6 @@ impl ShellControl {
let _ = self.connection.flush(); let _ = self.connection.flush();
} }
#[cfg_attr(
not(test),
expect(dead_code, reason = "Window-targeting API; the current UI targets workspaces.")
)]
pub(crate) fn activate_window(&self, id: u64) { pub(crate) fn activate_window(&self, id: u64) {
let (hi, lo) = split_id(id); let (hi, lo) = split_id(id);
self.shell.activate_window(hi, lo); self.shell.activate_window(hi, lo);
+53 -10
View File
@@ -1,3 +1,4 @@
mod activities;
mod bar; mod bar;
mod surfaces; mod surfaces;
@@ -161,6 +162,7 @@ struct FereseShell {
status: status::Snapshot, status: status::Snapshot,
recorder: recording::Recorder, recorder: recording::Recorder,
media: media::Model, media: media::Model,
activities: activities::Model,
calendar_offset: i32, calendar_offset: i32,
status_error: Option<status::ActionError>, status_error: Option<status::ActionError>,
theme_error: Option<String>, theme_error: Option<String>,
@@ -219,6 +221,15 @@ enum Message {
MediaAction(serde_json::Value), MediaAction(serde_json::Value),
MediaCompleted(Result<(), String>), MediaCompleted(Result<(), String>),
MediaTick, MediaTick,
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
ActivityTick,
ActivityHover(window::Id, bool),
ActivityActivate(window::Id, ferese_activities::Id),
ActivityNavigation(
window::Id,
Option<ferese_activities::Id>,
Option<cosmic::iced::keyboard::key::Named>,
),
MediaChoose, MediaChoose,
MediaSeek(u64), MediaSeek(u64),
MediaSeekCommit, MediaSeekCommit,
@@ -294,14 +305,17 @@ impl cosmic::Application for FereseShell {
// The compositor reports the complete configuration error through its // The compositor reports the complete configuration error through its
// accepted-theme snapshot. Use the local warning only when standalone. // accepted-theme snapshot. Use the local warning only when standalone.
let pending_config_error = if control.is_none() { startup_error } else { None }; let pending_config_error = if control.is_none() { startup_error } else { None };
let mut notification_center = notifications::Center::new(config.notifications.clone(), config.animations);
notification_center.activity_bridge = config.activities.sources.notification_bridge;
let app = Self { let app = Self {
core, core,
notifications: notifications::Center::new(config.notifications.clone(), config.animations), notifications: notification_center,
notification_surface: None, notification_surface: None,
status_service: status::Service::start(config.status.settings_command.clone()), status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(), status: status::Snapshot::default(),
recorder: recording::Recorder::default(), recorder: recording::Recorder::default(),
media: media::Model::default(), media: media::Model::default(),
activities: activities::Model::default(),
calendar_offset: 0, calendar_offset: 0,
status_error: None, status_error: None,
theme_error: None, theme_error: None,
@@ -366,6 +380,8 @@ impl cosmic::Application for FereseShell {
_ => None, _ => None,
}), }),
clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged), clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged),
activities::subscription::subscription().map(Message::ActivitiesUpdated),
self.activity_tick_subscription().map(|_| Message::ActivityTick),
self.media.subscription().map(Message::MediaUpdated), self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick), self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt), self.media.art.subscription().map(Message::MediaArt),
@@ -437,6 +453,19 @@ impl cosmic::Application for FereseShell {
) )
} }
Message::ActivitiesUpdated(snapshot) => {
self.activities.snapshot = snapshot;
self.sync_hub_activity()
}
Message::ActivityTick => self.sync_hub_activity(),
Message::ActivityHover(id, entered) => {
self.activity_hover(id, entered);
self.sync_hub_activity()
}
Message::ActivityActivate(id, activity) => self.activate_activity(id, activity),
Message::ActivityNavigation(id, focused, key) => {
self.activity_navigation(id, focused, key).unwrap_or_else(Task::none)
}
Message::MediaUpdated(snapshot) => { Message::MediaUpdated(snapshot) => {
self.media.receive(snapshot); self.media.receive(snapshot);
self.refresh_media_art( self.refresh_media_art(
@@ -488,7 +517,7 @@ impl cosmic::Application for FereseShell {
} }
Message::ThemeMode(mode) => cosmic::task::future(async move { Message::ThemeMode(mode) => cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?; let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
connection connection
.call("theme-set-mode", serde_json::json!({"mode": mode})) .call("theme-set-mode", serde_json::json!({"mode": mode}))
.map(|_| ()) .map(|_| ())
@@ -578,24 +607,35 @@ impl cosmic::Application for FereseShell {
dnd: self.notifications.dnd, dnd: self.notifications.dnd,
}); });
} }
let hub = notice.map_or_else(Task::none, |notice| self.hub_notice(notice)); let hub = notice
Task::batch([hub, self.sync_hub_notices(), self.sync_notification_surface()]) .filter(|_| !self.config.activities.sources.notification_bridge)
.map_or_else(Task::none, |notice| self.hub_notice(notice));
Task::batch([
hub,
self.sync_hub_activity(),
self.sync_hub_notices(),
self.sync_notification_surface(),
])
} }
Message::NotificationTick => { Message::NotificationTick => {
self.notifications.tick(); self.notifications.tick();
Task::batch([self.sync_hub_notices(), self.sync_notification_surface()]) Task::batch([
self.sync_hub_activity(),
self.sync_hub_notices(),
self.sync_notification_surface(),
])
} }
Message::DismissNotification(id) => { Message::DismissNotification(id) => {
self.notifications.close(id, 2); self.notifications.close(id, 2);
self.sync_notification_surface() Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
} }
Message::RemoveNotification(id) => { Message::RemoveNotification(id) => {
self.notifications.dismiss(id); self.notifications.dismiss(id);
self.sync_notification_surface() Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
} }
Message::InvokeNotification(id, action) => { Message::InvokeNotification(id, action) => {
self.notifications.invoke(id, action); self.notifications.invoke(id, action);
self.sync_notification_surface() Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
} }
Message::HoverNotification(id, hovered) => { Message::HoverNotification(id, hovered) => {
self.notifications.hover(id, hovered); self.notifications.hover(id, hovered);
@@ -608,11 +648,11 @@ impl cosmic::Application for FereseShell {
} }
Message::RemoveNotificationGroup(app) => { Message::RemoveNotificationGroup(app) => {
self.notifications.dismiss_group(&app); self.notifications.dismiss_group(&app);
self.sync_notification_surface() Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
} }
Message::ClearNotifications => { Message::ClearNotifications => {
self.notifications.clear(); self.notifications.clear();
self.sync_notification_surface() Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
} }
Message::AnimateMenu => self.animate_menu(), Message::AnimateMenu => self.animate_menu(),
Message::PanelAction(message) => { Message::PanelAction(message) => {
@@ -956,6 +996,8 @@ impl FereseShell {
let geometry = config.panels[0].resolved_geometry(); let geometry = config.panels[0].resolved_geometry();
let geometry_changed = edge_changed || old_geometry != geometry; let geometry_changed = edge_changed || old_geometry != geometry;
self.notifications.configure(config.notifications.clone()); self.notifications.configure(config.notifications.clone());
self.notifications
.configure_activity_bridge(config.activities.sources.notification_bridge);
self.config = config; self.config = config;
if composition_changed || geometry_changed { if composition_changed || geometry_changed {
for entry in &mut self.outputs { for entry in &mut self.outputs {
@@ -1098,6 +1140,7 @@ mod tests {
status: Default::default(), status: Default::default(),
recorder: Default::default(), recorder: Default::default(),
media: Default::default(), media: Default::default(),
activities: Default::default(),
calendar_offset: 0, calendar_offset: 0,
status_error: None, status_error: None,
theme_error: None, theme_error: None,
+1 -1
View File
@@ -106,7 +106,7 @@ impl Model {
self.error = None; self.error = None;
cosmic::task::future(async move { cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?; let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
connection.call("media-action", args).map(|_| ()) connection.call("media-action", args).map(|_| ())
}) })
.await .await
+2 -2
View File
@@ -13,7 +13,7 @@ pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> { fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
cosmic::iced::stream::channel(1, async |mut output| { cosmic::iced::stream::channel(1, async |mut output| {
loop { loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await; let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else { let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
continue; continue;
@@ -93,7 +93,7 @@ mod tests {
return; return;
} }
let path = ferese_ipc::theme::socket_path().unwrap(); let path = ferese_ipc::socket_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let listener = UnixListener::bind(path).unwrap(); let listener = UnixListener::bind(path).unwrap();
let (watch_send, watch_receive) = std::sync::mpsc::channel(); let (watch_send, watch_receive) = std::sync::mpsc::channel();
+76 -3
View File
@@ -269,6 +269,7 @@ struct Toast {
} }
pub struct Center { pub struct Center {
pub activity_bridge: bool,
config: ferese_config::notifications::NotificationConfig, config: ferese_config::notifications::NotificationConfig,
service: Service, service: Service,
pub ready: bool, pub ready: bool,
@@ -285,6 +286,7 @@ pub struct Center {
impl Center { impl Center {
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self { pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
Self { Self {
activity_bridge: false,
service: Service::start(&config), service: Service::start(&config),
ready: false, ready: false,
dnd: config.do_not_disturb, dnd: config.do_not_disturb,
@@ -306,6 +308,12 @@ impl Center {
.try_send(Command::Local(title.into(), body.into())); .try_send(Command::Local(title.into(), body.into()));
} }
pub fn configure_activity_bridge(&mut self, enabled: bool) {
self.activity_bridge = enabled;
if enabled {
self.toasts.clear();
}
}
pub fn configure(&mut self, config: ferese_config::notifications::NotificationConfig) { pub fn configure(&mut self, config: ferese_config::notifications::NotificationConfig) {
if self.config.do_not_disturb != config.do_not_disturb { if self.config.do_not_disturb != config.do_not_disturb {
self.dnd = config.do_not_disturb; self.dnd = config.do_not_disturb;
@@ -516,7 +524,7 @@ impl Center {
} }
} }
notice.unread = !self.history_open; notice.unread = !self.history_open;
let show = self.config.show_popups && (!self.dnd || notice.critical); let show = !self.activity_bridge && self.config.show_popups && (!self.dnd || notice.critical);
if show { if show {
self.toasts.push_back(Toast { self.toasts.push_back(Toast {
id: notice.id, id: notice.id,
@@ -553,7 +561,7 @@ impl Center {
} }
self.toasts.retain(|toast| !ids.contains(&toast.id)); self.toasts.retain(|toast| !ids.contains(&toast.id));
} }
if !show { if !show && !self.activity_bridge {
self.close(id, 1); self.close(id, 1);
} }
} }
@@ -562,11 +570,21 @@ impl Center {
cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream) cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream)
} }
// Notices presented by Activities still own their timeout, even without a toast.
fn hidden_deadlines(&self) -> impl Iterator<Item = (u32, Instant)> + '_ {
self.entries.iter().filter_map(|notice| {
(notice.live && !self.toasts.iter().any(|toast| toast.id == notice.id))
.then(|| notice.timeout.map(|timeout| (notice.id, notice.received_at + timeout)))
.flatten()
})
}
fn expiry_deadline(&self) -> Option<Instant> { fn expiry_deadline(&self) -> Option<Instant> {
self.toasts self.toasts
.iter() .iter()
.filter(|toast| !toast.hovered && toast.closing.is_none()) .filter(|toast| !toast.hovered && toast.closing.is_none())
.filter_map(|toast| toast.remaining.map(|remaining| self.last_tick + remaining)) .filter_map(|toast| toast.remaining.map(|remaining| self.last_tick + remaining))
.chain(self.hidden_deadlines().map(|(_, deadline)| deadline))
.min() .min()
} }
@@ -610,7 +628,10 @@ impl Center {
let now = Instant::now(); let now = Instant::now();
let delta = now.saturating_duration_since(self.last_tick); let delta = now.saturating_duration_since(self.last_tick);
self.last_tick = now; self.last_tick = now;
let mut expired = Vec::new(); let mut expired: Vec<_> = self
.hidden_deadlines()
.filter_map(|(id, deadline)| (deadline <= now).then_some(id))
.collect();
for toast in &mut self.toasts { for toast in &mut self.toasts {
if toast.closing.is_none() if toast.closing.is_none()
&& !toast.hovered && !toast.hovered
@@ -643,6 +664,7 @@ mod tests {
let (_, events) = tokio::sync::mpsc::channel(256); let (_, events) = tokio::sync::mpsc::channel(256);
let (commands, receiver) = mpsc::sync_channel(256); let (commands, receiver) = mpsc::sync_channel(256);
let center = Center { let center = Center {
activity_bridge: false,
config: NotificationConfig::default(), config: NotificationConfig::default(),
motion: Default::default(), motion: Default::default(),
service: Service { service: Service {
@@ -681,6 +703,57 @@ mod tests {
} }
} }
#[test]
fn bridge_creates_an_activity_and_removes_it_on_expiry_and_dismissal() {
use cosmic::Application;
let mut app = crate::tests::shell_with_measured_panel();
app.snapshot.outputs.push(crate::control::OutputSnapshot {
id: 4,
name: "test".into(),
active_workspace: 1,
focused: true,
});
let (center, _commands) = fixture();
app.notifications = center;
app.config.activities.sources.notification_bridge = true;
app.notifications.configure_activity_bridge(true);
let bar = app.outputs[0].bar;
let mut multilingual = notice(1);
multilingual.title = "通知🛠️".repeat(100);
multilingual.app = "アプリ".repeat(100);
drop(app.update(crate::Message::NotificationEvent(Event::Notice(multilingual))));
assert!(app.activity_available(bar));
assert!(app.notifications.toasts.is_empty());
app.notifications.entries[0].received_at = Instant::now() - Duration::from_secs(7);
drop(app.update(crate::Message::NotificationTick));
assert!(!app.activity_available(bar));
drop(app.update(crate::Message::NotificationEvent(Event::Notice(notice(2)))));
assert!(app.activity_available(bar));
drop(app.update(crate::Message::DismissNotification(2)));
assert!(!app.activity_available(bar));
}
#[test]
fn bridged_notice_stays_live_without_toast_until_timeout_or_dismissal() {
let (mut center, commands) = fixture();
center.configure_activity_bridge(true);
center.receive(notice(1));
assert!(center.toasts.is_empty());
assert!(center.entries[0].live && center.entries[0].unread);
assert!(commands.try_recv().is_err());
assert!(center.expiry_deadline().is_some());
center.entries[0].received_at = Instant::now() - Duration::from_secs(7);
center.tick();
assert!(!center.entries[0].live);
assert!(matches!(commands.try_recv(), Ok(Command::Close(1, 1, 1))));
let mut persistent = notice(2);
persistent.timeout = None;
center.receive(persistent);
assert!(center.expiry_deadline().is_none());
center.dismiss(2);
assert!(matches!(commands.try_recv(), Ok(Command::Close(2, 1, 2))));
}
#[test] #[test]
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() { fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
let (mut center, _) = fixture(); let (mut center, _) = fixture();
File diff suppressed because it is too large. Load diff
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more