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
@@ -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");
|
||||
|
||||