//! Versioned settings files, shared and per app (ยง18.1). use std::{ io::{self, Write as _}, path::{Path, PathBuf}, }; use serde_json::{Map, Value}; use crate::{AppId, lock::FileLock}; mod shared; pub use shared::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word}; /// The layout this build reads and writes. pub const VERSION: u64 = 1; /// One settings file; writes keep unknown fields. #[derive(Clone, Debug)] pub struct Store { path: PathBuf, } impl Store { /// Theme, language and motion, read by every app. pub fn shared() -> io::Result { let root = crate::dirs::root().map_err(io::Error::other)?; Ok(Self::at(root.join("settings.json"))) } /// One app's own preferences. pub fn app(app: AppId) -> io::Result { Ok(Self::at(crate::data_dir(app).map_err(io::Error::other)?.join("settings.json"))) } pub fn at(path: PathBuf) -> Self { Self { path } } pub fn path(&self) -> &Path { &self.path } /// The stored fields; none when the file is absent. pub fn read(&self) -> io::Result> { let bytes = match std::fs::read(&self.path) { Ok(bytes) => bytes, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Map::new()), Err(error) => return Err(error), }; let invalid = |why: String| io::Error::new(io::ErrorKind::InvalidData, format!("{}: {why}", self.path.display())); let Value::Object(fields) = serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))? else { return Err(invalid("not a JSON object".into())); }; match fields.get("version") { None => Ok(fields), Some(Value::Number(n)) if n.as_u64().is_some_and(|n| n <= VERSION) => Ok(fields), Some(Value::Number(n)) => Err(invalid(format!("version {n} is newer than this Ely reads"))), Some(_) => Err(invalid("the version is not a number".into())), } } /// Sets `key` under the lock; other fields stay. pub fn set(&self, key: &str, value: Value) -> io::Result<()> { self.set_if(key, value, || true).map(|_| ()) } /// Sets `key` if `still` holds once locked. pub fn set_if(&self, key: &str, value: Value, still: impl FnOnce() -> bool) -> io::Result { let dir = self.path.parent().ok_or_else(|| io::Error::other("settings have no folder"))?; std::fs::create_dir_all(dir)?; let _held = FileLock::take(&dir.join(".settings.lock"))?; // A newer choice made while we waited wins. if !still() { log::info!("settings: {key} overtaken while waiting"); return Ok(false); } // Read under the lock: another app may just have written. let mut fields = self.read()?; fields.insert(key.into(), value); fields.insert("version".into(), VERSION.into()); let mut temp = tempfile::NamedTempFile::new_in(dir)?; temp.write_all(&serde_json::to_vec_pretty(&Value::Object(fields))?)?; temp.as_file().sync_all()?; temp.persist(&self.path).map_err(|error| error.error)?; log::info!("settings: {key} saved in {}", self.path.display()); Ok(true) } } #[cfg(test)] mod tests;