Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items * Fix activity owner monitoring and Morph list interactions * Keep activity rows stable during updates and tighten their layout * Use subtle theme tint for activity cards * Tighten activity title and progress spacing * Use theme accent for active progress rings * Fit compact activities to measured titles and badge * Expose Live Activities display controls in Settings * Open activity terminals and streamline the Morph header * Give Morph continuous icons and prioritize workspace navigation * Show activity progress inside its ring * Inset activity progress rings and strengthen their stroke * Fix activity lifecycle, command output handling and list navigation * Reduce activity update work and harden publisher lifetimes * Set up Live Activities for the detected user shell during installation * Document Activities setup, daily use and troubleshooting * Preserve Morph idle width across empty activity updates * Fix dimming flicker, hide inactive music tab and consolidate Activities docs * Fix panel clearance across styles and match previews to theme settings * Prevent panel blur from repainting neighboring islands --------- Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
1 parent
7a7df0106c
commit
6b0d903ad0
107 files changed
+9340
-291
No files matched your search
@@ -0,0 +1,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));
|
||||
}
|
||||
Reference in new issue
Block a user