Add Live Activities as configurable bar items (#30)

* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

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

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

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

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

* Prevent panel blur from repainting neighboring islands

---------

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

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
//! Drain a child's pipe without waiting for inherited descriptors indefinitely.
use std::io::{self, Read};
use std::os::fd::AsRawFd;
use std::process::ChildStdout;
use std::sync::{Arc, Mutex};
use std::time::Instant;
pub(super) type Deadline = Arc<Mutex<Option<Instant>>>;
pub(super) struct Output {
pub pipe: ChildStdout,
pub deadline: Deadline,
}
impl Read for Output {
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
if bytes.is_empty() {
return Ok(0);
}
loop {
if self
.deadline
.lock()
.unwrap()
.is_some_and(|deadline| Instant::now() >= deadline)
{
// EOF lets BufReader return its final unterminated line too.
return Ok(0);
}
let mut fd = libc::pollfd {
fd: self.pipe.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// The pipe has exactly one reader. Once readable (or hung up), a
// pipe read returns available bytes (or EOF) without filling bytes.
let result = unsafe { libc::poll(&mut fd, 1, 20) };
if result > 0 {
return self.pipe.read(bytes);
}
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! One replaceable progress update, independent of the child's stdout reader.
use super::{Id, OwnerScope, Publish, State, Value, publish_with_timeout};
use std::path::PathBuf;
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::Duration;
#[derive(Default)]
struct Pending {
units: Option<u64>,
end: Option<State>,
closed: bool,
}
#[derive(Clone, Default)]
pub(super) struct Updates(Arc<(Mutex<Pending>, Condvar)>);
impl Updates {
pub fn progress(&self, units: u64) {
let (lock, wake) = &*self.0;
let mut pending = lock.lock().unwrap();
if !pending.closed {
pending.units = Some(units);
wake.notify_one();
}
}
}
pub(super) struct Publisher {
pub updates: Updates,
finished: mpsc::Receiver<()>,
}
impl Publisher {
pub fn new(socket: PathBuf, local: String, handle: Id) -> Self {
let updates = Updates::default();
let worker = updates.clone();
let (done, finished) = mpsc::sync_channel(1);
std::thread::spawn(move || {
loop {
let (lock, wake) = &*worker.0;
let mut pending = lock.lock().unwrap();
while pending.units.is_none() && pending.end.is_none() {
pending = wake.wait(pending).unwrap();
}
let end = pending.end.take();
let units = pending.units.take();
drop(pending); // Never hold the reader's lock during socket I/O.
let request = if let Some(result) = end {
Publish::End {
local: local.clone(),
handle: Some(handle.clone()),
result,
message: None,
}
} else {
Publish::Update {
local: local.clone(),
handle: Some(handle.clone()),
value: Some(Value::Text(format!("{} units compiled", units.unwrap()))),
lease_ms: None,
}
};
let timeout = if end.is_some() {
Duration::from_secs(2)
} else {
Duration::from_millis(200)
};
let _ = publish_with_timeout(&socket, OwnerScope::Publisher, request, timeout);
if end.is_some() {
break;
}
}
let _ = done.send(());
});
Self { updates, finished }
}
pub fn finish(self, result: State) {
let (lock, wake) = &*self.updates.0;
{
let mut pending = lock.lock().unwrap();
pending.closed = true;
pending.units = None;
pending.end = Some(result);
wake.notify_one();
}
// Completion has a fixed budget even if the broker stops responding.
let _ = self.finished.recv_timeout(Duration::from_secs(2));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ferese_ipc::{Request, Response, read_frame, write_frame};
use std::os::unix::net::UnixListener;
#[test]
fn blocked_publication_coalesces_updates_and_completion_is_last() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("broker.sock");
let listener = UnixListener::bind(&socket).unwrap();
let (seen, received) = mpsc::channel();
let (release, released) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut first, _) = listener.accept().unwrap();
first.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
let request: Request = read_frame(&mut first).unwrap();
seen.send(()).unwrap();
released.recv_timeout(Duration::from_secs(1)).unwrap();
// The progress timeout can already have elapsed on a loaded host.
let _ = write_frame(&mut first, &Response::success(request.id, serde_json::json!({})));
let (mut latest, _) = listener.accept().unwrap();
let request: Request = read_frame(&mut latest).unwrap();
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
assert!(
matches!(wire.publish, Publish::Update { value: Some(Value::Text(ref text)), .. } if text == "1000 units compiled")
);
write_frame(&mut latest, &Response::success(request.id, serde_json::json!({}))).unwrap();
seen.send(()).unwrap();
let (mut end, _) = listener.accept().unwrap();
let request: Request = read_frame(&mut end).unwrap();
let wire: ferese_ipc::activities::Request = serde_json::from_value(request.args).unwrap();
assert!(matches!(
wire.publish,
Publish::End {
result: State::Done,
..
}
));
write_frame(&mut end, &Response::success(request.id, serde_json::json!({}))).unwrap();
});
let publisher = Publisher::new(
socket,
"test".into(),
Id {
owner: ferese_activities::Owner { pid: 1, birth: 1 },
local: "test".into(),
session: 1,
generation: 1,
},
);
publisher.updates.progress(1);
received.recv_timeout(Duration::from_secs(1)).unwrap();
for units in 2..=1000 {
publisher.updates.progress(units);
}
release.send(()).unwrap();
received.recv_timeout(Duration::from_secs(1)).unwrap();
publisher.finish(State::Done);
server.join().unwrap();
}
}
+124
View File
@@ -0,0 +1,124 @@
use ferese_config::{Document, file::ConfigFile, panel};
use serde_json::json;
use std::{error::Error, path::Path};
fn prepare(source: &str) -> Result<String, Box<dyn Error>> {
let mut doc = Document::parse(source)?;
if !ferese_config::activities::setup_shell_hook(&mut doc)? {
return Ok(source.into());
}
if doc.get("panels").is_none() {
doc.set("panels", serde_json::to_value(vec![panel::Panel::default()])?)?;
}
let panels: Vec<panel::Panel> = serde_json::from_value(doc.get("panels").unwrap().clone())?;
panel::validate(&panels)?;
for (index, bar) in panels.iter().enumerate() {
let groups = [&bar.start, &bar.center, &bar.end]
.into_iter()
.flat_map(|zone| &zone.groups)
.collect::<Vec<_>>();
if groups
.iter()
.flat_map(|group| &group.items)
.any(|item| item.kind == panel::ItemKind::Activities)
{
continue;
}
let mut id = "activities".to_string();
while groups.iter().flat_map(|group| &group.items).any(|item| item.id.0 == id) {
id.push('-');
}
let item = serde_json::to_value(panel::Item::new(&id, panel::ItemKind::Activities))?;
// Append within an existing group, preserving its position and every other item.
let zone = ["end", "center", "start"].into_iter().find(|zone| {
doc.get(&format!("panels.{index}.{zone}.groups"))
.and_then(serde_json::Value::as_array)
.is_some_and(|groups| !groups.is_empty())
});
if let Some(zone) = zone {
doc.add(
&format!("panels.{index}.{zone}.groups.0.items"),
item.as_object().unwrap().clone().into_iter().collect(),
)?;
} else {
doc.add(
&format!("panels.{index}.end.groups"),
vec![("id".into(), json!("activities")), ("items".into(), json!([item]))],
)?;
}
}
let panels = serde_json::from_value::<Vec<panel::Panel>>(doc.get("panels").unwrap().clone())?;
panel::validate(&panels)?;
Ok(doc.to_string())
}
pub fn configure(path: &Path) -> Result<(), Box<dyn Error>> {
let file = ConfigFile::open(path)?;
let desired = prepare(file.source())?;
if desired == file.source() {
return Ok(());
}
let backup = path.with_file_name(format!(
"{}.before-activities-{}",
path.file_name().unwrap().to_string_lossy(),
ferese_ipc::activities::now_ms()
));
file.replace(&desired, Some(&backup), |_| Ok(()))?;
println!("Configured Live Activities; previous config: {}", backup.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setup_preserves_layout_comments_rules_and_explicit_disable() {
let source =
"// keep my layout\npanel main { style morph; end { group tools { item activities kind=clock; }; }; }\n";
let next = prepare(source).unwrap();
assert!(next.contains("// keep my layout"));
let doc = Document::parse(&next).unwrap();
assert_eq!(doc.get("panels.0.style"), Some(&json!("morph")));
assert_eq!(doc.get("panels.0.end.groups.0.items.0.kind"), Some(&json!("clock")));
assert_eq!(doc.get("panels.0.end.groups.0.items.1.id"), Some(&json!("activities-")));
assert_eq!(prepare(&next).unwrap(), next);
let disabled = "activities { sources { shell-hook #false; }; }\n";
assert_eq!(prepare(disabled).unwrap(), disabled);
let hidden = "panel main { end { group tools { item tasks kind=activities visible=#false; }; }; }\n";
let hidden = Document::parse(&prepare(hidden).unwrap()).unwrap();
assert_eq!(hidden.get("panels.0.end.groups.0.items.0.visible"), Some(&json!(false)));
assert_eq!(
hidden
.get("panels.0.end.groups.0.items")
.unwrap()
.as_array()
.unwrap()
.len(),
1
);
assert!(prepare("").is_ok());
let partial = Document::parse(&prepare("activities { max-visible 1; }\n").unwrap()).unwrap();
assert_eq!(partial.get("activities.max_visible"), Some(&json!(1)));
assert_eq!(partial.get("activities.sources.shell_hook"), Some(&json!(true)));
assert!(prepare("activities { unknown 1; }").is_err());
}
#[test]
fn configuration_is_backed_up_and_invalid_input_is_not_replaced() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
std::fs::write(&path, "// original\n").unwrap();
configure(&path).unwrap();
configure(&path).unwrap();
let backups: Vec<_> = std::fs::read_dir(directory.path())
.unwrap()
.flatten()
.filter(|file| file.file_name().to_string_lossy().contains(".before-activities-"))
.collect();
assert_eq!(backups.len(), 1);
assert_eq!(std::fs::read_to_string(backups[0].path()).unwrap(), "// original\n");
std::fs::write(&path, "activities { bad #true; }\n").unwrap();
assert!(configure(&path).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "activities { bad #true; }\n");
}
}
+131
View File
@@ -0,0 +1,131 @@
//! A complete exchange has one deadline, including a full Unix listen backlog.
use std::io::{self, Read, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::{ffi::OsStrExt, net::UnixStream};
use std::path::Path;
use std::time::{Duration, Instant};
pub(super) struct Stream {
socket: UnixStream,
deadline: Instant,
}
pub(super) fn connect(path: &Path, timeout: Duration) -> io::Result<Stream> {
let deadline = Instant::now() + timeout;
let path = path.as_os_str().as_bytes();
// SAFETY: an all-zero sockaddr_un is valid to initialize before connect.
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
if path.is_empty() || path.len() >= address.sun_path.len() || path.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid activity socket path",
));
}
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
for (to, from) in address.sun_path.iter_mut().zip(path) {
*to = *from as libc::c_char;
}
// SAFETY: socket creates a new descriptor; nonblocking connect cannot wait
// indefinitely for room in a Unix listener's queue.
let fd = unsafe {
libc::socket(
libc::AF_UNIX,
libc::SOCK_STREAM | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK,
0,
)
};
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: this function takes sole ownership of the descriptor just created.
let socket = unsafe { UnixStream::from_raw_fd(fd) };
let length = std::mem::offset_of!(libc::sockaddr_un, sun_path) + path.len() + 1;
// SAFETY: address contains a checked, NUL-terminated pathname of length bytes.
if unsafe {
libc::connect(
socket.as_raw_fd(),
(&raw const address).cast(),
length as libc::socklen_t,
)
} != 0
{
// Unix connect returns EAGAIN for a full backlog. Activity publishing is
// best effort, so fail promptly instead of delaying the wrapped command.
return Err(io::Error::last_os_error());
}
socket.set_nonblocking(false)?;
Ok(Stream { socket, deadline })
}
impl Stream {
fn remaining(&self) -> io::Result<Duration> {
self.deadline
.checked_duration_since(Instant::now())
.filter(|remaining| !remaining.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "activity publication deadline expired"))
}
}
impl Read for Stream {
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
self.socket.set_read_timeout(Some(self.remaining()?))?;
self.socket.read(bytes)
}
}
impl Write for Stream {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.socket.set_write_timeout(Some(self.remaining()?))?;
self.socket.write(bytes)
}
fn flush(&mut self) -> io::Result<()> {
self.socket.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::UnixListener;
use std::sync::mpsc;
#[test]
fn trickled_response_expires_and_closes_the_socket_without_a_detached_reader() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broker.sock");
let listener = UnixListener::bind(&path).unwrap();
let (done, closed) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let _: ferese_ipc::Request = ferese_ipc::read_frame(&mut socket).unwrap();
socket.write_all(&1000_u32.to_be_bytes()).unwrap();
for _ in 0..50 {
if socket.write_all(b" ").is_err() {
done.send(()).unwrap();
return;
}
std::thread::sleep(Duration::from_millis(20));
}
});
{
let mut stream = connect(&path, Duration::from_millis(100)).unwrap();
assert!(super::super::super::request(&mut stream, "activity-get", serde_json::json!({})).is_err());
}
closed
.recv_timeout(Duration::from_millis(400))
.expect("timed-out socket remained open");
server.join().unwrap();
}
#[test]
fn full_listener_backlog_does_not_block_connect() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broker.sock");
let listener = UnixListener::bind(&path).unwrap();
// SAFETY: the listener owns this socket; reducing its backlog does not
// affect the descriptor or the sockaddr used by subsequent clients.
assert_eq!(unsafe { libc::listen(listener.as_raw_fd(), 0) }, 0);
let _first = connect(&path, Duration::from_secs(1)).unwrap();
let started = Instant::now();
assert!(connect(&path, Duration::from_secs(1)).is_err());
assert!(started.elapsed() < Duration::from_millis(200));
}
}