Publish theme snapshots through IPC from explicit live state

This commit is contained in:
Samuel Onoja committed 2026-10-03 00:05:29 +01:00
1 parent 8195b82073
commit ccf7497b6f
23 files changed
+287 -130

No files matched your search

-32
View File
@@ -10,7 +10,6 @@ use serde_json::{Value, json};
use crate::Document;
pub use ferese_theme_model::*;
pub const SCHEMA_VERSION: u32 = 2;
pub const TRANSITION_MS: u64 = 250;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -229,37 +228,6 @@ pub fn default_theme() -> ResolvedTheme {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Snapshot {
pub version: u32,
pub revision: u64,
pub mode: Mode,
pub theme: ResolvedTheme,
pub presented: ResolvedTheme,
pub warnings: Vec<String>,
pub error: Option<String>,
#[serde(default = "crate::families::builtins")]
pub families: Vec<crate::families::Family>,
#[serde(default)]
pub fallback_note: Option<String>,
}
impl Default for Snapshot {
fn default() -> Self {
Self {
version: SCHEMA_VERSION,
revision: 0,
mode: Mode::Dark,
theme: default_theme(),
presented: default_theme(),
warnings: vec![],
error: None,
families: crate::families::builtins(),
fallback_note: None,
}
}
}
#[derive(Clone)]
pub struct Candidate {
pub policy: Policy,
+1 -1
View File
@@ -8,4 +8,4 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
ferese-config = { path = "../ferese-config" }
ferese-theme-model = { path = "../ferese-theme-model" }
+92 -20
View File
@@ -4,11 +4,47 @@ use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
use ferese_config::theme::Snapshot;
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};
pub const SCHEMA_VERSION: u32 = 2;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Snapshot {
pub version: u32,
pub revision: u64,
pub mode: Mode,
pub theme: ResolvedTheme,
pub presented: ResolvedTheme,
pub warnings: Vec<String>,
pub error: Option<String>,
pub families: Vec<Family>,
#[serde(default)]
pub fallback_note: Option<String>,
}
impl Snapshot {
/// Decode older messages with the caller's catalog. Explicit empty catalogs stay empty.
pub fn decode(mut value: Value, fallback_families: impl FnOnce() -> Vec<Family>) -> Result<Self, String> {
if let Some(object) = value.as_object_mut()
&& !object.contains_key("families")
{
object.insert(
"families".into(),
serde_json::to_value(fallback_families()).map_err(|e| e.to_string())?,
);
}
let snapshot: Self = serde_json::from_value(value).map_err(|e| e.to_string())?;
if snapshot.version != SCHEMA_VERSION {
return Err("Unsupported theme snapshot version".into());
}
Ok(snapshot)
}
}
pub fn socket_path() -> io::Result<PathBuf> {
std::env::var_os("XDG_RUNTIME_DIR")
.filter(|value| !value.is_empty())
@@ -53,23 +89,17 @@ impl Connection {
response.result.ok_or_else(|| "Missing theme snapshot".into())
}
pub fn get(&mut self) -> Result<Snapshot, String> {
let snapshot: Snapshot =
serde_json::from_value(self.call("theme-get", json!({}))?).map_err(|e| e.to_string())?;
if snapshot.version != ferese_config::theme::SCHEMA_VERSION {
return Err("Unsupported theme snapshot version".into());
}
Ok(snapshot)
pub fn get(&mut self, fallback_families: impl FnOnce() -> Vec<Family>) -> Result<Snapshot, String> {
Snapshot::decode(self.call("theme-get", json!({}))?, fallback_families)
}
pub fn watch(&mut self, revision: u64) -> Result<Snapshot, String> {
pub fn watch(
&mut self,
revision: u64,
fallback_families: impl FnOnce() -> Vec<Family>,
) -> Result<Snapshot, String> {
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
let snapshot: Snapshot =
serde_json::from_value(self.call("theme-watch", json!({"since": revision}))?).map_err(|e| e.to_string())?;
if snapshot.version != ferese_config::theme::SCHEMA_VERSION {
return Err("Unsupported theme snapshot version".into());
}
Ok(snapshot)
Snapshot::decode(self.call("theme-watch", json!({"since": revision}))?, fallback_families)
}
}
@@ -81,9 +111,51 @@ impl Drop for Cancellation {
}
}
pub fn current() -> Snapshot {
Connection::connect()
.ok()
.and_then(|mut connection| connection.get().ok())
.unwrap_or_default()
#[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();
}
}
}
+1 -1
View File
@@ -391,7 +391,7 @@ mod tests {
let resolved = ferese_config::theme::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..Default::default()
..ferese_config::theme::default_theme()
};
let palette = Palette::from_resolved(&resolved);
for enabled in [false, true] {
+2 -2
View File
@@ -122,7 +122,7 @@ mod tests {
for high_contrast in [false, true] {
let mut resolved = ferese_config::theme::ResolvedTheme {
appearance,
..Default::default()
..ferese_config::theme::default_theme()
};
resolved.accessibility.increase_contrast = high_contrast;
let palette = Palette::from_resolved(&resolved);
@@ -138,7 +138,7 @@ mod tests {
let mut resolved = ferese_config::theme::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..Default::default()
..ferese_config::theme::default_theme()
};
resolved.tokens.material.style = "translucent".into();
resolved.tokens.material.opacity = 0.25;
+25 -4
View File
@@ -1,8 +1,27 @@
use cosmic::iced::futures::SinkExt;
use ferese_config::theme::Snapshot;
use ferese_ipc::theme::Snapshot;
pub fn current() -> Snapshot {
ferese_ipc::theme::current()
ferese_ipc::theme::Connection::connect()
.ok()
.and_then(|mut connection| connection.get(ferese_config::families::builtins).ok())
.unwrap_or_else(fallback)
}
/// Standalone appearance; it does not represent compositor acceptance.
pub fn fallback() -> Snapshot {
let theme = ferese_config::theme::default_theme();
Snapshot {
version: ferese_ipc::theme::SCHEMA_VERSION,
revision: 0,
mode: ferese_config::theme::Mode::Dark,
presented: theme.clone(),
theme,
warnings: Vec::new(),
error: None,
families: ferese_config::families::builtins(),
fallback_note: None,
}
}
pub fn opacity(theme: &ferese_config::theme::ResolvedTheme) -> f32 {
@@ -30,13 +49,15 @@ fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> {
};
let (send, mut receive) = tokio::sync::watch::channel(None);
tokio::task::spawn_blocking(move || {
let Ok(mut snapshot) = connection.get() else { return };
let Ok(mut snapshot) = connection.get(ferese_config::families::builtins) else {
return;
};
loop {
let revision = snapshot.revision;
if send.send(Some(snapshot)).is_err() {
return;
}
match connection.watch(revision) {
match connection.watch(revision, ferese_config::families::builtins) {
Ok(next) => snapshot = next,
Err(_) => return,
}
@@ -1,4 +1,5 @@
use ferese_config::theme::Snapshot;
use ferese_ipc::theme::Snapshot;
use ferese_theme::service::fallback;
use serde_json::{Value, json};
fn fixture() -> Value {
@@ -7,7 +8,7 @@ fn fixture() -> Value {
#[test]
fn default_snapshot_preserves_wire_values_and_environment_wallpaper() {
let mut snapshot = Snapshot::default();
let mut snapshot = fallback();
let wallpaper = Some(ferese_config::default_wallpaper().into());
assert_eq!(snapshot.theme.tokens.background.path, wallpaper);
assert_eq!(snapshot.presented.tokens.background.path, wallpaper);
@@ -21,23 +22,23 @@ fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
let mut value = fixture();
value.as_object_mut().unwrap().remove("families");
value.as_object_mut().unwrap().remove("fallback_note");
let snapshot: Snapshot = serde_json::from_value(value.clone()).unwrap();
let snapshot: Snapshot = Snapshot::decode(value.clone(), ferese_config::families::builtins).unwrap();
assert_eq!(snapshot.families, ferese_config::families::builtins());
assert_eq!(snapshot.fallback_note, None);
value["families"] = json!([]);
assert!(
serde_json::from_value::<Snapshot>(value.clone())
Snapshot::decode(value.clone(), ferese_config::families::builtins)
.unwrap()
.families
.is_empty()
);
value["families"] = Value::Null;
assert!(serde_json::from_value::<Snapshot>(value).is_err());
assert!(Snapshot::decode(value, ferese_config::families::builtins).is_err());
}
#[test]
fn offline_preview_resolves_without_changing_published_values() {
let before = Snapshot::default();
let before = fallback();
let document = ferese_config::Document::parse("theme { mode \"light\"; family \"gruvbox\"; }\n").unwrap();
let candidate = ferese_config::theme::resolve(
&document,
@@ -48,5 +49,15 @@ fn offline_preview_resolves_without_changing_published_values() {
.unwrap();
assert_eq!(candidate.theme.appearance, ferese_config::theme::Appearance::Light);
assert_eq!(candidate.theme.tokens.colors.surface_base, "#FBF1C7");
assert_eq!(before, Snapshot::default());
assert_eq!(before, fallback());
}
#[test]
fn complete_snapshots_do_not_resolve_fallbacks_and_future_versions_are_rejected() {
let value = fixture();
let snapshot = Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
assert_eq!(serde_json::to_value(snapshot).unwrap(), value);
let mut future = value;
future["version"] = json!(ferese_ipc::theme::SCHEMA_VERSION + 1);
assert_eq!(Snapshot::decode(future, Vec::new).unwrap_err(), "Unsupported theme snapshot version");
}