From ccf7497b6fa4d79f1131b141f28caa329cc5784e Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 3 Oct 2026 00:05:29 +0100 Subject: [PATCH] Publish theme snapshots through IPC from explicit live state --- Cargo.lock | 4 +- compositor/src/reload.rs | 20 +-- compositor/src/theme.rs | 143 +++++++++++++----- crates/ferese-config/src/theme.rs | 32 ---- crates/ferese-ipc/Cargo.toml | 2 +- crates/ferese-ipc/src/theme.rs | 112 +++++++++++--- crates/ferese-theme/src/controls.rs | 2 +- crates/ferese-theme/src/palette.rs | 4 +- crates/ferese-theme/src/service.rs | 29 +++- .../tests/fixtures/theme-v2.json | 0 .../tests/theme_wire.rs | 25 ++- feresectl/Cargo.toml | 1 + feresectl/src/main.rs | 9 +- portal/src/consent.rs | 2 +- portal/src/picker.rs | 2 +- portal/src/settings.rs | 12 +- scripts/tests/test_component_boundaries.py | 3 + shell/ferese-lock/src/main.rs | 2 +- shell/ferese-polkit-agent/Cargo.toml | 1 + shell/ferese-polkit-agent/src/prompt.rs | 2 +- shell/ferese-settings/src/main.rs | 4 +- shell/ferese-settings/src/visuals.rs | 4 +- shell/ferese-shell/src/main.rs | 2 +- 23 files changed, 287 insertions(+), 130 deletions(-) rename crates/{ferese-config => ferese-theme}/tests/fixtures/theme-v2.json (100%) rename crates/{ferese-config => ferese-theme}/tests/theme_wire.rs (65%) diff --git a/Cargo.lock b/Cargo.lock index 5316ae2..6fe35f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1701,7 +1701,7 @@ dependencies = [ name = "ferese-ipc" version = "0.1.0" dependencies = [ - "ferese-config", + "ferese-theme-model", "serde", "serde_json", ] @@ -1734,6 +1734,7 @@ name = "ferese-polkit-agent" version = "0.1.0" dependencies = [ "ferese-config", + "ferese-ipc", "ferese-theme", "glib", "libc", @@ -1823,6 +1824,7 @@ dependencies = [ name = "feresectl" version = "0.1.0" dependencies = [ + "ferese-config", "ferese-ipc", "libc", "serde_json", diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index af6beff..31b9a6d 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -297,13 +297,13 @@ impl crate::Ferese { } = prepared; // Identical watcher/IPC reloads do not restart a theme transition. if self.config_source.as_ref() == Some(&source) - && candidate.theme == self.theme_engine.snapshot.theme - && candidate.families == self.theme_engine.snapshot.families - && candidate.policy.mode == self.theme_engine.snapshot.mode - && candidate.warnings == self.theme_engine.snapshot.warnings - && candidate.fallback_note == self.theme_engine.snapshot.fallback_note + && candidate.theme == self.theme_engine.live.theme + && candidate.families == self.theme_engine.live.families + && candidate.policy.mode == self.theme_engine.live.mode + && candidate.warnings == self.theme_engine.live.warnings + && candidate.fallback_note == self.theme_engine.live.fallback_note && self.theme_engine.matches_files(&candidate.files) - && self.theme_engine.snapshot.error.is_none() + && self.theme_engine.live.error.is_none() { if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) { tracing::warn!(%error, "cannot arm appearance schedule"); @@ -433,7 +433,7 @@ mod tests { } else { std::fs::remove_file(&path).unwrap(); } - let before = state.theme_engine.snapshot.revision; + let before = state.theme_engine.revision; let (reply, received) = std::sync::mpsc::sync_channel(1); state.queue_config_reload(index == 1, Some((42, reply))).unwrap(); let deadline = Instant::now() + Duration::from_secs(5); @@ -452,7 +452,7 @@ mod tests { ); assert_eq!(state.config_source.as_deref(), Some(valid)); if source == Some(valid) && before > 0 { - assert_eq!(state.theme_engine.snapshot.revision, before); + assert_eq!(state.theme_engine.revision, before); } } } @@ -491,8 +491,8 @@ mod tests { let display = smithay::reexports::wayland_server::Display::new().unwrap(); let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap(); state.config_source = Some(source.into()); - state.theme_engine.snapshot.theme = candidate.theme.clone(); - state.theme_engine.snapshot.presented = candidate.theme; + state.theme_engine.live.theme = candidate.theme.clone(); + state.theme_engine.live.presented = candidate.theme; for enabled in [false, true, false] { let source = format!("appearance {{ inactive-dim {{ enabled #{enabled}; amount 0.25; }}; }}"); diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index 8990742..a4eefd3 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -9,8 +9,11 @@ use calloop::generic::Generic; use calloop::timer::{TimeoutAction, Timer}; use calloop::{Interest, Mode as PollMode, PostAction}; use ferese_config::Document; -use ferese_config::theme::{Candidate, Mode, Snapshot, TRANSITION_MS, resolve_with_context}; -use ferese_ipc::Response; +use ferese_config::theme::{Candidate, Mode, TRANSITION_MS, resolve_with_context}; +use ferese_ipc::{ + Response, + theme::{SCHEMA_VERSION, Snapshot}, +}; use jiff::Timestamp; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{Value, json}; @@ -120,9 +123,35 @@ struct Transition { previous_wallpaper: Option, } +pub(crate) struct LiveTheme { + pub(crate) mode: Mode, + pub(crate) theme: ferese_config::theme::ResolvedTheme, + pub(crate) presented: ferese_config::theme::ResolvedTheme, + pub(crate) warnings: Vec, + pub(crate) error: Option, + pub(crate) families: Vec, + pub(crate) fallback_note: Option, +} + +impl Default for LiveTheme { + fn default() -> Self { + let theme = ferese_config::theme::default_theme(); + Self { + mode: Mode::Dark, + presented: theme.clone(), + theme, + warnings: Vec::new(), + error: None, + families: ferese_config::families::builtins(), + fallback_note: None, + } + } +} + #[derive(Default)] pub(crate) struct Engine { - pub(crate) snapshot: Snapshot, + pub(crate) live: LiveTheme, + pub(crate) revision: u64, waiters: HashMap)>, watcher: Option, watched_directories: HashSet, @@ -143,7 +172,7 @@ impl Engine { } pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender) { - if since != self.snapshot.revision { + if since != self.revision { let _ = response.try_send(Response::success(id, self.value())); } else { self.waiters.insert(owner, (id, response)); @@ -155,11 +184,22 @@ impl Engine { } pub(crate) fn value(&self) -> Value { - serde_json::to_value(&self.snapshot).expect("serializable theme snapshot") + let snapshot = Snapshot { + version: SCHEMA_VERSION, + revision: self.revision, + mode: self.live.mode, + theme: self.live.theme.clone(), + presented: self.live.presented.clone(), + warnings: self.live.warnings.clone(), + error: self.live.error.clone(), + families: self.live.families.clone(), + fallback_note: self.live.fallback_note.clone(), + }; + serde_json::to_value(snapshot).expect("serializable theme snapshot") } fn publish(&mut self) { - self.snapshot.revision = self.snapshot.revision.wrapping_add(1); + self.revision = self.revision.wrapping_add(1); let value = self.value(); for (_, (id, response)) in self.waiters.drain() { let _ = response.try_send(Response::success(id, value.clone())); @@ -167,9 +207,9 @@ impl Engine { } pub(crate) fn reject(&mut self, error: String) { - if self.snapshot.error.as_ref() != Some(&error) { + if self.live.error.as_ref() != Some(&error) { tracing::warn!(%error, "theme reload rejected; retaining active theme"); - self.snapshot.error = Some(error); + self.live.error = Some(error); self.publish(); } } @@ -341,12 +381,12 @@ pub(crate) fn init( state.theme_engine.active_files = candidate.files.clone(); state.theme_engine.watch_files(&candidate.files); state.theme_engine.arm_clock(candidate.next_transition)?; - state.theme_engine.snapshot.families = candidate.families; - state.theme_engine.snapshot.fallback_note = candidate.fallback_note; - state.theme_engine.snapshot.mode = candidate.policy.mode; - state.theme_engine.snapshot.theme = candidate.theme.clone(); - state.theme_engine.snapshot.presented = candidate.theme; - state.theme_engine.snapshot.warnings = candidate.warnings; + state.theme_engine.live.families = candidate.families; + state.theme_engine.live.fallback_note = candidate.fallback_note; + state.theme_engine.live.mode = candidate.policy.mode; + state.theme_engine.live.theme = candidate.theme.clone(); + state.theme_engine.live.presented = candidate.theme; + state.theme_engine.live.warnings = candidate.warnings; state.theme_engine.publish(); Ok(()) } @@ -364,17 +404,17 @@ impl Ferese { if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) { tracing::warn!(%error, "cannot arm appearance schedule"); } - if candidate.theme == self.theme_engine.snapshot.theme && self.theme_engine.pending.is_none() { - let changed = self.theme_engine.snapshot.mode != candidate.policy.mode - || self.theme_engine.snapshot.warnings != candidate.warnings - || self.theme_engine.snapshot.error.is_some() - || self.theme_engine.snapshot.families != candidate.families - || self.theme_engine.snapshot.fallback_note != candidate.fallback_note; - self.theme_engine.snapshot.families = candidate.families; - self.theme_engine.snapshot.fallback_note = candidate.fallback_note; - self.theme_engine.snapshot.mode = candidate.policy.mode; - self.theme_engine.snapshot.warnings = candidate.warnings; - self.theme_engine.snapshot.error = None; + if candidate.theme == self.theme_engine.live.theme && self.theme_engine.pending.is_none() { + let changed = self.theme_engine.live.mode != candidate.policy.mode + || self.theme_engine.live.warnings != candidate.warnings + || self.theme_engine.live.error.is_some() + || self.theme_engine.live.families != candidate.families + || self.theme_engine.live.fallback_note != candidate.fallback_note; + self.theme_engine.live.families = candidate.families; + self.theme_engine.live.fallback_note = candidate.fallback_note; + self.theme_engine.live.mode = candidate.policy.mode; + self.theme_engine.live.warnings = candidate.warnings; + self.theme_engine.live.error = None; if changed { self.theme_engine.publish(); } @@ -432,7 +472,7 @@ impl Ferese { } fn theme_transition_duration(&self) -> Duration { - if self.theme_engine.snapshot.theme.reduced_motion { + if self.theme_engine.live.theme.reduced_motion { Duration::ZERO } else { self.animation_duration(Duration::from_millis(TRANSITION_MS)) @@ -473,16 +513,16 @@ impl Ferese { } if start { let pending = self.theme_engine.pending.take().unwrap(); - let from = self.theme_engine.snapshot.presented.clone(); + let from = self.theme_engine.live.presented.clone(); let previous_wallpaper = pending .wallpaper .map(|next| std::mem::replace(&mut self.wallpaper, next)); - self.theme_engine.snapshot.families = pending.candidate.families; - self.theme_engine.snapshot.fallback_note = pending.candidate.fallback_note; - self.theme_engine.snapshot.mode = pending.candidate.policy.mode; - self.theme_engine.snapshot.theme = pending.candidate.theme; - self.theme_engine.snapshot.warnings = pending.candidate.warnings; - self.theme_engine.snapshot.error = None; + self.theme_engine.live.families = pending.candidate.families; + self.theme_engine.live.fallback_note = pending.candidate.fallback_note; + self.theme_engine.live.mode = pending.candidate.policy.mode; + self.theme_engine.live.theme = pending.candidate.theme; + self.theme_engine.live.warnings = pending.candidate.warnings; + self.theme_engine.live.error = None; self.theme_engine.transition = Some(Transition { from, started: now, @@ -500,7 +540,7 @@ impl Ferese { if frame_due && let Some(transition) = &self.theme_engine.transition { self.theme_engine.last_frame = Some(now); let progress = transition_progress(now.saturating_duration_since(transition.started), duration); - let target = &self.theme_engine.snapshot.theme; + let target = &self.theme_engine.live.theme; let complete = progress >= 1.; let presented = if complete { target.clone() @@ -508,7 +548,7 @@ impl Ferese { transition.from.transition(target, progress) }; self.theme_settings = Config::resolved_theme_settings(&presented).expect("validated resolved theme"); - self.theme_engine.snapshot.presented = presented; + self.theme_engine.live.presented = presented; self.theme_engine.publish(); if complete { @@ -582,6 +622,39 @@ impl Ferese { mod tests { use super::*; + #[test] + fn publication_projects_live_values_and_preserves_revision_semantics() { + let mut engine = Engine::default(); + let original = engine.value(); + engine.live.mode = Mode::Light; + engine.live.theme = ferese_config::theme::resolve( + &Document::parse("theme { mode \"light\"; }\n").unwrap(), + Path::new("/unused"), + "2026-10-02T12:00:00Z".parse().unwrap(), + |_| unreachable!(), + ) + .unwrap() + .theme; + // The target can advance while presented pixels still describe the old theme. + let (send, receive) = mpsc::sync_channel(1); + engine.watch(1, 42, 0, send); + assert!(receive.try_recv().is_err()); + engine.publish(); + let response = receive.try_recv().unwrap(); + assert_eq!(response.id, 42); + let value = response.result.unwrap(); + assert_eq!(value["version"], SCHEMA_VERSION); + assert_eq!(value["revision"], 1); + assert_eq!(value["mode"], "light"); + assert_eq!(value["theme"]["appearance"], "light"); + assert_eq!(value["presented"], original["presented"]); + engine.reject("invalid configuration".into()); + assert_eq!(engine.revision, 2); + assert_eq!(engine.value()["theme"], value["theme"]); + engine.reject("invalid configuration".into()); + assert_eq!(engine.revision, 2); + } + #[test] fn theme_and_wallpaper_share_scaled_progress_and_instant_completion() { let elapsed = Duration::from_millis(50); diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index ba3e9ea..2682bf9 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -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, - pub error: Option, - #[serde(default = "crate::families::builtins")] - pub families: Vec, - #[serde(default)] - pub fallback_note: Option, -} - -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, diff --git a/crates/ferese-ipc/Cargo.toml b/crates/ferese-ipc/Cargo.toml index c135d24..fa1f7c0 100644 --- a/crates/ferese-ipc/Cargo.toml +++ b/crates/ferese-ipc/Cargo.toml @@ -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" } diff --git a/crates/ferese-ipc/src/theme.rs b/crates/ferese-ipc/src/theme.rs index 5fbac16..e156f51 100644 --- a/crates/ferese-ipc/src/theme.rs +++ b/crates/ferese-ipc/src/theme.rs @@ -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, + pub error: Option, + pub families: Vec, + #[serde(default)] + pub fallback_note: Option, +} + +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) -> Result { + 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 { 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 { - 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) -> Result { + Snapshot::decode(self.call("theme-get", json!({}))?, fallback_families) } - pub fn watch(&mut self, revision: u64) -> Result { + pub fn watch( + &mut self, + revision: u64, + fallback_families: impl FnOnce() -> Vec, + ) -> Result { 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::(&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(); + } + } } diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index caf3646..9c599e0 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -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] { diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs index 54f2138..5af2db3 100644 --- a/crates/ferese-theme/src/palette.rs +++ b/crates/ferese-theme/src/palette.rs @@ -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; diff --git a/crates/ferese-theme/src/service.rs b/crates/ferese-theme/src/service.rs index 09c30c4..4561e98 100644 --- a/crates/ferese-theme/src/service.rs +++ b/crates/ferese-theme/src/service.rs @@ -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 { }; 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, } diff --git a/crates/ferese-config/tests/fixtures/theme-v2.json b/crates/ferese-theme/tests/fixtures/theme-v2.json similarity index 100% rename from crates/ferese-config/tests/fixtures/theme-v2.json rename to crates/ferese-theme/tests/fixtures/theme-v2.json diff --git a/crates/ferese-config/tests/theme_wire.rs b/crates/ferese-theme/tests/theme_wire.rs similarity index 65% rename from crates/ferese-config/tests/theme_wire.rs rename to crates/ferese-theme/tests/theme_wire.rs index 452935a..a50661c 100644 --- a/crates/ferese-config/tests/theme_wire.rs +++ b/crates/ferese-theme/tests/theme_wire.rs @@ -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::(value.clone()) + Snapshot::decode(value.clone(), ferese_config::families::builtins) .unwrap() .families .is_empty() ); value["families"] = Value::Null; - assert!(serde_json::from_value::(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"); } diff --git a/feresectl/Cargo.toml b/feresectl/Cargo.toml index 738a259..a5666eb 100644 --- a/feresectl/Cargo.toml +++ b/feresectl/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-config = { path = "../crates/ferese-config" } ferese-ipc = { path = "../crates/ferese-ipc" } serde_json.workspace = true libc.workspace = true diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 986f9a6..ab2f59a 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -50,13 +50,16 @@ fn theme_command(args: Vec) -> Result<(), Box> { let mut connection = ferese_ipc::theme::Connection::connect()?; match args.as_slice() { [command] if command == "get" || command == "status" => { - println!("{}", serde_json::to_string_pretty(&connection.get()?)?); + println!( + "{}", + serde_json::to_string_pretty(&connection.get(ferese_config::families::builtins)?)? + ); } [command] if command == "subscribe" => { - let mut snapshot = connection.get()?; + let mut snapshot = connection.get(ferese_config::families::builtins)?; loop { println!("{}", serde_json::to_string(&snapshot)?); - snapshot = connection.watch(snapshot.revision)?; + snapshot = connection.watch(snapshot.revision, ferese_config::families::builtins)?; } } [command, mode] if command == "mode" && matches!(mode.as_str(), "light" | "dark" | "auto") => { diff --git a/portal/src/consent.rs b/portal/src/consent.rs index 9859cc0..cc12700 100644 --- a/portal/src/consent.rs +++ b/portal/src/consent.rs @@ -29,7 +29,7 @@ pub(crate) struct ShortcutField { #[derive(Clone, Debug)] enum Message { - ThemeChanged(Box), + ThemeChanged(Box), Opened(cosmic::iced::window::Id), Attached(crate::parent::Attachment), Accept, diff --git a/portal/src/picker.rs b/portal/src/picker.rs index c75550d..bf371a4 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -52,7 +52,7 @@ pub(crate) struct Selection { #[derive(Clone, Debug)] enum Message { - ThemeChanged(Box), + ThemeChanged(Box), WindowOpened(cosmic::iced::window::Id), Attached(crate::parent::Attachment), Select(usize), diff --git a/portal/src/settings.rs b/portal/src/settings.rs index 6be146d..c6239e1 100644 --- a/portal/src/settings.rs +++ b/portal/src/settings.rs @@ -55,7 +55,7 @@ impl Settings { pub(crate) fn new() -> Self { let theme = ferese_ipc::theme::Connection::connect() .ok() - .and_then(|mut connection| connection.get().ok()) + .and_then(|mut connection| connection.get(ferese_config::families::builtins).ok()) .map(|snapshot| snapshot.theme); Self(Arc::new(RwLock::new(initial_appearance(theme.as_ref(), load_config)))) } @@ -84,13 +84,15 @@ impl Settings { { let (send, mut receive) = tokio::sync::mpsc::channel(1); 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.blocking_send(snapshot).is_err() { return; } - match connection.watch(revision) { + match connection.watch(revision, ferese_config::families::builtins) { Ok(next) => snapshot = next, Err(_) => return, } @@ -236,7 +238,7 @@ mod tests { fn ipc_appearance_has_priority_over_disk_fallback() { let theme = ResolvedTheme { appearance: ThemeAppearance::Light, - ..Default::default() + ..ferese_config::theme::default_theme() }; assert_eq!( initial_appearance(Some(&theme), || panic!("disk fallback read with live IPC")), @@ -284,7 +286,7 @@ mod tests { fn reports_ferese_theme_and_reduced_motion() { let mut resolved = ResolvedTheme { appearance: ThemeAppearance::Light, - ..Default::default() + ..ferese_config::theme::default_theme() }; resolved.tokens.colors.accent = "#ff8000".into(); resolved.reduced_motion = true; diff --git a/scripts/tests/test_component_boundaries.py b/scripts/tests/test_component_boundaries.py index b248230..d270a43 100644 --- a/scripts/tests/test_component_boundaries.py +++ b/scripts/tests/test_component_boundaries.py @@ -40,6 +40,9 @@ class ComponentBoundaries(unittest.TestCase): "libcosmic", "wayland-client", "tokio", "kdl", "jiff", }), dependencies) + def test_ipc_does_not_depend_on_configuration(self): + self.assertNotIn("ferese-config", reachable(dependency_graph(), "ferese-ipc")) + def test_transitive_dependencies_are_checked(self): self.assertIn("core", reachable({"shell": {"helper"}, "helper": {"core"}}, "shell")) diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index a98a58d..8f3742e 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -80,7 +80,7 @@ fn main() -> iced::Result { #[derive(Clone)] enum Message { - ThemeChanged(Box), + ThemeChanged(Box), WallpaperLoaded(u64, Result), Event(Box), Input(Zeroizing), diff --git a/shell/ferese-polkit-agent/Cargo.toml b/shell/ferese-polkit-agent/Cargo.toml index 713ddba..f4148c6 100644 --- a/shell/ferese-polkit-agent/Cargo.toml +++ b/shell/ferese-polkit-agent/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-config = { path = "../../crates/ferese-config" } ferese-theme = { path = "../../crates/ferese-theme" } glib = "0.21.5" diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index b79d230..5b1f35f 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -72,7 +72,7 @@ impl Appearance { #[derive(Clone)] enum Message { - ThemeChanged(Box), + ThemeChanged(Box), WindowOpened(cosmic::iced::window::Id), MaterialAttached(Result), Tick, diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 40283a4..6a46d95 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -81,7 +81,7 @@ enum Message { RefreshDisplays, DisplaysLoaded(Result, String>), RefreshRate(String, displays::Mode, bool), - ThemeChanged(Box), + ThemeChanged(Box), DragWindow, Page(Page), PagePresented(Page), @@ -151,7 +151,7 @@ struct App { native_palette: visuals::Palette, family_ids: std::sync::Arc>, gallery_focus: Option<(String, Option)>, - resolved: ferese_config::theme::Snapshot, + resolved: ferese_ipc::theme::Snapshot, undo_revision: u64, auto_details: bool, advanced_theme: bool, diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index fd01efe..a1d063a 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -297,7 +297,7 @@ mod tests { let theme = ferese_config::theme::ResolvedTheme { appearance: preset.appearance, tokens, - ..Default::default() + ..ferese_config::theme::default_theme() }; let palette = Palette::from_resolved(&theme); for background in [palette.background, palette.sidebar, palette.card] { @@ -348,7 +348,7 @@ mod tests { let resolved = ferese_config::theme::ResolvedTheme { appearance: item.appearance, tokens: ferese_config::theme::preset(item.id, item.appearance).unwrap(), - ..Default::default() + ..ferese_config::theme::default_theme() }; assert_eq!( Palette::from_resolved(&resolved).native_theme().cosmic().is_dark, diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index b114bff..0ed5d53 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -223,7 +223,7 @@ struct OutputSurfaces { #[derive(Clone, Debug)] enum Message { - ThemeChanged(Box), + ThemeChanged(Box), ThemeMode(ferese_config::theme::Mode), ThemeModeSet(Result<(), String>), BeginNoteEdit(String),