Publish theme snapshots through IPC from explicit live state
This commit is contained in:
1 parent
8195b82073
commit
ccf7497b6f
23 files changed
+287
-130
No files matched your search
Generated
+3
-1
@@ -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",
|
||||
|
||||
+10
-10
@@ -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; }}; }}");
|
||||
|
||||
+108
-35
@@ -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<WallpaperState>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub(crate) error: Option<String>,
|
||||
pub(crate) families: Vec<ferese_config::families::Family>,
|
||||
pub(crate) fallback_note: Option<String>,
|
||||
}
|
||||
|
||||
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<u64, (u64, mpsc::SyncSender<Response>)>,
|
||||
watcher: Option<RecommendedWatcher>,
|
||||
watched_directories: HashSet<PathBuf>,
|
||||
@@ -143,7 +172,7 @@ impl Engine {
|
||||
}
|
||||
|
||||
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -50,13 +50,16 @@ fn theme_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
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") => {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub(crate) struct ShortcutField {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
Opened(cosmic::iced::window::Id),
|
||||
Attached(crate::parent::Attachment),
|
||||
Accept,
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(crate) struct Selection {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
WindowOpened(cosmic::iced::window::Id),
|
||||
Attached(crate::parent::Attachment),
|
||||
Select(usize),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ fn main() -> iced::Result {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
WallpaperLoaded(u64, Result<widget::image::Handle, String>),
|
||||
Event(Box<Event>),
|
||||
Input(Zeroizing<String>),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Appearance {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
WindowOpened(cosmic::iced::window::Id),
|
||||
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
|
||||
Tick,
|
||||
|
||||
@@ -81,7 +81,7 @@ enum Message {
|
||||
RefreshDisplays,
|
||||
DisplaysLoaded(Result<Vec<displays::Display>, String>),
|
||||
RefreshRate(String, displays::Mode, bool),
|
||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
DragWindow,
|
||||
Page(Page),
|
||||
PagePresented(Page),
|
||||
@@ -151,7 +151,7 @@ struct App {
|
||||
native_palette: visuals::Palette,
|
||||
family_ids: std::sync::Arc<Vec<String>>,
|
||||
gallery_focus: Option<(String, Option<ferese_config::theme::Appearance>)>,
|
||||
resolved: ferese_config::theme::Snapshot,
|
||||
resolved: ferese_ipc::theme::Snapshot,
|
||||
undo_revision: u64,
|
||||
auto_details: bool,
|
||||
advanced_theme: bool,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -223,7 +223,7 @@ struct OutputSurfaces {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
ThemeMode(ferese_config::theme::Mode),
|
||||
ThemeModeSet(Result<(), String>),
|
||||
BeginNoteEdit(String),
|
||||
|
||||
Reference in new issue
Block a user