//! 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 { 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 }, } #[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, 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