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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user