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]>
No files matched your search
@@ -1740,6 +1740,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"async-channel",
|
||||
"calloop 0.14.4",
|
||||
"ferese-activities",
|
||||
"ferese-animation",
|
||||
"ferese-config",
|
||||
"ferese-core",
|
||||
@@ -1765,6 +1766,13 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ferese-activities"
|
||||
version = "0.1.0-beta"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ferese-animation"
|
||||
version = "0.1.0-beta"
|
||||
@@ -1777,10 +1785,12 @@ dependencies = [
|
||||
name = "ferese-config"
|
||||
version = "0.1.0-beta"
|
||||
dependencies = [
|
||||
"ferese-activities",
|
||||
"ferese-panel",
|
||||
"ferese-theme-model",
|
||||
"jiff",
|
||||
"kdl",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -1798,6 +1808,7 @@ dependencies = [
|
||||
name = "ferese-ipc"
|
||||
version = "0.1.0-beta"
|
||||
dependencies = [
|
||||
"ferese-activities",
|
||||
"ferese-theme-model",
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -1900,6 +1911,7 @@ name = "ferese-shell"
|
||||
version = "0.1.0-beta"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ferese-activities",
|
||||
"ferese-animation",
|
||||
"ferese-config",
|
||||
"ferese-ipc",
|
||||
@@ -1965,6 +1977,7 @@ name = "feresectl"
|
||||
version = "0.1.0-beta"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ferese-activities",
|
||||
"ferese-config",
|
||||
"ferese-ipc",
|
||||
"libc",
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"compositor",
|
||||
"feresectl",
|
||||
"crates/ferese-animation",
|
||||
"crates/ferese-activities",
|
||||
"crates/ferese-core",
|
||||
"crates/ferese-config",
|
||||
"crates/ferese-theme",
|
||||
|
||||
@@ -22,6 +22,7 @@ Ferese combines a window manager and desktop shell with one configuration.
|
||||
- Scrolling columns, window groups, tree tiling and floating windows with saved size and position
|
||||
- Integrated shell with notifications, media controls, Settings and Control Center
|
||||
- [Custom panels](docs/panel-configuration.md) with seven styles, editable groups and items, and top or bottom placement
|
||||
- [Live Activities](docs/activities.md) for build status, running commands and progress from connected apps
|
||||
- Workspace overview and recent-window switching
|
||||
- [Continuum animations](docs/configuration.md#animations): interruptible springs, gesture momentum and shared motion controls
|
||||
- Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-activities = { path = "../crates/ferese-activities" }
|
||||
ferese-panel = { path = "../crates/ferese-panel" }
|
||||
ferese-shape.workspace = true
|
||||
ferese-config = { path = "../crates/ferese-config" }
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
//! Session broker: verified process identities, one-shot policy deadlines and
|
||||
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
|
||||
mod publication;
|
||||
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
|
||||
use ferese_ipc::{
|
||||
Response,
|
||||
activities::{Publish, Snapshot},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rules {
|
||||
pub enabled: bool,
|
||||
pub matcher: ferese_config::activities::Matcher,
|
||||
}
|
||||
pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
|
||||
|
||||
pub(crate) struct Engine {
|
||||
pub store: Store,
|
||||
pub rules: SharedRules,
|
||||
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
|
||||
process_enabled: bool,
|
||||
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
|
||||
process_watcher: Option<crate::activities_process::Watcher>,
|
||||
pub timer: Option<crate::ipc::DeadlineTimer<crate::Ferese>>,
|
||||
revision: u64,
|
||||
cached_snapshot: Option<Arc<Snapshot>>,
|
||||
publisher: Option<publication::Publisher>,
|
||||
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
|
||||
rates: BTreeMap<Owner, (u64, u16)>,
|
||||
windows: BTreeMap<Id, u64>,
|
||||
results: BTreeMap<Id, ferese_config::activities::ResultText>,
|
||||
}
|
||||
impl Default for Engine {
|
||||
fn default() -> Self {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
Self {
|
||||
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
|
||||
rules: Arc::new(RwLock::new(Arc::new(Rules {
|
||||
enabled: false,
|
||||
matcher: ferese_config::activities::Matcher::new(&settings).expect("built-in rules"),
|
||||
}))),
|
||||
monitor: None,
|
||||
process_enabled: false,
|
||||
process_sender: None,
|
||||
process_watcher: None,
|
||||
timer: None,
|
||||
revision: 1,
|
||||
cached_snapshot: None,
|
||||
publisher: publication::Publisher::new()
|
||||
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
|
||||
.ok(),
|
||||
watchers: BTreeMap::new(),
|
||||
rates: BTreeMap::new(),
|
||||
results: BTreeMap::new(),
|
||||
windows: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Engine {
|
||||
pub fn detect_process(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
matched: ferese_config::activities::Matched,
|
||||
output: Option<String>,
|
||||
fd: &std::os::fd::OwnedFd,
|
||||
) {
|
||||
if crate::ipc::activity_owner::exited(fd) {
|
||||
return;
|
||||
}
|
||||
// A dispatched detection is not an admission acknowledgement. Retry
|
||||
// starts after rejection, and refresh existing records without restarting
|
||||
// their eligibility delay or recency boost.
|
||||
if self.resolve(owner, "process", None).is_ok() {
|
||||
let _ = self.publish(
|
||||
owner,
|
||||
Publish::Update {
|
||||
local: "process".into(),
|
||||
handle: None,
|
||||
value: None,
|
||||
lease_ms: Some(5_000),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
let _ = self.publish(
|
||||
owner,
|
||||
Publish::Hook {
|
||||
local: "process".into(),
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
Some(matched),
|
||||
output,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(
|
||||
&mut self,
|
||||
settings: &ferese_config::activities::Settings,
|
||||
matcher: ferese_config::activities::Matcher,
|
||||
) {
|
||||
self.store.limits = settings.limits();
|
||||
self.process_enabled = settings.sources.process_watch;
|
||||
*self.rules.write().expect("activity rules") = Arc::new(Rules {
|
||||
enabled: settings.sources.shell_hook,
|
||||
matcher,
|
||||
});
|
||||
self.refresh_process_source();
|
||||
self.changed();
|
||||
}
|
||||
pub fn attach_process_source(
|
||||
&mut self,
|
||||
sender: smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>,
|
||||
) {
|
||||
self.process_sender = Some(sender);
|
||||
self.refresh_process_source();
|
||||
}
|
||||
fn refresh_process_source(&mut self) {
|
||||
if !self.process_enabled {
|
||||
self.process_watcher = None;
|
||||
} else if self.process_watcher.is_none()
|
||||
&& let Some(sender) = &self.process_sender
|
||||
{
|
||||
match crate::activities_process::Watcher::start(self.rules.clone(), sender.clone()) {
|
||||
Ok(watcher) => self.process_watcher = Some(watcher),
|
||||
Err(error) => tracing::warn!(%error, "could not start optional activity process source"),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn snapshot(&mut self) -> Arc<Snapshot> {
|
||||
if let Some(snapshot) = &self.cached_snapshot {
|
||||
return snapshot.clone();
|
||||
}
|
||||
let snapshot = Arc::new(Snapshot {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
revision: self.revision,
|
||||
now_ms: ferese_ipc::activities::now_ms(),
|
||||
limits: self.store.limits,
|
||||
activities: self.store.entries().cloned().collect(),
|
||||
windows: self
|
||||
.windows
|
||||
.iter()
|
||||
.map(|(activity, window)| ferese_ipc::activities::ActivityWindow {
|
||||
activity: activity.clone(),
|
||||
window: *window,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
self.cached_snapshot = Some(snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
pub fn remove_connection(&mut self, owner: u64) {
|
||||
self.watchers.remove(&owner);
|
||||
}
|
||||
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
|
||||
if recipients.is_empty() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
if let Some(publisher) = &self.publisher {
|
||||
publisher.send(snapshot, recipients);
|
||||
} else {
|
||||
publication::unavailable(recipients);
|
||||
}
|
||||
}
|
||||
pub fn get(&mut self, request: u64, sender: SyncSender<Response>) {
|
||||
self.deliver(vec![(request, sender)]);
|
||||
}
|
||||
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
|
||||
if since != self.revision {
|
||||
self.get(request, sender);
|
||||
} else if self.watchers.len() < 32 {
|
||||
self.watchers.insert(connection, (request, sender));
|
||||
} else {
|
||||
let _ = sender.try_send(Response::error(
|
||||
request,
|
||||
"subscriber_limit",
|
||||
"Activity subscriber limit",
|
||||
));
|
||||
}
|
||||
}
|
||||
pub fn owner_exit(&mut self, owner: Owner) {
|
||||
self.store.owner_exit(owner);
|
||||
self.rates.remove(&owner);
|
||||
self.changed();
|
||||
}
|
||||
pub fn changed(&mut self) {
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.cached_snapshot = None;
|
||||
self.results
|
||||
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
|
||||
self.windows
|
||||
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
|
||||
let recipients = std::mem::take(&mut self.watchers).into_values().collect();
|
||||
self.deliver(recipients);
|
||||
self.schedule();
|
||||
}
|
||||
pub fn tick(&mut self) {
|
||||
self.store.advance(ferese_ipc::activities::now_ms());
|
||||
self.changed();
|
||||
}
|
||||
pub fn schedule(&self) {
|
||||
if let Some(monitor) = &self.monitor {
|
||||
monitor.retain(
|
||||
&self
|
||||
.store
|
||||
.entries()
|
||||
.filter(|entry| entry.state == State::Running)
|
||||
.map(|entry| entry.id.owner)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(timer) = &self.timer {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
timer.update(
|
||||
self.store
|
||||
.next_deadline(now)
|
||||
.map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(now))),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn publish_verified(
|
||||
&mut self,
|
||||
verified: crate::ipc::activity_owner::VerifiedOwner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
window: Option<u64>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Admission and publication run in one compositor dispatch. A queued
|
||||
// request owns its pidfd, but is not yet subject to monitor pruning.
|
||||
let owner = self
|
||||
.monitor
|
||||
.as_ref()
|
||||
.ok_or("Activity owner monitor unavailable")?
|
||||
.register(verified)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let result = self
|
||||
.publish_from(owner, publish, matched, output, window)
|
||||
.map_err(str::to_owned);
|
||||
self.schedule();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn publish(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
self.publish_from(owner, publish, matched, output, None)
|
||||
}
|
||||
|
||||
fn publish_from(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
window: Option<u64>,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
let started = match &publish {
|
||||
Publish::Start { local, .. } | Publish::Run { local, .. } | Publish::Hook { local, .. } => {
|
||||
Some(local.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if matches!(publish, Publish::Hook { .. }) && matched.is_none() {
|
||||
return Ok(json!({"matched":false}));
|
||||
}
|
||||
self.rates.retain(|_, (at, _)| now.saturating_sub(*at) < 60_000);
|
||||
if self.rates.len() >= 128 && !self.rates.contains_key(&owner) {
|
||||
return Err("owner publication quota exceeded");
|
||||
}
|
||||
let rate = self.rates.entry(owner).or_insert((now, 0));
|
||||
if now.saturating_sub(rate.0) >= 1_000 {
|
||||
*rate = (now, 0);
|
||||
}
|
||||
// Each connection waits for acknowledgement; excess replaceable updates
|
||||
// are explicitly rejected so terminal events retain capacity.
|
||||
if (matches!(publish, Publish::Update { .. }) && rate.1 >= 30)
|
||||
|| (matches!(
|
||||
publish,
|
||||
Publish::Start { .. } | Publish::Hook { .. } | Publish::Run { .. }
|
||||
) && rate.1 >= 10)
|
||||
{
|
||||
return Err("publication rate exceeded");
|
||||
}
|
||||
rate.1 = rate.1.saturating_add(1);
|
||||
// Expiration is a change even when the following publication is rejected.
|
||||
// Notify subscribers before a store mutation can silently sweep leases.
|
||||
if self.store.advance(now) {
|
||||
self.changed();
|
||||
}
|
||||
let result = match publish {
|
||||
Publish::Start {
|
||||
local,
|
||||
mut data,
|
||||
lease_ms,
|
||||
} => {
|
||||
if data.output.is_none() {
|
||||
data.output = output;
|
||||
}
|
||||
let id = self
|
||||
.store
|
||||
.start(owner, local, data, now, 0, lease_ms, true, true, None)?;
|
||||
json!({"handle":id})
|
||||
}
|
||||
Publish::Run { local, title, .. } => {
|
||||
if let Some(matched) = matched {
|
||||
let data = Data {
|
||||
source: if local == "process" {
|
||||
Source::Process
|
||||
} else {
|
||||
Source::Command
|
||||
},
|
||||
output,
|
||||
priority: matched.rule.priority,
|
||||
icon: matched.rule.icon.clone(),
|
||||
title: title.unwrap_or(matched.title),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let id = self.store.start(
|
||||
owner,
|
||||
local,
|
||||
data,
|
||||
now,
|
||||
matched.rule.min_duration_ms,
|
||||
None,
|
||||
true,
|
||||
matched.rule.flash_on_fail,
|
||||
matched.group,
|
||||
)?;
|
||||
self.results.insert(id.clone(), matched.rule.result);
|
||||
json!({"handle":id,"progress":matched.rule.progress})
|
||||
} else {
|
||||
let data = Data {
|
||||
source: Source::Command,
|
||||
output,
|
||||
priority: 100,
|
||||
icon: "ferese".into(),
|
||||
title: title.unwrap_or_else(|| "Command".into()),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let id = self.store.start(owner, local, data, now, 0, None, true, true, None)?;
|
||||
json!({"handle":id,"progress":"indeterminate"})
|
||||
}
|
||||
}
|
||||
Publish::Hook { local, .. } => {
|
||||
let Some(matched) = matched else {
|
||||
return Ok(json!({"matched":false}));
|
||||
};
|
||||
let data = Data {
|
||||
source: if local == "process" {
|
||||
Source::Process
|
||||
} else {
|
||||
Source::Command
|
||||
},
|
||||
output,
|
||||
priority: matched.rule.priority,
|
||||
icon: matched.rule.icon.clone(),
|
||||
title: matched.title,
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let lease = (local == "process").then_some(5_000);
|
||||
let id = self.store.start(
|
||||
owner,
|
||||
local,
|
||||
data,
|
||||
now,
|
||||
matched.rule.min_duration_ms,
|
||||
lease,
|
||||
true,
|
||||
matched.rule.flash_on_fail,
|
||||
matched.group,
|
||||
)?;
|
||||
self.results.insert(id.clone(), matched.rule.result);
|
||||
json!({"matched":true,"handle":id,"progress":matched.rule.progress})
|
||||
}
|
||||
Publish::Update {
|
||||
local,
|
||||
handle,
|
||||
value,
|
||||
lease_ms,
|
||||
} => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
self.store.update(owner, &id, value, lease_ms, now)?;
|
||||
json!({})
|
||||
}
|
||||
Publish::End {
|
||||
local,
|
||||
handle,
|
||||
result,
|
||||
message,
|
||||
} => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
let message = message.or_else(|| {
|
||||
self.results.get(&id).map(|text| {
|
||||
if result == State::Done {
|
||||
text.ok.clone()
|
||||
} else {
|
||||
text.fail.clone()
|
||||
}
|
||||
})
|
||||
});
|
||||
self.store.finish(owner, &id, result, message, now)?;
|
||||
json!({})
|
||||
}
|
||||
Publish::Cancel { local, handle } => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
self.store.finish(owner, &id, State::Cancelled, None, now)?;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
if let Some(local) = started
|
||||
&& let Some(id) = self.store.resolve(owner, &local)
|
||||
&& let Some(window) = window
|
||||
{
|
||||
self.windows.insert(id.clone(), window);
|
||||
}
|
||||
self.changed();
|
||||
Ok(result)
|
||||
}
|
||||
fn resolve(&self, owner: Owner, local: &str, handle: Option<Id>) -> Result<Id, &'static str> {
|
||||
match handle {
|
||||
Some(id) if id.local == local => Ok(id),
|
||||
Some(_) => Err("handle does not match local ID"),
|
||||
None => self
|
||||
.store
|
||||
.resolve(owner, local)
|
||||
.cloned()
|
||||
.ok_or("unknown owner-scoped activity"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Ferese {
|
||||
pub(crate) fn activity_window(&self, owner: &crate::ipc::activity_owner::VerifiedOwner) -> Option<u64> {
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
let mut ancestors = owner.ancestor_pids().peekable();
|
||||
ancestors.peek()?;
|
||||
let windows: Vec<_> = self
|
||||
.windows
|
||||
.records()
|
||||
.filter_map(|(id, record)| {
|
||||
let client = record.surface.as_ref()?.client()?;
|
||||
let pid = u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
|
||||
Some((pid, id.0))
|
||||
})
|
||||
.collect();
|
||||
origin_window(ancestors, &windows)
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_window(ancestors: impl Iterator<Item = u32>, windows: &[(u32, u64)]) -> Option<u64> {
|
||||
for pid in ancestors {
|
||||
let mut matches = windows.iter().filter(|(client, _)| *client == pid);
|
||||
if let Some((_, window)) = matches.next() {
|
||||
// A terminal server may own several windows. Never guess from focus.
|
||||
return matches.next().is_none().then_some(*window);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn owner() -> Owner {
|
||||
Owner { pid: 12, birth: 3 }
|
||||
}
|
||||
fn data() -> Data {
|
||||
Data {
|
||||
source: Source::Publisher,
|
||||
output: None,
|
||||
priority: 255,
|
||||
icon: "hammer".into(),
|
||||
title: "Build".into(),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
|
||||
let mut engine = Engine::default();
|
||||
let id = engine
|
||||
.store
|
||||
.start(
|
||||
owner(),
|
||||
"expired".into(),
|
||||
data(),
|
||||
ferese_ipc::activities::now_ms().saturating_sub(5_000),
|
||||
0,
|
||||
Some(1_000),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let before = engine.snapshot();
|
||||
assert_eq!(before.activities.len(), 1);
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 11, before.revision, sender);
|
||||
assert!(
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "expired".into(),
|
||||
handle: Some(id),
|
||||
value: None,
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(engine.snapshot().activities.is_empty());
|
||||
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let snapshot: Snapshot = serde_json::from_value(response.result.unwrap()).unwrap();
|
||||
assert!(snapshot.activities.is_empty());
|
||||
assert!(snapshot.revision > before.revision);
|
||||
assert_eq!(before.activities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_are_lazy_shared_and_immutable_across_publications() {
|
||||
let mut engine = Engine::default();
|
||||
engine.changed();
|
||||
assert!(engine.cached_snapshot.is_none(), "no subscriber needs a snapshot");
|
||||
let before = engine.snapshot();
|
||||
assert!(Arc::ptr_eq(&before, &engine.snapshot()));
|
||||
let (first, first_reply) = std::sync::mpsc::sync_channel(1);
|
||||
let (second, second_reply) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 11, before.revision, first);
|
||||
engine.watch(2, 22, before.revision, second);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Start {
|
||||
local: "build".into(),
|
||||
data: data(),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let after = engine.snapshot();
|
||||
assert!(!Arc::ptr_eq(&before, &after));
|
||||
assert!(before.activities.is_empty());
|
||||
assert_eq!(after.activities.len(), 1);
|
||||
for (id, reply) in [(11, first_reply), (22, second_reply)] {
|
||||
let reply = reply.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(reply.id, id);
|
||||
let snapshot: Snapshot = serde_json::from_value(reply.result.unwrap()).unwrap();
|
||||
assert_eq!(snapshot.revision, after.revision);
|
||||
assert_eq!(snapshot.activities[0].id, after.activities[0].id);
|
||||
}
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(3, 33, before.revision, sender);
|
||||
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap().id, 33);
|
||||
assert!(Arc::ptr_eq(&after, &engine.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_that_exits_before_detection_dispatch_is_not_published() {
|
||||
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
let (owner, fd, _) = crate::ipc::activity_owner::inspect(child.id()).unwrap();
|
||||
let matched = ferese_config::activities::Matcher::new(&Default::default())
|
||||
.unwrap()
|
||||
.matches("cargo test", "/tmp", &[])
|
||||
.unwrap();
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine.detect_process(owner, matched, None, &fd);
|
||||
assert_eq!(engine.store.entries().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_process_detection_is_retried_without_restarting_admitted_records() {
|
||||
let mut engine = Engine::default();
|
||||
let (owner, fd, _) = crate::ipc::activity_owner::inspect(std::process::id()).unwrap();
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
let matched = ferese_config::activities::Matcher::new(&settings)
|
||||
.unwrap()
|
||||
.matches("cargo test", "/tmp/test", &[])
|
||||
.unwrap();
|
||||
engine.store.limits.max_total = 0;
|
||||
engine.detect_process(owner, matched.clone(), None, &fd);
|
||||
assert_eq!(engine.store.entries().count(), 0);
|
||||
engine.store.limits.max_total = 128;
|
||||
engine.detect_process(owner, matched.clone(), None, &fd);
|
||||
let admitted = engine.store.entries().next().unwrap().clone();
|
||||
engine.detect_process(owner, matched, None, &fd);
|
||||
let refreshed = engine.store.entries().next().unwrap();
|
||||
assert_eq!(refreshed.id, admitted.id);
|
||||
assert_eq!(refreshed.eligible_at, admitted.eligible_at);
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_matching_uses_nearest_unique_ancestor_and_never_guesses() {
|
||||
let ancestry = [400, 300, 200, 100];
|
||||
assert_eq!(origin_window(ancestry.into_iter(), &[(200, 7), (100, 8)]), Some(7));
|
||||
assert_eq!(
|
||||
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
|
||||
None
|
||||
);
|
||||
assert_eq!(origin_window(ancestry.into_iter(), &[(900, 7)]), None);
|
||||
assert_eq!(origin_window([].into_iter(), &[(200, 7)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_window_survives_updates_and_results_then_expires() {
|
||||
let mut engine = Engine::default();
|
||||
engine.store.limits.start_delay_ms = 0;
|
||||
engine
|
||||
.publish_from(
|
||||
owner(),
|
||||
Publish::Run {
|
||||
local: "build".into(),
|
||||
title: Some("Build".into()),
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
Some(42),
|
||||
)
|
||||
.unwrap();
|
||||
let id = engine.store.entries().next().unwrap().id.clone();
|
||||
assert_eq!(engine.snapshot().windows[0].window, 42);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
value: Some(Value::Progress(0.5)),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(engine.snapshot().windows[0].window, 42);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::End {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
result: State::Done,
|
||||
message: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
engine.owner_exit(owner());
|
||||
assert_eq!(engine.snapshot().windows[0].activity, id);
|
||||
engine.store.advance(ferese_ipc::activities::now_ms() + 2_000);
|
||||
engine.changed();
|
||||
assert!(engine.snapshot().windows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_disconnect_does_not_own_lifetime_and_rate_limit_preserves_completion() {
|
||||
let mut engine = Engine::default();
|
||||
let result = engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Start {
|
||||
local: "build".into(),
|
||||
data: data(),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("first".into()),
|
||||
)
|
||||
.unwrap();
|
||||
let id: Id = serde_json::from_value(result["handle"].clone()).unwrap();
|
||||
engine.remove_connection(1);
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
for _ in 0..40 {
|
||||
let _ = engine.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
value: Some(Value::Progress(0.5)),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("other".into()),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
engine.store.entries().next().unwrap().data.output.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::End {
|
||||
local: "build".into(),
|
||||
handle: Some(id),
|
||||
result: State::Done,
|
||||
message: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
engine.store.entries().count(),
|
||||
0,
|
||||
"completion before delay never flashes"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rule_is_pinned_across_atomic_reload_and_no_raw_metadata_is_published() {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
let matched = ferese_config::activities::Matcher::new(&settings)
|
||||
.unwrap()
|
||||
.matches("cargo test --secret", "/secret/cwd", &[])
|
||||
.unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Run {
|
||||
local: "build".into(),
|
||||
title: None,
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
Some(matched),
|
||||
Some("first".into()),
|
||||
)
|
||||
.unwrap();
|
||||
let before = engine.store.entries().next().unwrap().clone();
|
||||
let mut replacement = settings.clone();
|
||||
replacement.start_delay_ms = 5_000;
|
||||
replacement.rules[0].title = "Replacement".into();
|
||||
engine.configure(
|
||||
&replacement,
|
||||
ferese_config::activities::Matcher::new(&replacement).unwrap(),
|
||||
);
|
||||
let after = engine.store.entries().next().unwrap();
|
||||
assert_eq!(after.data.title, "cargo test");
|
||||
assert_eq!(after.eligible_at, before.eligible_at);
|
||||
let serialized = serde_json::to_string(engine.snapshot().as_ref()).unwrap();
|
||||
assert!(!serialized.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Bounded serialization and delivery outside the compositor event loop.
|
||||
use ferese_ipc::{Response, activities::Snapshot};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
mpsc::{self, SyncSender},
|
||||
};
|
||||
|
||||
type Recipient = (u64, SyncSender<Response>);
|
||||
struct Job {
|
||||
snapshot: Arc<Snapshot>,
|
||||
recipients: Vec<Recipient>,
|
||||
}
|
||||
pub(super) struct Publisher(SyncSender<Job>);
|
||||
impl Publisher {
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
let (send, receive) = mpsc::sync_channel::<Job>(8);
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-activity-snapshots".into())
|
||||
.spawn(move || {
|
||||
let mut cached: Option<(Arc<Snapshot>, serde_json::Value)> = None;
|
||||
while let Ok(job) = receive.recv() {
|
||||
if !cached
|
||||
.as_ref()
|
||||
.is_some_and(|(snapshot, _)| Arc::ptr_eq(snapshot, &job.snapshot))
|
||||
{
|
||||
cached = Some((job.snapshot.clone(), serde_json::json!(job.snapshot.as_ref())));
|
||||
}
|
||||
let value = &cached.as_ref().unwrap().1;
|
||||
for (id, sender) in job.recipients {
|
||||
// Client queues are bounded too. A slow/disconnected client
|
||||
// cannot block serialization or another subscriber.
|
||||
let _ = sender.try_send(Response::success(id, value.clone()));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self(send))
|
||||
}
|
||||
|
||||
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
|
||||
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
|
||||
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
|
||||
unavailable(job.recipients);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn unavailable(recipients: Vec<Recipient>) {
|
||||
for (id, sender) in recipients {
|
||||
let _ = sender.try_send(Response::error(
|
||||
id,
|
||||
"activity_busy",
|
||||
"Activity snapshot delivery unavailable; retry",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
|
||||
let (send, _receive) = mpsc::sync_channel(0);
|
||||
let publisher = Publisher(send);
|
||||
let (reply, receive) = mpsc::sync_channel(1);
|
||||
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
|
||||
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(response.id, 7);
|
||||
assert_eq!(response.error.unwrap().code, "activity_busy");
|
||||
|
||||
let publisher = Publisher::new().unwrap();
|
||||
let (slow, _slow_receive) = mpsc::sync_channel(1);
|
||||
slow.send(Response::success(1, serde_json::json!({}))).unwrap();
|
||||
let (fast, fast_receive) = mpsc::sync_channel(1);
|
||||
publisher.send(Arc::new(Snapshot::default()), vec![(2, slow), (3, fast)]);
|
||||
assert_eq!(fast_receive.recv_timeout(Duration::from_secs(1)).unwrap().id, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Explicitly enabled fallback. Bounded /proc sampling and regex work stay on
|
||||
//! one worker; disappearance emits owner exit, never an inferred exit status.
|
||||
use crate::ipc::IpcEvent;
|
||||
use ferese_activities::Owner;
|
||||
use smithay::reexports::calloop::channel;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) struct Watcher(Arc<AtomicBool>);
|
||||
impl Drop for Watcher {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
impl Watcher {
|
||||
pub fn start(
|
||||
rules: crate::activities::SharedRules,
|
||||
sender: channel::SyncSender<IpcEvent>,
|
||||
) -> std::io::Result<Self> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let worker_stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-activity-processes".into())
|
||||
.spawn(move || {
|
||||
let mut observed =
|
||||
BTreeMap::<Owner, (Arc<std::os::fd::OwnedFd>, ferese_config::activities::Matched)>::new();
|
||||
while !worker_stop.load(Ordering::Acquire) {
|
||||
let rules = rules.read().expect("activity rules").clone();
|
||||
let dead: Vec<_> = observed
|
||||
.iter()
|
||||
.filter_map(|(owner, (fd, _))| crate::ipc::activity_owner::exited(fd).then_some(*owner))
|
||||
.collect();
|
||||
for owner in dead {
|
||||
observed.remove(&owner);
|
||||
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Reconcile every detection: admission may have failed or its lease expired.
|
||||
for (owner, (fd, matched)) in &observed {
|
||||
let _ = sender.try_send(IpcEvent::ActivityDetected(
|
||||
*owner,
|
||||
Box::new(matched.clone()),
|
||||
fd.clone(),
|
||||
));
|
||||
}
|
||||
if let Ok(processes) = std::fs::read_dir("/proc") {
|
||||
for process in processes.take(4_096).flatten() {
|
||||
if worker_stop.load(Ordering::Acquire) || observed.len() >= 128 {
|
||||
break;
|
||||
}
|
||||
let Some(pid) = process.file_name().to_str().and_then(|name| name.parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok((owner, fd, parents)) = crate::ipc::activity_owner::inspect(pid) else {
|
||||
continue;
|
||||
};
|
||||
if observed.contains_key(&owner) || parents.iter().any(|parent| parent == "feresectl") {
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(process.path().join("cmdline")) else {
|
||||
continue;
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
if file.take(4_097).read_to_end(&mut bytes).is_err() || bytes.len() > 4_096 {
|
||||
continue;
|
||||
}
|
||||
let Ok(command) = String::from_utf8(bytes) else {
|
||||
continue;
|
||||
};
|
||||
let command = command.trim_end_matches('\0').replace('\0', " ");
|
||||
let Ok(cwd) = std::fs::read_link(process.path().join("cwd")) else {
|
||||
continue;
|
||||
};
|
||||
let cwd = cwd.to_string_lossy();
|
||||
let Some(matched) = rules.matcher.matches(&command, &cwd, &parents) else {
|
||||
continue;
|
||||
};
|
||||
let fd = Arc::new(fd);
|
||||
// Keep the inspected process identity alive through dispatch.
|
||||
// No raw command, cwd or environment crosses the dispatcher.
|
||||
if sender
|
||||
.try_send(IpcEvent::ActivityDetected(owner, Box::new(matched.clone()), fd.clone()))
|
||||
.is_ok()
|
||||
{
|
||||
observed.insert(owner, (fd, matched));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
})?;
|
||||
Ok(Self(stop))
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ use crate::window_rules::{WindowRule, WindowRuleConfig};
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
activities: ferese_config::activities::Settings,
|
||||
#[serde(default)]
|
||||
panels: Option<Vec<ferese_panel::Panel>>,
|
||||
#[serde(default)]
|
||||
@@ -118,6 +120,7 @@ impl Config {
|
||||
if let Some(panels) = &self.panels {
|
||||
ferese_panel::validate(panels).map_err(ConfigError::Binding)?;
|
||||
}
|
||||
self.activities.validate().map_err(ConfigError::Binding)?;
|
||||
self.notifications.validate().map_err(ConfigError::Binding)?;
|
||||
self.xwayland.validate().map_err(ConfigError::Binding)?;
|
||||
for daemon in &self.autostart {
|
||||
@@ -128,6 +131,9 @@ impl Config {
|
||||
let input_settings = self.input_settings()?;
|
||||
let bindings = self.bindings(&input_settings)?;
|
||||
Ok(crate::RuntimeConfig {
|
||||
activity_settings: self.activities.clone(),
|
||||
activity_matcher: ferese_config::activities::Matcher::new(&self.activities)
|
||||
.map_err(ConfigError::Binding)?,
|
||||
lock_idle: self.lock_screen.validate()?,
|
||||
idle_inhibit: self.idle_inhibit,
|
||||
autostart: self.autostart.clone(),
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::{fs, io, thread};
|
||||
use ferese_core::LayoutMode;
|
||||
use ferese_ipc::{Request, Response, VERSION};
|
||||
|
||||
pub(crate) mod activity_owner;
|
||||
mod transport;
|
||||
use ferese_layout::Direction;
|
||||
use serde_json::{Value, json};
|
||||
@@ -40,8 +41,13 @@ const _: () = assert!(
|
||||
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IpcCall {
|
||||
pub(crate) struct IpcCall {
|
||||
native_portal: bool,
|
||||
activity: Option<(
|
||||
activity_owner::VerifiedOwner,
|
||||
ferese_ipc::activities::Publish,
|
||||
Option<ferese_config::activities::Matched>,
|
||||
)>,
|
||||
owner: u64,
|
||||
request: Request,
|
||||
response: SyncSender<Response>,
|
||||
@@ -49,9 +55,15 @@ struct IpcCall {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum IpcEvent {
|
||||
Call(IpcCall),
|
||||
pub(crate) enum IpcEvent {
|
||||
Call(Box<IpcCall>),
|
||||
Closed(u64),
|
||||
ActivityOwnerExited(ferese_activities::Owner),
|
||||
ActivityDetected(
|
||||
ferese_activities::Owner,
|
||||
Box<ferese_config::activities::Matched>,
|
||||
Arc<std::os::fd::OwnedFd>,
|
||||
),
|
||||
}
|
||||
|
||||
struct IpcConnection {
|
||||
@@ -149,8 +161,28 @@ pub(crate) fn init(
|
||||
event_loop.handle().insert_source(receiver, |event, _, state| {
|
||||
if let channel::Event::Msg(event) = event {
|
||||
let call = match event {
|
||||
IpcEvent::Call(call) => call,
|
||||
IpcEvent::Call(call) => *call,
|
||||
IpcEvent::ActivityDetected(owner, matched, fd) => {
|
||||
let output = state
|
||||
.outputs_json()
|
||||
.as_array()
|
||||
.and_then(|outputs| {
|
||||
outputs
|
||||
.iter()
|
||||
.find(|output| output["focused"] == true)
|
||||
.or_else(|| outputs.first())
|
||||
})
|
||||
.and_then(|output| output["name"].as_str())
|
||||
.map(str::to_owned);
|
||||
state.activities.detect_process(owner, *matched, output, &fd);
|
||||
return;
|
||||
}
|
||||
IpcEvent::ActivityOwnerExited(owner) => {
|
||||
state.activities.owner_exit(owner);
|
||||
return;
|
||||
}
|
||||
IpcEvent::Closed(owner) => {
|
||||
state.activities.remove_connection(owner);
|
||||
state.ipc_events.remove(owner);
|
||||
for request in state.screenshot.terminate_owner(owner) {
|
||||
state
|
||||
@@ -170,7 +202,67 @@ pub(crate) fn init(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if call.request.command == "event-stream" {
|
||||
if call.request.command == "activity-publish" {
|
||||
let result = validate_request(&call.request)
|
||||
.map_err(|error| error.message)
|
||||
.and_then(|()| {
|
||||
if state.session_lock.active() {
|
||||
return Err("Session locked".into());
|
||||
}
|
||||
let (owner, publish, matched) = call.activity.ok_or("Unverified activity owner")?;
|
||||
let output = state
|
||||
.outputs_json()
|
||||
.as_array()
|
||||
.and_then(|outputs| {
|
||||
outputs
|
||||
.iter()
|
||||
.find(|output| output["focused"] == true)
|
||||
.or_else(|| outputs.first())
|
||||
})
|
||||
.and_then(|output| output["name"].as_str())
|
||||
.map(str::to_owned);
|
||||
let window = state.activity_window(&owner);
|
||||
state
|
||||
.activities
|
||||
.publish_verified(owner, publish, matched, output, window)
|
||||
});
|
||||
state.activities.schedule();
|
||||
let response = match result {
|
||||
Ok(result) => Response::success(call.request.id, result),
|
||||
Err(error) => Response::error(call.request.id, "activity_rejected", error),
|
||||
};
|
||||
let _ = call.response.try_send(response);
|
||||
} else if call.request.command == "activity-get" {
|
||||
if state.session_lock.active() {
|
||||
let _ = call.response.try_send(Response::error(
|
||||
call.request.id,
|
||||
"session_locked",
|
||||
"IPC unavailable while session is locked",
|
||||
));
|
||||
} else if let Err(error) = validate_request(&call.request) {
|
||||
let _ = call
|
||||
.response
|
||||
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||
} else {
|
||||
state.activities.get(call.request.id, call.response);
|
||||
}
|
||||
} else if call.request.command == "activity-watch" {
|
||||
if let Err(error) = validate_request(&call.request) {
|
||||
let _ = call
|
||||
.response
|
||||
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||
} else if let Some(since) = call.request.args["since"].as_u64() {
|
||||
state
|
||||
.activities
|
||||
.watch(call.owner, call.request.id, since, call.response);
|
||||
} else {
|
||||
let _ = call.response.try_send(Response::error(
|
||||
call.request.id,
|
||||
"invalid_argument",
|
||||
"Missing activity revision",
|
||||
));
|
||||
}
|
||||
} else if call.request.command == "event-stream" {
|
||||
let result = validate_request(&call.request).and_then(|()| {
|
||||
if call.request.args["version"].as_u64() != Some(u64::from(ferese_ipc::events::VERSION)) {
|
||||
return Err(CommandError::new(
|
||||
@@ -317,10 +409,26 @@ pub(crate) fn init(
|
||||
})?;
|
||||
let worker = Worker::spawn(encoded);
|
||||
|
||||
let activity_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, _| state.activities.tick());
|
||||
let (rules_send, rules_receive) = sync_channel(1);
|
||||
let monitor = activity_owner::Monitor::new(sender.clone());
|
||||
let state_monitor = monitor.clone();
|
||||
let state_sender = sender.clone();
|
||||
event_loop.handle().insert_idle(move |state| {
|
||||
state.activities.timer = Some(activity_timer);
|
||||
state.activities.monitor = Some(state_monitor);
|
||||
state.activities.attach_process_source(state_sender);
|
||||
state.activities.schedule();
|
||||
let _ = rules_send.send(state.activities.rules.clone());
|
||||
});
|
||||
let signal = event_loop.get_signal();
|
||||
thread::Builder::new()
|
||||
.name("ferese-ipc-listener".to_owned())
|
||||
.spawn(move || accept_connections(listener, sender, signal))?;
|
||||
.spawn(move || {
|
||||
if let Ok(rules) = rules_receive.recv() {
|
||||
accept_connections(listener, sender, signal, rules, monitor);
|
||||
}
|
||||
})?;
|
||||
|
||||
// A client that dies between receiving a screenshot path and unlinking it
|
||||
// leaves the file behind, so staged files are reclaimed at startup and then
|
||||
@@ -415,7 +523,13 @@ impl<Data: 'static> Drop for DeadlineTimer<Data> {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
|
||||
fn accept_connections(
|
||||
listener: UnixListener,
|
||||
sender: channel::SyncSender<IpcEvent>,
|
||||
signal: LoopSignal,
|
||||
rules: crate::activities::SharedRules,
|
||||
monitor: activity_owner::Monitor,
|
||||
) {
|
||||
let active_connections = Arc::new(AtomicUsize::new(0));
|
||||
let connection_ids = AtomicU64::new(1);
|
||||
|
||||
@@ -448,11 +562,13 @@ fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEve
|
||||
let sender = sender.clone();
|
||||
let owner = connection_ids.fetch_add(1, Ordering::Relaxed);
|
||||
let signal = signal.clone();
|
||||
let monitor = monitor.clone();
|
||||
let rules = rules.clone();
|
||||
if let Err(error) = thread::Builder::new()
|
||||
.name("ferese-ipc-client".to_owned())
|
||||
.spawn(move || {
|
||||
let _permit = permit;
|
||||
serve_connection(stream, sender, signal, owner);
|
||||
serve_connection_with_activities(stream, sender, signal, owner, Some((monitor, rules)));
|
||||
})
|
||||
{
|
||||
tracing::warn!(%error, "could not start IPC connection worker");
|
||||
@@ -475,7 +591,17 @@ fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit>
|
||||
Some(ConnectionPermit { active: active.clone() })
|
||||
}
|
||||
|
||||
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal, owner: u64) {
|
||||
#[cfg(test)]
|
||||
fn serve_connection(stream: UnixStream, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal, owner: u64) {
|
||||
serve_connection_with_activities(stream, sender, signal, owner, None)
|
||||
}
|
||||
fn serve_connection_with_activities(
|
||||
mut stream: UnixStream,
|
||||
sender: channel::SyncSender<IpcEvent>,
|
||||
signal: LoopSignal,
|
||||
owner: u64,
|
||||
activity_host: Option<(activity_owner::Monitor, crate::activities::SharedRules)>,
|
||||
) {
|
||||
let native_portal = crate::handlers::window_capture::is_portal(&stream);
|
||||
let _connection = IpcConnection {
|
||||
owner,
|
||||
@@ -484,7 +610,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
|
||||
let mut idle = Some(transport::FRAME_TIMEOUT);
|
||||
let mut owns_lease = false;
|
||||
loop {
|
||||
let request: Request = match transport::read_request(&stream, idle) {
|
||||
let mut request: Request = match transport::read_request(&stream, idle) {
|
||||
Ok(request) => request,
|
||||
Err(ferese_ipc::FrameError::Io(error))
|
||||
if matches!(
|
||||
@@ -512,6 +638,61 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let activity = if request.command == "activity-publish" {
|
||||
let verified = (|| -> Result<_, String> {
|
||||
validate_request(&request).map_err(|error| error.message)?;
|
||||
let wire: ferese_ipc::activities::Request =
|
||||
serde_json::from_value(std::mem::take(&mut request.args)).map_err(|error| error.to_string())?;
|
||||
if wire.version != ferese_ipc::activities::VERSION {
|
||||
return Err("Unsupported activity schema".into());
|
||||
}
|
||||
let (monitor, rules) = activity_host.as_ref().ok_or("Activity host unavailable")?;
|
||||
let (mut identity, parents) = monitor
|
||||
.verify(
|
||||
&stream,
|
||||
matches!(wire.owner, ferese_ipc::activities::OwnerScope::Parent),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut publish = wire.publish;
|
||||
let is_run = matches!(publish, ferese_ipc::activities::Publish::Run { .. });
|
||||
let matched = if let ferese_ipc::activities::Publish::Hook { command, cwd, .. }
|
||||
| ferese_ipc::activities::Publish::Run { command, cwd, .. } = &mut publish
|
||||
{
|
||||
let rules = rules.read().map_err(|_| "Activity rules unavailable")?.clone();
|
||||
let matched = if rules.enabled || is_run {
|
||||
rules.matcher.matches(command, cwd, &parents)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
command.clear();
|
||||
cwd.clear();
|
||||
matched
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if matches!(
|
||||
publish,
|
||||
ferese_ipc::activities::Publish::Start { .. } | ferese_ipc::activities::Publish::Run { .. }
|
||||
) || (matches!(publish, ferese_ipc::activities::Publish::Hook { .. }) && matched.is_some())
|
||||
{
|
||||
identity.capture_ancestors();
|
||||
}
|
||||
Ok((identity, publish, matched))
|
||||
})();
|
||||
match verified {
|
||||
Ok(verified) => Some(verified),
|
||||
Err(error) => {
|
||||
if transport::write_response(&stream, &Response::error(request.id, "owner_rejected", error))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (response, receiver) = sync_channel(1);
|
||||
let event_stream_requested = request.command == "event-stream";
|
||||
let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY);
|
||||
@@ -525,14 +706,21 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
|
||||
| "begin-session-end"
|
||||
);
|
||||
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
|
||||
let call = IpcEvent::Call(IpcCall {
|
||||
let call = IpcEvent::Call(Box::new(IpcCall {
|
||||
native_portal,
|
||||
activity,
|
||||
owner,
|
||||
request,
|
||||
response,
|
||||
events: event_stream_requested.then_some(events),
|
||||
});
|
||||
if sender.try_send(call).is_err() {
|
||||
}));
|
||||
let terminal_activity = matches!(&call, IpcEvent::Call(call) if matches!(call.activity.as_ref().map(|(_, publish, _)| publish), Some(ferese_ipc::activities::Publish::End { .. } | ferese_ipc::activities::Publish::Cancel { .. })));
|
||||
let sent = if terminal_activity {
|
||||
sender.send(call).is_ok()
|
||||
} else {
|
||||
sender.try_send(call).is_ok()
|
||||
};
|
||||
if !sent {
|
||||
tracing::warn!("disconnecting IPC client because the request queue is full");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Authenticate the socket peer and its immediate parent before accepting any
|
||||
//! activity request. pidfds bind monitoring to a process, not a reused PID.
|
||||
use super::IpcEvent;
|
||||
use ferese_activities::Owner;
|
||||
use smithay::reexports::calloop::channel;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// Carries the kernel identity through the IPC queue. Monitoring begins only
|
||||
/// when the compositor admits the request, so pruning cannot race a pending start.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct VerifiedOwner {
|
||||
pub owner: Owner,
|
||||
fd: OwnedFd,
|
||||
ancestors: Vec<(u32, OwnedFd)>,
|
||||
}
|
||||
|
||||
impl VerifiedOwner {
|
||||
/// Run on the IPC worker, only for starts. Hold ancestor identities until
|
||||
/// dispatch; a recycled PID must never select a window.
|
||||
pub fn capture_ancestors(&mut self) {
|
||||
let mut next = self.owner.pid;
|
||||
let mut child = None;
|
||||
for _ in 0..16 {
|
||||
let Ok((birth, parent)) = identity(next) else { break };
|
||||
if next == self.owner.pid && birth != self.owner.birth {
|
||||
break;
|
||||
}
|
||||
let Ok(fd) = pidfd(next) else { break };
|
||||
if identity(next).ok() != Some((birth, parent)) || exited(&fd) {
|
||||
break;
|
||||
}
|
||||
if let Some((pid, expected)) = child
|
||||
&& identity(pid).ok() != Some(expected)
|
||||
{
|
||||
break;
|
||||
}
|
||||
self.ancestors.push((next, fd));
|
||||
child = Some((next, (birth, parent)));
|
||||
if parent <= 1 || parent == next {
|
||||
break;
|
||||
}
|
||||
next = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ancestor_pids(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
self.ancestors
|
||||
.iter()
|
||||
.take_while(|(_, fd)| !exited(fd))
|
||||
.map(|(pid, _)| *pid)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Monitor(Arc<(Mutex<BTreeMap<Owner, OwnedFd>>, Condvar)>);
|
||||
impl Monitor {
|
||||
pub fn new(sender: channel::SyncSender<IpcEvent>) -> Self {
|
||||
let shared = Arc::new((Mutex::new(BTreeMap::<Owner, OwnedFd>::new()), Condvar::new()));
|
||||
let worker = shared.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-activity-owners".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let (lock, ready) = &*worker;
|
||||
let mut owners = lock.lock().expect("activity owners");
|
||||
while owners.is_empty() {
|
||||
owners = ready.wait(owners).expect("activity owners");
|
||||
}
|
||||
let dead: Vec<_> = owners
|
||||
.iter()
|
||||
.filter_map(|(owner, fd)| {
|
||||
let mut poll = libc::pollfd {
|
||||
fd: fd.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: poll addresses one live, initialized descriptor.
|
||||
(unsafe { libc::poll(&raw mut poll, 1, 0) } > 0).then_some(*owner)
|
||||
})
|
||||
.collect();
|
||||
for owner in &dead {
|
||||
owners.remove(owner);
|
||||
}
|
||||
drop(owners);
|
||||
for owner in dead {
|
||||
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
})
|
||||
.expect("could not start activity owner monitor");
|
||||
Self(shared)
|
||||
}
|
||||
pub fn retain(&self, owners: &std::collections::BTreeSet<Owner>) {
|
||||
self.0
|
||||
.0
|
||||
.lock()
|
||||
.expect("activity owners")
|
||||
.retain(|owner, _| owners.contains(owner));
|
||||
}
|
||||
pub fn verify(&self, stream: &UnixStream, parent: bool) -> io::Result<(VerifiedOwner, Vec<String>)> {
|
||||
let peer = credentials(stream)?;
|
||||
let peer_pid = u32::try_from(peer.pid).map_err(|_| io::Error::other("invalid peer PID"))?;
|
||||
let peer_identity = identity(peer_pid)?;
|
||||
let peer_fd = peer_pidfd(stream)?;
|
||||
if identity(peer_pid)?.0 != peer_identity.0 || exited(&peer_fd) {
|
||||
return Err(io::Error::other("peer identity changed"));
|
||||
}
|
||||
let pid = if parent { peer_identity.1 } else { peer_pid };
|
||||
let (birth, _) = identity(pid)?;
|
||||
let fd = pidfd(pid)?;
|
||||
if identity(pid)?.0 != birth || exited(&fd) || (parent && identity(peer_pid)? != peer_identity) {
|
||||
return Err(io::Error::other("owner identity changed"));
|
||||
}
|
||||
let metadata = std::fs::metadata(format!("/proc/{pid}"))?;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if metadata.uid() != peer.uid {
|
||||
return Err(io::Error::other("owner user differs from peer"));
|
||||
}
|
||||
let owner = Owner { pid, birth };
|
||||
let mut parents = Vec::new();
|
||||
let mut next = pid;
|
||||
for _ in 0..16 {
|
||||
let Ok((_, parent)) = identity(next) else {
|
||||
break;
|
||||
};
|
||||
if let Ok(name) = std::fs::read_to_string(format!("/proc/{next}/comm")) {
|
||||
parents.push(name.trim().chars().take(128).collect());
|
||||
}
|
||||
if parent == 0 || parent == next {
|
||||
break;
|
||||
}
|
||||
next = parent;
|
||||
}
|
||||
Ok((
|
||||
VerifiedOwner {
|
||||
owner,
|
||||
fd,
|
||||
ancestors: Vec::new(),
|
||||
},
|
||||
parents,
|
||||
))
|
||||
}
|
||||
pub fn register(&self, verified: VerifiedOwner) -> io::Result<Owner> {
|
||||
let VerifiedOwner { owner, fd, .. } = verified;
|
||||
if exited(&fd) {
|
||||
return Err(io::Error::other("activity owner exited before admission"));
|
||||
}
|
||||
let (lock, ready) = &*self.0;
|
||||
let mut owners = lock.lock().expect("activity owners");
|
||||
if owners.len() >= 128 && !owners.contains_key(&owner) {
|
||||
return Err(io::Error::other("owner monitor quota"));
|
||||
}
|
||||
owners.entry(owner).or_insert(fd);
|
||||
ready.notify_one();
|
||||
drop(owners);
|
||||
Ok(owner)
|
||||
}
|
||||
}
|
||||
pub(crate) fn exited(fd: &OwnedFd) -> bool {
|
||||
let mut poll = libc::pollfd {
|
||||
fd: fd.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: poll addresses one initialized descriptor retained by fd.
|
||||
unsafe { libc::poll(&raw mut poll, 1, 0) != 0 }
|
||||
}
|
||||
fn peer_pidfd(stream: &UnixStream) -> io::Result<OwnedFd> {
|
||||
let mut fd = -1_i32;
|
||||
let mut len = std::mem::size_of::<i32>() as libc::socklen_t;
|
||||
// SO_PEERPIDFD (Linux 6.5+) refers to the process captured when this socket
|
||||
// connected, closing the credential-PID reuse race before authentication.
|
||||
// SAFETY: both output buffers have the sizes expected by getsockopt.
|
||||
if unsafe {
|
||||
libc::getsockopt(
|
||||
stream.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
77,
|
||||
(&raw mut fd).cast(),
|
||||
&raw mut len,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if fd < 0 {
|
||||
return Err(io::Error::other("missing peer pidfd"));
|
||||
}
|
||||
// SAFETY: SO_PEERPIDFD returned a new descriptor owned by this caller.
|
||||
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
|
||||
}
|
||||
fn pidfd(pid: u32) -> io::Result<OwnedFd> {
|
||||
// SAFETY: pidfd_open accepts numeric arguments and returns an owned fd.
|
||||
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
|
||||
if fd < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) })
|
||||
}
|
||||
}
|
||||
fn identity(pid: u32) -> io::Result<(u64, u32)> {
|
||||
parse_stat(&std::fs::read_to_string(format!("/proc/{pid}/stat"))?)
|
||||
}
|
||||
fn parse_stat(stat: &str) -> io::Result<(u64, u32)> {
|
||||
let end = stat
|
||||
.rfind(')')
|
||||
.ok_or_else(|| io::Error::other("invalid process stat"))?;
|
||||
let fields: Vec<_> = stat[end + 1..].split_whitespace().collect();
|
||||
let parent = fields
|
||||
.get(1)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| io::Error::other("missing parent identity"))?;
|
||||
let birth = fields
|
||||
.get(19)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| io::Error::other("missing process birth"))?;
|
||||
Ok((birth, parent))
|
||||
}
|
||||
fn credentials(stream: &UnixStream) -> io::Result<libc::ucred> {
|
||||
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
// SAFETY: both output buffers have the sizes expected by SO_PEERCRED.
|
||||
if unsafe {
|
||||
libc::getsockopt(
|
||||
stream.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
(&raw mut credentials).cast(),
|
||||
&raw mut len,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
Ok(credentials)
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
pub(crate) fn inspect(pid: u32) -> io::Result<(Owner, OwnedFd, Vec<String>)> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if std::fs::metadata(format!("/proc/{pid}"))?.uid() != unsafe { libc::geteuid() } {
|
||||
return Err(io::Error::other("process belongs to another user"));
|
||||
}
|
||||
let (birth, _) = identity(pid)?;
|
||||
let fd = pidfd(pid)?;
|
||||
if identity(pid)?.0 != birth || exited(&fd) {
|
||||
return Err(io::Error::other("process disappeared"));
|
||||
}
|
||||
let mut next = pid;
|
||||
let mut parents = Vec::new();
|
||||
for _ in 0..16 {
|
||||
if let Ok(name) = std::fs::read_to_string(format!("/proc/{next}/comm")) {
|
||||
parents.push(name.trim().chars().take(128).collect());
|
||||
}
|
||||
let Ok((_, parent)) = identity(next) else {
|
||||
break;
|
||||
};
|
||||
if parent == 0 || parent == next {
|
||||
break;
|
||||
}
|
||||
next = parent;
|
||||
}
|
||||
Ok((Owner { pid, birth }, fd, parents))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn captured_ancestry_stops_at_an_exited_process() {
|
||||
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
let (owner, fd, _) = inspect(child.id()).unwrap();
|
||||
let mut verified = VerifiedOwner {
|
||||
owner,
|
||||
fd,
|
||||
ancestors: Vec::new(),
|
||||
};
|
||||
verified.capture_ancestors();
|
||||
assert_eq!(verified.ancestor_pids().next(), Some(child.id()));
|
||||
assert!(verified.ancestor_pids().any(|pid| pid == std::process::id()));
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
assert!(verified.ancestor_pids().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_start_survives_monitor_pruning() {
|
||||
use ferese_activities::{Data, Source, Template, Value};
|
||||
let (sender, _receiver) = channel::sync_channel(16);
|
||||
let monitor = Monitor::new(sender);
|
||||
let mut engine = crate::activities::Engine::default();
|
||||
engine.monitor = Some(monitor.clone());
|
||||
let (peer, _other) = UnixStream::pair().unwrap();
|
||||
let (verified, _) = monitor.verify(&peer, false).unwrap();
|
||||
let owner = verified.owner;
|
||||
// A policy timer or a different queued publication runs after verification,
|
||||
// before this worker's Start reaches the compositor dispatcher.
|
||||
engine.schedule();
|
||||
engine
|
||||
.publish_verified(
|
||||
verified,
|
||||
ferese_ipc::activities::Publish::Start {
|
||||
local: "pending-start".into(),
|
||||
data: Data {
|
||||
source: Source::Publisher,
|
||||
output: None,
|
||||
priority: 100,
|
||||
icon: "hammer".into(),
|
||||
title: "Build".into(),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
},
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("test".into()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
assert!(
|
||||
monitor.0.0.lock().unwrap().contains_key(&owner),
|
||||
"accepted lease-free activity has lost its owner monitor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_that_exits_in_the_dispatch_queue_is_rejected() {
|
||||
let (sender, _receiver) = channel::sync_channel(16);
|
||||
let monitor = Monitor::new(sender);
|
||||
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
let (owner, fd, _) = inspect(child.id()).unwrap();
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
assert!(
|
||||
monitor
|
||||
.register(VerifiedOwner {
|
||||
owner,
|
||||
fd,
|
||||
ancestors: Vec::new()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(monitor.0.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_names_with_spaces_or_parentheses_do_not_shift_birth_field() {
|
||||
let fields = (0..20)
|
||||
.map(|i| if i == 0 { "S".into() } else { i.to_string() })
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert_eq!(
|
||||
parse_stat(&format!("42 (name ) with spaces) {fields}")).unwrap(),
|
||||
(19, 1)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn verifies_kernel_peer_parent_and_monitors_actual_exit() {
|
||||
let (sender, _receiver) = channel::sync_channel(16);
|
||||
let monitor = Monitor::new(sender);
|
||||
let (left, _) = UnixStream::pair().unwrap();
|
||||
let (peer, _) = monitor.verify(&left, false).unwrap();
|
||||
assert_eq!(peer.owner.pid, std::process::id());
|
||||
let (parent, _) = monitor.verify(&left, true).unwrap();
|
||||
assert_eq!(parent.owner.pid, unsafe { libc::getppid() } as u32);
|
||||
let mut child = std::process::Command::new("sleep").arg("0.05").spawn().unwrap();
|
||||
let fd = pidfd(child.id()).unwrap();
|
||||
assert!(!exited(&fd));
|
||||
child.wait().unwrap();
|
||||
assert!(exited(&fd));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod activities;
|
||||
mod activities_process;
|
||||
mod backends;
|
||||
mod config;
|
||||
mod cursor;
|
||||
|
||||
@@ -810,3 +810,211 @@ fn corner_heavy_shader_timing_sample() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn window_tint_does_not_inherit_panel_geometry() {
|
||||
use smithay::backend::renderer::gles::UniformValue;
|
||||
use smithay::output::{Mode, PhysicalProperties, Subpixel};
|
||||
fn paint(renderer: &mut GlesRenderer, element: &impl RenderElement<GlesRenderer>) -> Vec<u8> {
|
||||
let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (96, 96).into()).unwrap();
|
||||
let mut target = renderer.bind(&mut texture).unwrap();
|
||||
let rect = Rectangle::from_size((96, 96).into());
|
||||
let mut frame = renderer
|
||||
.render(&mut target, (96, 96).into(), Transform::Normal)
|
||||
.unwrap();
|
||||
frame.clear([1.0, 1.0, 1.0, 1.0].into(), &[rect]).unwrap();
|
||||
let geometry = element.geometry(1.0.into());
|
||||
element
|
||||
.draw(
|
||||
&mut frame,
|
||||
element.src(),
|
||||
geometry,
|
||||
&[Rectangle::from_size(geometry.size)],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let _ = frame.finish().unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(&target, Rectangle::from_size((96, 96).into()), Fourcc::Abgr8888)
|
||||
.unwrap();
|
||||
renderer.map_texture(&mapping).unwrap().to_vec()
|
||||
}
|
||||
let mut renderer = renderer();
|
||||
let mut resources = RenderResources::default();
|
||||
let output = Output::new(
|
||||
"tint-test".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (96, 96).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
None,
|
||||
Some((0, 0).into()),
|
||||
);
|
||||
let geometry = Rectangle::new((6, 6).into(), (80, 80).into());
|
||||
let corners = RoundedRect::from_logical(geometry, 1.0, 12.0).with_shape(CornerShape::Continuous);
|
||||
for resize_fill in [false, true] {
|
||||
let tint = window_tint_element(
|
||||
&mut resources,
|
||||
&mut renderer,
|
||||
ferese_layout::WindowId(1),
|
||||
geometry,
|
||||
corners,
|
||||
[0.0, 0.0, 0.0, 0.44],
|
||||
resize_fill,
|
||||
&output,
|
||||
)
|
||||
.unwrap();
|
||||
let baseline = paint(&mut renderer, &tint);
|
||||
let program = material_program(&mut resources, &mut renderer).unwrap();
|
||||
let panel = SharedPixelShaderElement::new(
|
||||
program.0,
|
||||
Rectangle::from_size((96, 24).into()),
|
||||
None,
|
||||
1.0,
|
||||
vec![
|
||||
Uniform::new("visible_rect", [0.0_f32, 72.0, 96.0, 24.0]),
|
||||
Uniform::new("material_radii", [12.0_f32; 4]),
|
||||
Uniform::new("tint", [0.2_f32, 0.2, 0.2, 1.0]),
|
||||
Uniform::new("paint_mode", 0.0_f32),
|
||||
Uniform::new("shadow_rect", [0.0_f32; 4]),
|
||||
Uniform::new("shadow_values", [0.0_f32; 2]),
|
||||
Uniform::new("panel_count", 1.0_f32),
|
||||
Uniform::new("panel_fusion", 0.0_f32),
|
||||
Uniform::new(
|
||||
"panel_shapes[0]",
|
||||
UniformValue::Matrix3x3 {
|
||||
matrices: vec![[0.0, 72.0, 96.0, 24.0, 12.0, 12.0, 12.0, 12.0, 0.0]],
|
||||
transpose: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
RenderElementKind::Unspecified,
|
||||
);
|
||||
paint(&mut renderer, &panel);
|
||||
let after_panel = paint(&mut renderer, &tint);
|
||||
let changed = baseline
|
||||
.chunks_exact(4)
|
||||
.zip(after_panel.chunks_exact(4))
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
assert_eq!(
|
||||
changed, 0,
|
||||
"a panel draw changed {changed} window pixels (resize_fill={resize_fill})"
|
||||
);
|
||||
assert!(
|
||||
baseline.chunks_exact(4).any(|p| p[0] < 200),
|
||||
"fixture must draw the tint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn floating_blur_sampling_halo_does_not_paint_neighboring_islands() {
|
||||
use smithay::backend::renderer::ImportMem;
|
||||
use smithay::backend::renderer::gles::UniformValue;
|
||||
let mut renderer = renderer();
|
||||
let mut resources = RenderResources::default();
|
||||
let program = blur_program(&mut resources, &mut renderer).unwrap();
|
||||
for scale in [1.0_f64, 1.25, 1.5, 1.75, 2.0] {
|
||||
let size: Size<i32, Physical> = ((256. * scale) as i32, (96. * scale) as i32).into();
|
||||
let full = Rectangle::from_size(size);
|
||||
let shapes: Vec<[f32; 9]> = [24., 112.]
|
||||
.into_iter()
|
||||
.map(|x| [x, 24., 80., 32., 16., 16., 16., 16., 0.].map(|v| v * scale as f32))
|
||||
.collect();
|
||||
// Each island captures an extra 32 logical pixels on every side for
|
||||
// blur sampling. That capture includes part of its neighboring island.
|
||||
for island in 0..2 {
|
||||
let shape = shapes[island];
|
||||
let visible = [shape[0], shape[1], shape[2], shape[3]];
|
||||
let halo = 32. * scale as f32;
|
||||
let capture = [
|
||||
(visible[0] - halo).max(0.),
|
||||
(visible[1] - halo).max(0.),
|
||||
(visible[0] + visible[2] + halo).min(size.w as f32),
|
||||
(visible[1] + visible[3] + halo).min(size.h as f32),
|
||||
];
|
||||
let rect = Rectangle::<i32, Physical>::new(
|
||||
(capture[0] as i32, capture[1] as i32).into(),
|
||||
((capture[2] - capture[0]) as i32, (capture[3] - capture[1]) as i32).into(),
|
||||
);
|
||||
let mut pixels = vec![128_u8; (rect.size.w * rect.size.h * 4) as usize];
|
||||
for p in pixels.chunks_exact_mut(4) {
|
||||
p[3] = 255;
|
||||
}
|
||||
let source = renderer
|
||||
.import_memory(&pixels, Fourcc::Abgr8888, (rect.size.w, rect.size.h).into(), false)
|
||||
.unwrap();
|
||||
let uniforms = vec![
|
||||
Uniform::new("visible_rect", visible),
|
||||
Uniform::new("material_radii", [16. * scale as f32; 4]),
|
||||
Uniform::new("texture_size", [rect.size.w as f32, rect.size.h as f32]),
|
||||
Uniform::new("capture_origin", [capture[0], capture[1]]),
|
||||
Uniform::new("blur_radius", halo),
|
||||
Uniform::new("presentation_alpha", 1.0_f32),
|
||||
Uniform::new("background_opacity", 0.6_f32),
|
||||
Uniform::new("tint", [0.1_f32, 0.1, 0.1, 0.5]),
|
||||
Uniform::new("panel_count", 2.0_f32),
|
||||
Uniform::new("panel_fusion", 0.0_f32),
|
||||
Uniform::new(
|
||||
"panel_shapes[0]",
|
||||
UniformValue::Matrix3x3 {
|
||||
matrices: shapes.clone(),
|
||||
transpose: false,
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut texture: GlesTexture = renderer
|
||||
.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into())
|
||||
.unwrap();
|
||||
let mut target = renderer.bind(&mut texture).unwrap();
|
||||
let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap();
|
||||
frame.clear([0.; 4].into(), &[full]).unwrap();
|
||||
// OpenGL coordinates have the opposite Y direction from the frame.
|
||||
let dst = Rectangle::new((rect.loc.x, size.h - rect.loc.y - rect.size.h).into(), rect.size);
|
||||
frame
|
||||
.render_texture_from_to(
|
||||
&source,
|
||||
Rectangle::from_size(source.size().to_f64()),
|
||||
dst,
|
||||
&[Rectangle::from_size(dst.size)],
|
||||
&[],
|
||||
Transform::Normal,
|
||||
1.,
|
||||
Some(&program.0),
|
||||
&uniforms,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = frame.finish().unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(&target, Rectangle::from_size((size.w, size.h).into()), Fourcc::Abgr8888)
|
||||
.unwrap();
|
||||
let rendered = renderer.map_texture(&mapping).unwrap().to_vec();
|
||||
let mut painted = 0;
|
||||
for pixel in rendered.chunks_exact(4).enumerate() {
|
||||
let (index, rgba) = pixel;
|
||||
let x = (index % size.w as usize) as f32 + 0.5;
|
||||
if x < visible[0] || x >= visible[0] + visible[2] {
|
||||
assert_eq!(
|
||||
rgba[3], 0,
|
||||
"island {island} paints outside its bounds at x={x}, scale={scale}"
|
||||
);
|
||||
} else if rgba[3] > 0 {
|
||||
painted += 1;
|
||||
}
|
||||
}
|
||||
assert!(painted > 100, "fixture must paint its own island");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,21 @@ pub(super) fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'sta
|
||||
]
|
||||
}
|
||||
|
||||
// The material program is shared with panels. GL uniforms survive between
|
||||
// draws, so every ordinary tint must explicitly disable the panel mask.
|
||||
pub(super) fn tint_uniforms(p: &BorderParameters) -> Vec<Uniform<'static>> {
|
||||
vec![
|
||||
Uniform::new("visible_rect", p.clip_rect),
|
||||
Uniform::new("material_radii", [p.radius; 4]),
|
||||
Uniform::new("tint", p.color),
|
||||
Uniform::new("paint_mode", 0.0_f32),
|
||||
Uniform::new("shadow_rect", p.clip_rect),
|
||||
Uniform::new("shadow_values", [0.0_f32; 2]),
|
||||
Uniform::new("panel_count", 0.0_f32),
|
||||
Uniform::new("panel_fusion", 0.0_f32),
|
||||
]
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn window_tint_element(
|
||||
resources: &mut RenderResources,
|
||||
@@ -231,16 +246,7 @@ pub(super) fn window_tint_element(
|
||||
focus_gradient_line: [0.0; 4],
|
||||
focus_mix: 0.0,
|
||||
};
|
||||
let uniforms = |p: &BorderParameters| {
|
||||
vec![
|
||||
Uniform::new("visible_rect", p.clip_rect),
|
||||
Uniform::new("material_radii", [p.radius; 4]),
|
||||
Uniform::new("tint", p.color),
|
||||
Uniform::new("paint_mode", 0.0_f32),
|
||||
Uniform::new("shadow_rect", p.clip_rect),
|
||||
Uniform::new("shadow_values", [0.0_f32; 2]),
|
||||
]
|
||||
};
|
||||
let uniforms = tint_uniforms;
|
||||
let context = renderer.context_id().erased();
|
||||
let buffers = if resize_fill {
|
||||
&mut resources.windows.entry(id).or_default().resize_fill
|
||||
|
||||
@@ -691,7 +691,8 @@ mod region_geometry_tests {
|
||||
#[test]
|
||||
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
|
||||
use crate::effects::SemanticRole;
|
||||
for surface in ["flat", "floating", "morph"] {
|
||||
for style in ferese_panel::PanelStyle::AVAILABLE {
|
||||
let surface = style.key();
|
||||
for opacity in [0., 0.4, 1.] {
|
||||
for reduce in [false, true] {
|
||||
let source = format!(
|
||||
|
||||
@@ -39,14 +39,7 @@ pub(super) fn overview_chrome_element(
|
||||
if key.1 == OverviewChromePart::Outline {
|
||||
border_uniforms(p)
|
||||
} else {
|
||||
vec![
|
||||
Uniform::new("visible_rect", p.clip_rect),
|
||||
Uniform::new("material_radii", [p.radius; 4]),
|
||||
Uniform::new("tint", p.color),
|
||||
Uniform::new("paint_mode", 0.0_f32),
|
||||
Uniform::new("shadow_rect", p.clip_rect),
|
||||
Uniform::new("shadow_values", [0.0_f32; 2]),
|
||||
]
|
||||
tint_uniforms(p)
|
||||
}
|
||||
};
|
||||
let context = renderer.context_id().erased();
|
||||
|
||||
@@ -25,6 +25,9 @@ varying vec2 v_coords;
|
||||
|
||||
void main() {
|
||||
float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii);
|
||||
// The capture includes a sampling halo, but this draw owns only its visible
|
||||
// region. The shared panel mask also contains neighboring islands.
|
||||
if (panel_count > 0.5) sdf = max(sdf, panel_box_distance(gl_FragCoord.xy, visible_rect));
|
||||
float coverage = alpha * presentation_alpha * edge_coverage(sdf);
|
||||
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
|
||||
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
|
||||
|
||||
@@ -490,6 +490,7 @@ pub struct Ferese {
|
||||
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
|
||||
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
|
||||
pub(crate) media_engine: crate::idle_inhibition::media::Engine,
|
||||
pub(crate) activities: crate::activities::Engine,
|
||||
pub(crate) automatic_idle_inhibited: bool,
|
||||
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
||||
pub direct_backend: Option<DirectBackendState>,
|
||||
@@ -548,6 +549,8 @@ pub struct Ferese {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeConfig {
|
||||
pub(crate) activity_settings: ferese_config::activities::Settings,
|
||||
pub(crate) activity_matcher: ferese_config::activities::Matcher,
|
||||
pub(crate) lock_idle: IdleSettings,
|
||||
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
|
||||
pub(crate) autostart: Vec<DaemonConfig>,
|
||||
@@ -672,6 +675,8 @@ impl Ferese {
|
||||
let default_cursor = crate::cursor::fallback_cursor();
|
||||
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
||||
|
||||
let mut activities = crate::activities::Engine::default();
|
||||
activities.configure(&config.activity_settings, config.activity_matcher.clone());
|
||||
let mut state = Self {
|
||||
session_lock_state,
|
||||
session_lock: crate::session_lock::Lock::default(),
|
||||
@@ -772,6 +777,7 @@ impl Ferese {
|
||||
idle_inhibit: config.idle_inhibit,
|
||||
media_players: Vec::new(),
|
||||
media_engine: Default::default(),
|
||||
activities,
|
||||
automatic_idle_inhibited: false,
|
||||
active_shortcuts_inhibitor: None,
|
||||
direct_backend: None,
|
||||
@@ -850,6 +856,10 @@ impl Ferese {
|
||||
// session-scoped, so a difference from the running service is reported
|
||||
// as `restart_required` rather than applied behind the user's back.
|
||||
self.xwayland_config = config.xwayland.clone();
|
||||
if changed(&["activities"]) {
|
||||
self.activities
|
||||
.configure(&config.activity_settings, config.activity_matcher.clone());
|
||||
}
|
||||
|
||||
let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
|
||||
let bindings_changed = changed(&["bindings", "commands"]);
|
||||
|
||||
@@ -33,11 +33,11 @@ struct PreviewWindow {
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
/// Morph owns the gap on its reserved edge. Keep the layout's outer gap
|
||||
/// The panel owns the gap on its reserved edge. Keep the layout's outer gap
|
||||
/// on the other three edges, and keep floating work-area bounds unchanged.
|
||||
pub(super) fn morph_layout_bounds(&self, mut bounds: Rect, outer: f64) -> Rect {
|
||||
pub(super) fn panel_layout_bounds(&self, mut bounds: Rect, outer: f64) -> Rect {
|
||||
use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone};
|
||||
if self.panel_style != ferese_panel::PanelStyle::Morph || outer <= 0. || !outer.is_finite() {
|
||||
if outer <= 0. || !outer.is_finite() {
|
||||
return bounds;
|
||||
}
|
||||
let Some(output) = self
|
||||
@@ -64,7 +64,7 @@ impl Ferese {
|
||||
};
|
||||
let top = f64::from(output_geometry.loc.y + geometry.loc.y);
|
||||
let bottom = top + f64::from(geometry.size.h);
|
||||
// Only remove a gap if Morph actually borders the usable area.
|
||||
// Only remove a gap if the panel actually borders the usable area.
|
||||
// Another reserved bar on this edge must retain its own spacing.
|
||||
if policy.anchor.contains(Anchor::TOP)
|
||||
&& !policy.anchor.contains(Anchor::BOTTOM)
|
||||
@@ -91,7 +91,7 @@ impl Ferese {
|
||||
} else {
|
||||
self.gap_config.outer
|
||||
};
|
||||
self.morph_layout_bounds(bounds, outer)
|
||||
self.panel_layout_bounds(bounds, outer)
|
||||
}
|
||||
pub(super) fn visible_workspace_ids(&self) -> HashSet<WorkspaceId> {
|
||||
let mut visible = self
|
||||
@@ -321,7 +321,7 @@ impl Ferese {
|
||||
};
|
||||
|
||||
let layout_bounds = self.workspace_layout_bounds(bounds, &workspace.layout);
|
||||
let maximized_bounds = self.morph_layout_bounds(bounds, self.gap_config.outer);
|
||||
let maximized_bounds = self.panel_layout_bounds(bounds, self.gap_config.outer);
|
||||
let workspace_fullscreen = workspace.fullscreen;
|
||||
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
|
||||
let workspace_focus = workspace.last_focused;
|
||||
@@ -750,7 +750,7 @@ impl Ferese {
|
||||
windows.sort_by_key(|w| w.id.0);
|
||||
let input = PreviewInput {
|
||||
bounds: self.workspace_layout_bounds(bounds, &workspace.layout),
|
||||
maximized_bounds: self.morph_layout_bounds(bounds, self.gap_config.outer),
|
||||
maximized_bounds: self.panel_layout_bounds(bounds, self.gap_config.outer),
|
||||
fullscreen_bounds: self.full_output_bounds_for(output).unwrap_or(bounds),
|
||||
gaps: self.gap_config,
|
||||
focused: workspace.last_focused,
|
||||
@@ -835,3 +835,7 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "panel_clearance_tests.rs"]
|
||||
mod panel_clearance_tests;
|
||||
@@ -97,6 +97,13 @@ impl Ferese {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let fallback = self
|
||||
.space
|
||||
.outputs()
|
||||
.find(|candidate| *candidate != output)
|
||||
.map(|output| output.name());
|
||||
self.activities.store.remove_output(&output.name(), fallback.as_deref());
|
||||
self.activities.changed();
|
||||
self.display_presentation.remove_output(output);
|
||||
self.output_redraw_pending.retain(|pending| pending != output);
|
||||
if let Some(id) = self.output_ids.get(output) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::*;
|
||||
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
|
||||
use smithay::utils::Transform;
|
||||
use std::io::Write;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) {
|
||||
let words = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode];
|
||||
wire.write_all(
|
||||
&words
|
||||
.into_iter()
|
||||
.chain(args.iter().copied())
|
||||
.flat_map(u32::to_ne_bytes)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_panel_style_owns_clearance_at_its_reserved_edge() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"state::layout::panel_clearance_tests::every_panel_style_owns_clearance_at_its_reserved_edge",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let output = Output::new(
|
||||
"clearance".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
state.begin_desktop_transition();
|
||||
let changes = state
|
||||
.publish_desktop(vec![DesktopOutput {
|
||||
output: output.clone(),
|
||||
identity: "clearance".into(),
|
||||
mode: Mode {
|
||||
size: (800, 600).into(),
|
||||
refresh: 60_000,
|
||||
},
|
||||
transform: Transform::Normal,
|
||||
scale: Scale::Fractional(1.0),
|
||||
position: (0, 0).into(),
|
||||
}])
|
||||
.unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
let (mut wire, server) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
|
||||
.unwrap();
|
||||
let layers = client
|
||||
.create_resource::<ZwlrLayerShellV1, (), Ferese>(&state.display_handle, 4, ())
|
||||
.unwrap();
|
||||
request(&mut wire, compositor.id().protocol_id(), 0, &[2]);
|
||||
let mut namespace = b"ferese-shell-top-bar\0".to_vec();
|
||||
let length = namespace.len() as u32;
|
||||
namespace.resize(namespace.len().div_ceil(4) * 4, 0);
|
||||
let args = [3, 2, 0, 2, length]
|
||||
.into_iter()
|
||||
.chain(
|
||||
namespace
|
||||
.chunks_exact(4)
|
||||
.map(|b| u32::from_ne_bytes(b.try_into().unwrap())),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
request(&mut wire, layers.id().protocol_id(), 0, &args);
|
||||
request(&mut wire, 3, 0, &[800, 36]);
|
||||
request(&mut wire, 3, 1, &[1 | 4 | 8]); // Top, left and right.
|
||||
for style in ferese_panel::PanelStyle::ALL {
|
||||
state.panel_style = style;
|
||||
for clearance in [0, 5, 24] {
|
||||
let mut panel = ferese_panel::Panel::from_preset(style);
|
||||
panel.geometry.height = Some(36.);
|
||||
panel.geometry.window_clearance = Some(clearance);
|
||||
panel.geometry.edge_margin = Some(8);
|
||||
let geometry = panel.resolved_geometry();
|
||||
request(&mut wire, 3, 2, &[panel.exclusive_zone(false) as u32]);
|
||||
request(&mut wire, 3, 3, &[geometry.edge_margin as u32, 0, 0, 0]);
|
||||
request(&mut wire, 2, 6, &[]);
|
||||
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
|
||||
let bounds = state.output_bounds_for(&output).unwrap();
|
||||
let edge = f64::from(geometry.edge_margin + 36 + clearance);
|
||||
assert_eq!(bounds.y, edge, "{style:?}, clearance={clearance}");
|
||||
for outer in [0., 12.] {
|
||||
let adjusted = state.panel_layout_bounds(bounds, outer);
|
||||
let window = maximized_rect(adjusted, outer);
|
||||
assert_eq!(window.y, edge, "{style:?}, clearance={clearance}, outer={outer}");
|
||||
assert_eq!(window.x, bounds.x + outer);
|
||||
assert_eq!(window.y + window.height, bounds.y + bounds.height - outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
request(&mut wire, 3, 2, &[0]);
|
||||
request(&mut wire, 2, 6, &[]);
|
||||
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
|
||||
let bounds = state.output_bounds_for(&output).unwrap();
|
||||
assert_eq!(
|
||||
state.panel_layout_bounds(bounds, 12.),
|
||||
bounds,
|
||||
"an unreserved panel must not remove gaps"
|
||||
);
|
||||
}
|
||||
@@ -166,6 +166,12 @@ impl Ferese {
|
||||
|
||||
pub(crate) fn finish_desktop_transition(&mut self, mut changes: DesktopChanges) {
|
||||
self.desktop_transition.as_mut().unwrap().publishing = true;
|
||||
let activity_output = self.space.outputs().next().map(|output| output.name());
|
||||
if let Some(output) = activity_output
|
||||
&& self.activities.store.attach_output(&output)
|
||||
{
|
||||
self.activities.changed();
|
||||
}
|
||||
if changes.layout {
|
||||
self.relayout_desktop();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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(), ¬ice, 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"
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-activities = { path = "../ferese-activities" }
|
||||
regex = "1.12"
|
||||
ferese-panel = { path = "../ferese-panel" }
|
||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||
jiff.workspace = true
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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, ®ex)?;
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod activities;
|
||||
pub mod families;
|
||||
pub mod file;
|
||||
pub mod notifications;
|
||||
@@ -144,7 +145,11 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
|
||||
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) {
|
||||
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
|
||||
records.as_array_mut().unwrap().push(value);
|
||||
|
||||
@@ -238,3 +238,27 @@ fn item_options_are_strict_and_metadata_is_explicit() {
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-activities = { path = "../ferese-activities" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
libc.workspace = true
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod activities;
|
||||
mod connection;
|
||||
pub use connection::{Cancellation, Connection, socket_path};
|
||||
use std::error::Error;
|
||||
use std::io::{Read, Write};
|
||||
use std::{fmt, io};
|
||||
|
||||
@@ -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 serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use crate::Connection;
|
||||
|
||||
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 {
|
||||
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> {
|
||||
Snapshot::decode(self.call("theme-get", json!({}))?, fallback_families)
|
||||
}
|
||||
@@ -108,71 +51,4 @@ impl Connection {
|
||||
) -> Result<Snapshot, String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ impl Profile {
|
||||
Content::Workspaces => self.workspaces_height,
|
||||
Content::Media => self.media_height,
|
||||
Content::Calendar => self.calendar_height,
|
||||
Content::Notifications => self.notifications_height,
|
||||
Content::Notifications | Content::Activities => self.notifications_height,
|
||||
Content::Controls => self.controls_height,
|
||||
},
|
||||
],
|
||||
@@ -96,6 +96,7 @@ impl Profile {
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Content {
|
||||
Activities,
|
||||
Workspaces,
|
||||
Media,
|
||||
Calendar,
|
||||
|
||||
@@ -120,6 +120,7 @@ pub enum ItemKind {
|
||||
},
|
||||
FocusedWindow,
|
||||
Media,
|
||||
Activities,
|
||||
QuickSettings,
|
||||
Network,
|
||||
Audio,
|
||||
@@ -141,6 +142,7 @@ impl ItemKind {
|
||||
Self::Workspaces { .. } => "Workspaces",
|
||||
Self::FocusedWindow => "Focused window",
|
||||
Self::Media => "Media",
|
||||
Self::Activities => "Activities",
|
||||
Self::QuickSettings => "Quick Settings",
|
||||
Self::Network => "Network",
|
||||
Self::Audio => "Audio",
|
||||
@@ -155,7 +157,7 @@ impl ItemKind {
|
||||
pub fn representations(self) -> &'static [Representation] {
|
||||
use Representation::*;
|
||||
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::Workspaces { .. } | Self::FocusedWindow => &[Wide],
|
||||
_ => &[Icon],
|
||||
@@ -283,6 +285,8 @@ impl Item {
|
||||
},
|
||||
priority: if matches!(kind, ItemKind::Overview | ItemKind::Workspaces { .. }) {
|
||||
100
|
||||
} else if kind == ItemKind::Activities {
|
||||
20
|
||||
} else {
|
||||
50
|
||||
},
|
||||
@@ -294,6 +298,7 @@ impl Item {
|
||||
return false;
|
||||
}
|
||||
match self.kind {
|
||||
ItemKind::Activities => availability.activities,
|
||||
ItemKind::Media => availability.media,
|
||||
ItemKind::Network => availability.network,
|
||||
ItemKind::Audio => availability.audio,
|
||||
@@ -308,6 +313,7 @@ impl Item {
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Availability {
|
||||
pub media: bool,
|
||||
pub activities: bool,
|
||||
pub network: bool,
|
||||
pub audio: bool,
|
||||
pub notifications: bool,
|
||||
@@ -792,6 +798,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
visible(Availability {
|
||||
activities: true,
|
||||
media: true,
|
||||
network: true,
|
||||
audio: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ use cosmic::iced::futures::SinkExt;
|
||||
use ferese_ipc::theme::Snapshot;
|
||||
|
||||
pub fn current() -> Snapshot {
|
||||
ferese_ipc::theme::Connection::connect()
|
||||
ferese_ipc::Connection::connect()
|
||||
.ok()
|
||||
.and_then(|mut connection| connection.get(ferese_config::families::builtins).ok())
|
||||
.unwrap_or_else(fallback)
|
||||
@@ -31,7 +31,7 @@ pub fn subscription() -> cosmic::iced::Subscription<Snapshot> {
|
||||
fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> {
|
||||
cosmic::iced::stream::channel(1, async |mut output| {
|
||||
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 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
continue;
|
||||
@@ -103,7 +103,7 @@ mod tests {
|
||||
}
|
||||
// No compositor: return the same standalone values without connecting elsewhere.
|
||||
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();
|
||||
let listener = UnixListener::bind(path).unwrap();
|
||||
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 |
@@ -3,6 +3,24 @@ use cosmic::iced::{Color, ContentFit, Length};
|
||||
use cosmic::theme;
|
||||
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 BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
|
||||
pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
## 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.
|
||||
- [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.
|
||||
|
||||
@@ -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.
|
||||
@@ -7,6 +7,8 @@ and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness
|
||||
keys need `brightnessctl` and audio keys need `wpctl`.
|
||||
This reference covers layouts, appearance, input, and desktop behavior.
|
||||
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
|
||||
|
||||
|
||||
@@ -27,12 +27,37 @@ host. Hardware checks remain separate.
|
||||
|
||||
## 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.
|
||||
|
||||
- [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.
|
||||
- [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
|
||||
|
||||
Run backend unit tests and compare D-Bus contracts against installed portal XML:
|
||||
|
||||
@@ -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`
|
||||
and `previous` links for switching between releases during updates and rollback.
|
||||
|
||||
The installer preserves your configuration, other desktop sessions and existing PAM
|
||||
policy. Ferese starts its authentication agent and portal services with the session.
|
||||
On first user setup, the installer detects Fish, Bash or Zsh, installs its
|
||||
[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
|
||||
apps use the Ferese dialog. Remove other polkit agents from your Ferese autostart
|
||||
configuration to avoid competing session registrations.
|
||||
|
||||
### Initial configuration
|
||||
|
||||
You can start with Ferese’s built-in defaults, or copy the example config if you’d like
|
||||
a file to customize:
|
||||
Activities setup creates a config file if one doesn't exist. You can edit that
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -326,6 +335,7 @@ Installer options:
|
||||
./scripts/install.sh --dry-run
|
||||
./scripts/install.sh --offline --release-id my-build
|
||||
./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 ./target/bundles/my-build
|
||||
```
|
||||
|
||||
@@ -20,6 +20,7 @@ feresectl output-profiles
|
||||
feresectl theme get
|
||||
feresectl xwayland status
|
||||
feresectl media
|
||||
feresectl activity list
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Every feresectl IPC command, including theme commands and subscriptions, resolves
|
||||
|
||||
@@ -41,6 +41,10 @@ Other controls save automatically, with sliders saving when you release them.
|
||||
**Undo** restores the previous save, **Reload configuration** reads external
|
||||
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;
|
||||
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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
the wider range shown above.
|
||||
|
||||
Morph reserves space for its collapsed height plus window clearance. Its
|
||||
expanded views overlay windows, so increasing their height does not push windows
|
||||
down. At the panel edge, Morph accounts for the layout's outer gap rather than
|
||||
adding the two gaps together.
|
||||
All panel styles reserve their height plus window clearance. At the panel edge,
|
||||
clearance replaces the layout's outer gap; the other edges keep their usual gaps.
|
||||
Morph reserves its collapsed height, and expanded views overlay windows without
|
||||
pushing them down.
|
||||
|
||||
## Background, outline and corners
|
||||
|
||||
@@ -247,6 +251,11 @@ content. Hovering can expand a preview, and clicking opens the hub's Workspaces,
|
||||
Music, Calendar,
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
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
|
||||
inside it. Item settings still determine which configured controls appear in
|
||||
its Controls view and shortcuts.
|
||||
inside it. `inner-padding` and `side-margins` also belong to the regular panel;
|
||||
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
|
||||
shoulders, or set `morph { attached #true; }`. **Preview on hover** maps to
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-activities = { path = "../crates/ferese-activities" }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ferese-config = { path = "../crates/ferese-config" }
|
||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,20 @@ pub struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
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.
|
||||
#[command(alias = "get-focused-window")]
|
||||
FocusedWindow,
|
||||
@@ -214,6 +228,7 @@ fn value_name(value: &impl ValueEnum) -> String {
|
||||
impl Command {
|
||||
pub fn ipc(&self) -> Option<(&'static str, Value)> {
|
||||
let (command, args) = match self {
|
||||
Self::Activity { .. } | Self::Run { .. } => return None,
|
||||
Self::FocusedWindow => ("get-focused-window", json!({})),
|
||||
Self::Windows => ("get-windows", json!({})),
|
||||
Self::IdleInhibition => ("get-idle-inhibition", json!({})),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod activities;
|
||||
mod autostart;
|
||||
mod cli;
|
||||
mod output;
|
||||
@@ -18,6 +19,12 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
if matches!(cli.command, Command::Autostart) {
|
||||
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| {
|
||||
matches!(
|
||||
arg.as_str(),
|
||||
@@ -35,9 +42,19 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
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())?;
|
||||
match &cli.command {
|
||||
Command::Theme { action } => theme_command(action, &socket, cli.json),
|
||||
Command::Activity { action } => activities::command(action, &socket, cli.json),
|
||||
Command::EventStream => event_stream(&socket),
|
||||
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(
|
||||
stream,
|
||||
&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>> {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect_to(socket)?;
|
||||
let mut connection = ferese_ipc::Connection::connect_to(socket)?;
|
||||
let result = match action {
|
||||
ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?,
|
||||
ThemeAction::Subscribe => {
|
||||
|
||||
@@ -189,3 +189,284 @@ fn event_stream_uses_the_selected_socket_and_prints_complete_json_lines() {
|
||||
stop.send(()).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");
|
||||
}
|
||||
@@ -53,7 +53,7 @@ pub(crate) struct Settings(Arc<RwLock<Appearance>>);
|
||||
|
||||
impl Settings {
|
||||
pub(crate) fn new() -> Self {
|
||||
let theme = ferese_ipc::theme::Connection::connect()
|
||||
let theme = ferese_ipc::Connection::connect()
|
||||
.ok()
|
||||
.and_then(|mut connection| connection.get(ferese_config::families::builtins).ok())
|
||||
.map(|snapshot| snapshot.theme);
|
||||
@@ -78,7 +78,7 @@ impl Settings {
|
||||
pub(crate) async fn watch(self, connection: Connection) {
|
||||
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||
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
|
||||
&& let Ok(cancellation) = connection.cancellation()
|
||||
{
|
||||
|
||||
@@ -61,6 +61,7 @@ def main():
|
||||
("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"}),
|
||||
("invalid-config", "startup_config", {"FERESE_TEST_STARTUP_CONFIG": "1"}),
|
||||
("activities", "activities", {"FERESE_TEST_ACTIVITIES": "1"}),
|
||||
]
|
||||
if args.suite == "desktop":
|
||||
cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"}))
|
||||
|
||||
@@ -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
|
||||
--offline Build using cached Cargo dependencies
|
||||
--resize-metrics Build with resize-barrier measurements
|
||||
--no-activities Skip automatic shell hook and Activities setup
|
||||
--dry-run Show commands without building or installing
|
||||
-h, --help Show this help
|
||||
|
||||
@@ -33,6 +34,7 @@ replace_portal_config=false
|
||||
offline=false
|
||||
resize_metrics=false
|
||||
dry_run=false
|
||||
no_activities=false
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--release-id|--bundle|--bundle-only)
|
||||
@@ -46,6 +48,7 @@ while (($#)); do
|
||||
--replace-portal-config) replace_portal_config=true; shift ;;
|
||||
--offline) offline=true; shift ;;
|
||||
--resize-metrics) resize_metrics=true; shift ;;
|
||||
--no-activities) no_activities=true; shift ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unknown option: $1 (see --help)" ;;
|
||||
@@ -100,8 +103,10 @@ if $dry_run; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
user_setup=(python3 "$helper" user-setup)
|
||||
if $no_activities; then user_setup+=(--no-activities); fi
|
||||
if [[ -z $bundle_only ]] && ((EUID != 0)); then
|
||||
python3 "$helper" user-setup --check
|
||||
"${user_setup[@]}" --check
|
||||
fi
|
||||
if [[ ${prepare+x} ]]; then
|
||||
for tool in cargo desktop-file-validate dbus-run-session; do
|
||||
@@ -118,7 +123,7 @@ fi
|
||||
python3 "$helper" verify "$bundle_path"
|
||||
"${installer[@]}"
|
||||
if ((EUID != 0)); then
|
||||
if ! python3 "$helper" user-setup; then
|
||||
if ! "${user_setup[@]}"; then
|
||||
echo 'System installation succeeded; user setup is incomplete.' >&2
|
||||
echo "Retry as your normal user: python3 '$helper' user-setup" >&2
|
||||
exit 1
|
||||
|
||||
@@ -6,6 +6,7 @@ import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
@@ -173,6 +174,7 @@ def validate_bundle(bundle, *, installed=False):
|
||||
# release's recorded files without requiring these later additions.
|
||||
required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png',
|
||||
'licenses/Ferese-LICENSE.txt'}
|
||||
required = {target for target in required if not target.startswith('activities/')}
|
||||
|
||||
if not required <= seen or actual != seen | {'manifest.json'}:
|
||||
fail('Bundle is incomplete or contains unlisted files')
|
||||
@@ -536,6 +538,73 @@ def system_command(args):
|
||||
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):
|
||||
if os.geteuid() == 0:
|
||||
fail('Run user-setup as your normal user')
|
||||
@@ -553,6 +622,8 @@ def user_setup(args):
|
||||
|
||||
if not args.check:
|
||||
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.')
|
||||
|
||||
|
||||
@@ -569,6 +640,8 @@ def parser():
|
||||
verify.add_argument('bundle', type=Path)
|
||||
user = commands.add_parser('user-setup')
|
||||
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'):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument('--root', type=Path, default=Path('/'), help='Destination filesystem root (for packaging/tests)')
|
||||
|
||||
@@ -182,6 +182,31 @@
|
||||
"source": "packaging/pam.d/ferese-lock.generic",
|
||||
"target": "pam/generic",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -517,6 +517,52 @@ class FrontendTest(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):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory)
|
||||
@@ -530,7 +576,7 @@ class UserSetupTest(unittest.TestCase):
|
||||
systemctl.chmod(0o755)
|
||||
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.
|
||||
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)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(override.exists())
|
||||
|
||||
@@ -34,7 +34,7 @@ impl Appearance {
|
||||
let mut theme = ferese_theme_client::service::current().presented;
|
||||
if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
|
||||
&& 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!({
|
||||
"source": document.to_string(),
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct Display {
|
||||
}
|
||||
|
||||
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 values = value.as_array().ok_or("Invalid display response")?;
|
||||
Ok(values
|
||||
|
||||
@@ -800,7 +800,7 @@ impl cosmic::Application for App {
|
||||
suffix += 1;
|
||||
}
|
||||
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(
|
||||
"theme-preview",
|
||||
serde_json::json!({"source": snapshot.source, "directory": directory}),
|
||||
|
||||
@@ -34,13 +34,14 @@ impl std::fmt::Display for StyleChoice {
|
||||
}
|
||||
}
|
||||
|
||||
const CATALOG: [CatalogItem; 12] = [
|
||||
const CATALOG: [CatalogItem; 13] = [
|
||||
CatalogItem(ItemKind::Overview),
|
||||
CatalogItem(ItemKind::Workspaces {
|
||||
style: ferese_panel::WorkspaceStyle::Dots,
|
||||
}),
|
||||
CatalogItem(ItemKind::FocusedWindow),
|
||||
CatalogItem(ItemKind::Media),
|
||||
CatalogItem(ItemKind::Activities),
|
||||
CatalogItem(ItemKind::QuickSettings),
|
||||
CatalogItem(ItemKind::Network),
|
||||
CatalogItem(ItemKind::Audio),
|
||||
@@ -197,7 +198,45 @@ impl App {
|
||||
.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(
|
||||
widget::container(appearance)
|
||||
.id("panel-appearance-settings")
|
||||
@@ -327,6 +366,18 @@ impl App {
|
||||
|
||||
fn panel_opacity_controls(&self) -> Element<'static, Message> {
|
||||
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 tokens = &self.resolved.presented.tokens;
|
||||
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::FocusedWindow => icons::DISPLAY,
|
||||
ItemKind::Media => icons::MEDIA,
|
||||
ItemKind::Activities => icons::FERESE,
|
||||
ItemKind::QuickSettings => icons::CONTROL_CENTER,
|
||||
ItemKind::Network => icons::WIFI_FULL,
|
||||
ItemKind::Audio => icons::VOLUME_HIGH,
|
||||
|
||||
@@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame};
|
||||
use crate::{App, Element, Message, panel_controls, visuals};
|
||||
|
||||
const SAMPLE_AVAILABILITY: Availability = Availability {
|
||||
activities: true,
|
||||
media: true,
|
||||
network: true,
|
||||
audio: true,
|
||||
@@ -19,6 +20,18 @@ fn available(item: &Item) -> bool {
|
||||
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 {
|
||||
pub(super) fn panel_preview(&self) -> Element<'_, Message> {
|
||||
let panel = match self.preview_panel() {
|
||||
@@ -115,14 +128,7 @@ impl App {
|
||||
groups: Vec::new(),
|
||||
scale: self.render_scale,
|
||||
high_contrast: palette.high_contrast,
|
||||
background: cosmic::iced::Color {
|
||||
a: if self.resolved.presented.accessibility.reduce_transparency {
|
||||
1.
|
||||
} else {
|
||||
panel.background_opacity.unwrap_or(palette.card.a)
|
||||
},
|
||||
..palette.card
|
||||
},
|
||||
background: panel_background(&self.resolved.presented, &panel),
|
||||
border: if panel.border {
|
||||
visuals::color(
|
||||
&self.resolved.presented.tokens.colors.border,
|
||||
@@ -194,7 +200,7 @@ impl App {
|
||||
ferese_panel::shape::ZonePosition::End,
|
||||
)))
|
||||
.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 padding = panel.resolved_geometry().inner_padding;
|
||||
let border = panel.border;
|
||||
@@ -265,10 +271,7 @@ impl App {
|
||||
groups,
|
||||
scale: self.render_scale,
|
||||
high_contrast: palette.high_contrast,
|
||||
background: cosmic::iced::Color {
|
||||
a: opacity,
|
||||
..palette.card
|
||||
},
|
||||
background,
|
||||
border: if border {
|
||||
visuals::color(
|
||||
&self.resolved.presented.tokens.colors.border,
|
||||
@@ -367,6 +370,7 @@ impl App {
|
||||
ItemKind::Workspaces { .. } => icons::OVERVIEW,
|
||||
ItemKind::FocusedWindow => icons::DISPLAY,
|
||||
ItemKind::Media => icons::MEDIA,
|
||||
ItemKind::Activities => icons::FERESE,
|
||||
ItemKind::QuickSettings => icons::CONTROL_CENTER,
|
||||
ItemKind::Network => icons::WIFI_FULL,
|
||||
ItemKind::Audio => icons::VOLUME_HIGH,
|
||||
@@ -380,6 +384,14 @@ impl App {
|
||||
let label = match (item.kind, representation) {
|
||||
(ItemKind::Workspaces { .. }, _) => "1 2 3",
|
||||
(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::Compact) => "Song title",
|
||||
(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::{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)]
|
||||
struct Bounds {
|
||||
targets: Vec<(widget::Id, ItemId)>,
|
||||
|
||||
@@ -232,6 +232,33 @@ pub fn reload_running() -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn edits_preserve_comments_custom_keys_and_other_rules() {
|
||||
let source = r#"// personal theme
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-activities = { path = "../../crates/ferese-activities" }
|
||||
ferese-layout = { path = "../../crates/ferese-layout" }
|
||||
ferese-panel = { path = "../../crates/ferese-panel" }
|
||||
ferese-animation = { path = "../../crates/ferese-animation" }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -54,6 +54,7 @@ impl FereseShell {
|
||||
let effects = output.and_then(|output| output.effects.as_ref());
|
||||
let compositor_material = effects.is_some();
|
||||
let availability = Availability {
|
||||
activities: self.activity_available(id),
|
||||
media: self.media.snapshot.selected.is_some(),
|
||||
network: self.status.network.is_some(),
|
||||
audio: self.status.audio.is_some(),
|
||||
@@ -340,6 +341,9 @@ impl FereseShell {
|
||||
.into();
|
||||
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::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected),
|
||||
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
|
||||
|
||||
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ShellConfig {
|
||||
pub(crate) activities: ferese_config::activities::Settings,
|
||||
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
|
||||
pub(crate) animations: crate::motion::Settings,
|
||||
pub(crate) font_family: Option<String>,
|
||||
@@ -22,6 +23,7 @@ impl Default for ShellConfig {
|
||||
fn default() -> Self {
|
||||
let status = StatusConfig::default();
|
||||
Self {
|
||||
activities: Default::default(),
|
||||
notifications: Default::default(),
|
||||
animations: Default::default(),
|
||||
font_family: None,
|
||||
@@ -183,6 +185,8 @@ pub(crate) enum WallpaperMode {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct FereseConfig {
|
||||
#[serde(default)]
|
||||
activities: ferese_config::activities::Settings,
|
||||
#[serde(default)]
|
||||
panels: Option<Vec<crate::panel::Panel>>,
|
||||
#[serde(default)]
|
||||
@@ -398,6 +402,7 @@ fn parse_document(
|
||||
theme.bar_radius = theme.material_radius;
|
||||
|
||||
Ok(ShellConfig {
|
||||
activities: config.activities,
|
||||
notifications: config.notifications,
|
||||
animations: config.animations,
|
||||
font_family: config.theme.typography.font_family,
|
||||
|
||||
@@ -211,10 +211,6 @@ impl ShellControl {
|
||||
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) {
|
||||
let (hi, lo) = split_id(id);
|
||||
self.shell.activate_window(hi, lo);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod activities;
|
||||
mod bar;
|
||||
mod surfaces;
|
||||
|
||||
@@ -161,6 +162,7 @@ struct FereseShell {
|
||||
status: status::Snapshot,
|
||||
recorder: recording::Recorder,
|
||||
media: media::Model,
|
||||
activities: activities::Model,
|
||||
calendar_offset: i32,
|
||||
status_error: Option<status::ActionError>,
|
||||
theme_error: Option<String>,
|
||||
@@ -219,6 +221,15 @@ enum Message {
|
||||
MediaAction(serde_json::Value),
|
||||
MediaCompleted(Result<(), String>),
|
||||
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,
|
||||
MediaSeek(u64),
|
||||
MediaSeekCommit,
|
||||
@@ -294,14 +305,17 @@ impl cosmic::Application for FereseShell {
|
||||
// The compositor reports the complete configuration error through its
|
||||
// accepted-theme snapshot. Use the local warning only when standalone.
|
||||
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 {
|
||||
core,
|
||||
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
|
||||
notifications: notification_center,
|
||||
notification_surface: None,
|
||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||
status: status::Snapshot::default(),
|
||||
recorder: recording::Recorder::default(),
|
||||
media: media::Model::default(),
|
||||
activities: activities::Model::default(),
|
||||
calendar_offset: 0,
|
||||
status_error: None,
|
||||
theme_error: None,
|
||||
@@ -366,6 +380,8 @@ impl cosmic::Application for FereseShell {
|
||||
_ => None,
|
||||
}),
|
||||
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_tick_subscription().map(|_| Message::MediaTick),
|
||||
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) => {
|
||||
self.media.receive(snapshot);
|
||||
self.refresh_media_art(
|
||||
@@ -488,7 +517,7 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::ThemeMode(mode) => cosmic::task::future(async 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("theme-set-mode", serde_json::json!({"mode": mode}))
|
||||
.map(|_| ())
|
||||
@@ -578,24 +607,35 @@ impl cosmic::Application for FereseShell {
|
||||
dnd: self.notifications.dnd,
|
||||
});
|
||||
}
|
||||
let hub = notice.map_or_else(Task::none, |notice| self.hub_notice(notice));
|
||||
Task::batch([hub, self.sync_hub_notices(), self.sync_notification_surface()])
|
||||
let hub = notice
|
||||
.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 => {
|
||||
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) => {
|
||||
self.notifications.close(id, 2);
|
||||
self.sync_notification_surface()
|
||||
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
|
||||
}
|
||||
Message::RemoveNotification(id) => {
|
||||
self.notifications.dismiss(id);
|
||||
self.sync_notification_surface()
|
||||
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
|
||||
}
|
||||
Message::InvokeNotification(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) => {
|
||||
self.notifications.hover(id, hovered);
|
||||
@@ -608,11 +648,11 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::RemoveNotificationGroup(app) => {
|
||||
self.notifications.dismiss_group(&app);
|
||||
self.sync_notification_surface()
|
||||
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
|
||||
}
|
||||
Message::ClearNotifications => {
|
||||
self.notifications.clear();
|
||||
self.sync_notification_surface()
|
||||
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
|
||||
}
|
||||
Message::AnimateMenu => self.animate_menu(),
|
||||
Message::PanelAction(message) => {
|
||||
@@ -956,6 +996,8 @@ impl FereseShell {
|
||||
let geometry = config.panels[0].resolved_geometry();
|
||||
let geometry_changed = edge_changed || old_geometry != geometry;
|
||||
self.notifications.configure(config.notifications.clone());
|
||||
self.notifications
|
||||
.configure_activity_bridge(config.activities.sources.notification_bridge);
|
||||
self.config = config;
|
||||
if composition_changed || geometry_changed {
|
||||
for entry in &mut self.outputs {
|
||||
@@ -1098,6 +1140,7 @@ mod tests {
|
||||
status: Default::default(),
|
||||
recorder: Default::default(),
|
||||
media: Default::default(),
|
||||
activities: Default::default(),
|
||||
calendar_offset: 0,
|
||||
status_error: None,
|
||||
theme_error: None,
|
||||
|
||||
@@ -106,7 +106,7 @@ impl Model {
|
||||
self.error = None;
|
||||
cosmic::task::future(async 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(|_| ())
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -13,7 +13,7 @@ pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
|
||||
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::theme::Connection::connect).await;
|
||||
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;
|
||||
@@ -93,7 +93,7 @@ mod tests {
|
||||
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();
|
||||
let listener = UnixListener::bind(path).unwrap();
|
||||
let (watch_send, watch_receive) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -269,6 +269,7 @@ struct Toast {
|
||||
}
|
||||
|
||||
pub struct Center {
|
||||
pub activity_bridge: bool,
|
||||
config: ferese_config::notifications::NotificationConfig,
|
||||
service: Service,
|
||||
pub ready: bool,
|
||||
@@ -285,6 +286,7 @@ pub struct Center {
|
||||
impl Center {
|
||||
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
|
||||
Self {
|
||||
activity_bridge: false,
|
||||
service: Service::start(&config),
|
||||
ready: false,
|
||||
dnd: config.do_not_disturb,
|
||||
@@ -306,6 +308,12 @@ impl Center {
|
||||
.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) {
|
||||
if self.config.do_not_disturb != config.do_not_disturb {
|
||||
self.dnd = config.do_not_disturb;
|
||||
@@ -516,7 +524,7 @@ impl Center {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
self.toasts.push_back(Toast {
|
||||
id: notice.id,
|
||||
@@ -553,7 +561,7 @@ impl Center {
|
||||
}
|
||||
self.toasts.retain(|toast| !ids.contains(&toast.id));
|
||||
}
|
||||
if !show {
|
||||
if !show && !self.activity_bridge {
|
||||
self.close(id, 1);
|
||||
}
|
||||
}
|
||||
@@ -562,11 +570,21 @@ impl Center {
|
||||
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> {
|
||||
self.toasts
|
||||
.iter()
|
||||
.filter(|toast| !toast.hovered && toast.closing.is_none())
|
||||
.filter_map(|toast| toast.remaining.map(|remaining| self.last_tick + remaining))
|
||||
.chain(self.hidden_deadlines().map(|(_, deadline)| deadline))
|
||||
.min()
|
||||
}
|
||||
|
||||
@@ -610,7 +628,10 @@ impl Center {
|
||||
let now = Instant::now();
|
||||
let delta = now.saturating_duration_since(self.last_tick);
|
||||
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 {
|
||||
if toast.closing.is_none()
|
||||
&& !toast.hovered
|
||||
@@ -643,6 +664,7 @@ mod tests {
|
||||
let (_, events) = tokio::sync::mpsc::channel(256);
|
||||
let (commands, receiver) = mpsc::sync_channel(256);
|
||||
let center = Center {
|
||||
activity_bridge: false,
|
||||
config: NotificationConfig::default(),
|
||||
motion: Default::default(),
|
||||
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]
|
||||
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
|
||||
let (mut center, _) = fixture();
|
||||
|
||||