diff --git a/Cargo.toml b/Cargo.toml index 07c7d87..9717672 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ optional = true [workspace] members = [ + "cosmic-config", "examples/*", ] exclude = [ diff --git a/cosmic-config/Cargo.toml b/cosmic-config/Cargo.toml new file mode 100644 index 0000000..800e410 --- /dev/null +++ b/cosmic-config/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cosmic-config" +version = "0.1.0" +edition = "2021" + +[dependencies] +atomicwrites = "0.4.0" +dirs = "4.0.0" +notify = "5.1.0" +ron = "0.8.0" +serde = "1.0.152" diff --git a/cosmic-config/src/lib.rs b/cosmic-config/src/lib.rs new file mode 100644 index 0000000..49f30ea --- /dev/null +++ b/cosmic-config/src/lib.rs @@ -0,0 +1,250 @@ +use notify::Watcher; +use serde::{de::DeserializeOwned, Serialize}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::Mutex, +}; + +#[derive(Debug)] +pub enum Error { + AtomicWrites(atomicwrites::Error), + InvalidName(String), + Io(std::io::Error), + NoConfigDirectory, + Notify(notify::Error), + Ron(ron::Error), + RonSpanned(ron::error::SpannedError), +} + +impl From> for Error { + fn from(f: atomicwrites::Error) -> Self { + Self::AtomicWrites(f) + } +} + +impl From for Error { + fn from(f: std::io::Error) -> Self { + Self::Io(f) + } +} + +impl From for Error { + fn from(f: notify::Error) -> Self { + Self::Notify(f) + } +} + +impl From for Error { + fn from(f: ron::Error) -> Self { + Self::Ron(f) + } +} + +impl From for Error { + fn from(f: ron::error::SpannedError) -> Self { + Self::RonSpanned(f) + } +} + +pub trait ConfigGet { + /// Get a configuration value + fn get(&self, key: &str) -> Result; +} + +pub trait ConfigSet { + /// Set a configuration value + fn set(&self, key: &str, value: T) -> Result<(), Error>; +} + +#[derive(Clone, Debug)] +pub struct Config { + system_path: PathBuf, + user_path: PathBuf, +} + +impl Config { + /// Get the config for the libcosmic toolkit + pub fn libcosmic() -> Result { + Self::new("com.system76.libcosmic", 1) + } + + /// Get config for the given application name and config version + // Use folder at XDG config/name for config storage, return Config if successful + //TODO: fallbacks for flatpak (HOST_XDG_CONFIG_HOME, xdg-desktop settings proxy) + pub fn new(name: &str, version: u64) -> Result { + // Get libcosmic system defaults path + //TODO: support non-UNIX OS + let cosmic_system_path = Path::new("/usr/share/cosmic"); + // Append [name]/v[version] + let system_path = cosmic_system_path.join(name).join(format!("v{}", version)); + + // Get libcosmic user configuration directory + let cosmic_user_path = dirs::config_dir() + .ok_or(Error::NoConfigDirectory)? + .join("cosmic"); + // Append [name]/v[version] + let user_path = cosmic_user_path.join(name).join(format!("v{}", version)); + + // If the app paths are children of the cosmic paths + if system_path.starts_with(&cosmic_system_path) && user_path.starts_with(&cosmic_user_path) + { + // Create app user path + fs::create_dir_all(&user_path)?; + // Return Config + Ok(Self { + system_path, + user_path, + }) + } else { + // Return error for invalid name + Err(Error::InvalidName(name.to_string())) + } + } + + // Start a transaction (to set multiple configs at the same time) + pub fn transaction<'a>(&'a self) -> ConfigTransaction<'a> { + ConfigTransaction { + config: self, + updates: Mutex::new(Vec::new()), + } + } + + // Watch keys for changes, will be triggered once per transaction + // This may end up being an mpsc channel instead of a function + // See EventHandler in the notify crate: https://docs.rs/notify/latest/notify/trait.EventHandler.html + // Having a callback allows for any application abstraction to be used + pub fn watch(&self, f: F) -> Result + // Argument is an array of all keys that changed in that specific transaction + //TODO: simplify F requirements + where + F: Fn(&Self, &[String]) + Send + Sync + 'static, + { + let watch_config = self.clone(); + let mut watcher = + notify::recommended_watcher(move |event_res: Result| { + // println!("{:#?}", event_res); + match event_res { + Ok(event) => { + let mut keys = Vec::new(); + for path in event.paths.iter() { + match path.strip_prefix(&watch_config.user_path) { + Ok(key_path) => match key_path.to_str() { + Some(key) => { + // Skip any .atomicwrite temporary files + if key.starts_with(".atomicwrite") { + continue; + } + keys.push(key.to_string()); + } + None => { + //TODO: handle errors + } + }, + Err(err) => { + //TODO: handle errors + } + } + } + if !keys.is_empty() { + f(&watch_config, &keys); + } + } + Err(err) => { + //TODO: handle errors + } + } + })?; + watcher.watch(&self.user_path, notify::RecursiveMode::NonRecursive)?; + Ok(watcher) + } + + fn default_path(&self, key: &str) -> Result { + let default_path = self.system_path.join(key); + // Ensure key path is a direct child of config directory + if default_path.parent() == Some(&self.system_path) { + Ok(default_path) + } else { + Err(Error::InvalidName(key.to_string())) + } + } + + fn key_path(&self, key: &str) -> Result { + let key_path = self.user_path.join(key); + // Ensure key path is a direct child of config directory + if key_path.parent() == Some(&self.user_path) { + Ok(key_path) + } else { + Err(Error::InvalidName(key.to_string())) + } + } +} + +// Getting any setting is available on a Config object +impl ConfigGet for Config { + //TODO: check for transaction + fn get(&self, key: &str) -> Result { + // If key path exists + let key_path = self.key_path(key)?; + let data = if key_path.is_file() { + // Load user override + fs::read_to_string(key_path)? + } else { + // Load system default + let default_path = self.default_path(key)?; + fs::read_to_string(default_path)? + }; + let t = ron::from_str(&data)?; + Ok(t) + } +} + +// Setting any setting in this way will do one transaction per set call +impl ConfigSet for Config { + fn set(&self, key: &str, value: T) -> Result<(), Error> { + // Wrap up single key/value sets in a transaction + let tx = self.transaction(); + tx.set(key, value)?; + tx.commit() + } +} + +#[must_use = "Config transaction must be committed"] +pub struct ConfigTransaction<'a> { + config: &'a Config, + //TODO: use map? + updates: Mutex>, +} + +impl<'a> ConfigTransaction<'a> { + /// Apply all pending changes from ConfigTransaction + //TODO: apply all changes at once + pub fn commit(self) -> Result<(), Error> { + let mut updates = self.updates.lock().unwrap(); + for (key_path, data) in updates.drain(..) { + atomicwrites::AtomicFile::new( + key_path, + atomicwrites::OverwriteBehavior::AllowOverwrite, + ) + .write(|file| file.write_all(data.as_bytes()))?; + } + Ok(()) + } +} + +// Setting any setting in this way will do one transaction for all settings +// when commit finishes that transaction +impl<'a> ConfigSet for ConfigTransaction<'a> { + fn set(&self, key: &str, value: T) -> Result<(), Error> { + //TODO: sanitize key (no slashes, cannot be . or ..) + let key_path = self.config.key_path(key)?; + let data = ron::to_string(&value)?; + //TODO: replace duplicates? + { + let mut updates = self.updates.lock().unwrap(); + updates.push((key_path, data)); + } + Ok(()) + } +} diff --git a/examples/config/Cargo.toml b/examples/config/Cargo.toml new file mode 100644 index 0000000..98b49b0 --- /dev/null +++ b/examples/config/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "config" +version = "0.1.0" +authors = [] +edition = "2021" +publish = false + +[dependencies] +cosmic-config = { path = "../../cosmic-config" } +ron = "0.8.0" diff --git a/examples/config/src/main.rs b/examples/config/src/main.rs new file mode 100644 index 0000000..4fad7a7 --- /dev/null +++ b/examples/config/src/main.rs @@ -0,0 +1,85 @@ +// Copyright 2022 System76 +// SPDX-License-Identifier: MPL-2.0 + +use cosmic_config::{Config, ConfigGet, ConfigSet}; + +pub fn main() { + let config = Config::new("com.system76.Example", 1).unwrap(); + + let watcher = config + .watch(|config, keys| { + println!("Changed: {:?}", keys); + for key in keys.iter() { + println!(" - {} = {:?}", key, config.get::(key)); + } + }) + .unwrap(); + + println!("Setting example-bool to true"); + println!( + "Set example-bool to true: {:?}", + config.set("example-bool", true) + ); + println!( + "Get example-bool as bool: {:?}", + config.get::("example-bool") + ); + println!( + "Get example-bool as u32: {:?}", + config.get::("example-bool") + ); + println!( + "Get example-bool as String: {:?}", + config.get::("example-bool") + ); + println!(); + + println!("Setting example-int to 1"); + println!("Set example-int to 1: {:?}", config.set("example-int", 1)); + println!( + "Get example-int as u32: {:?}", + config.get::("example-int") + ); + println!( + "Get example-int as bool: {:?}", + config.get::("example-int") + ); + println!( + "Get example-int as String: {:?}", + config.get::("example-int") + ); + println!(); + + println!("Setting example-string to \"example\""); + println!( + "Set example-string to \"example\": {:?}", + config.set("example-string", "example") + ); + println!( + "Get example-string as String: {:?}", + config.get::("example-string") + ); + println!( + "Get example-string as bool: {:?}", + config.get::("example-string") + ); + println!( + "Get example-string as u32: {:?}", + config.get::("example-string") + ); + println!(); + + println!("Create transaction"); + let tx = config.transaction(); + println!( + "Set example-bool to false: {:?}", + tx.set("example-bool", false) + ); + println!("Set example-int to 0: {:?}", tx.set("example-int", 0)); + println!( + "Set example-string to \"\": {:?}", + tx.set("example-string", "") + ); + println!("Committing transaction"); + println!("Commit transaction: {:?}", tx.commit()); +}