WIP: Config API

This commit is contained in:
Jeremy Soller 2023-03-05 10:44:04 -07:00
parent 7b367daf38
commit e3ec7e3b7d
5 changed files with 357 additions and 0 deletions

View file

@ -76,6 +76,7 @@ optional = true
[workspace]
members = [
"cosmic-config",
"examples/*",
]
exclude = [

11
cosmic-config/Cargo.toml Normal file
View file

@ -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"

250
cosmic-config/src/lib.rs Normal file
View file

@ -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<std::io::Error>),
InvalidName(String),
Io(std::io::Error),
NoConfigDirectory,
Notify(notify::Error),
Ron(ron::Error),
RonSpanned(ron::error::SpannedError),
}
impl From<atomicwrites::Error<std::io::Error>> for Error {
fn from(f: atomicwrites::Error<std::io::Error>) -> Self {
Self::AtomicWrites(f)
}
}
impl From<std::io::Error> for Error {
fn from(f: std::io::Error) -> Self {
Self::Io(f)
}
}
impl From<notify::Error> for Error {
fn from(f: notify::Error) -> Self {
Self::Notify(f)
}
}
impl From<ron::Error> for Error {
fn from(f: ron::Error) -> Self {
Self::Ron(f)
}
}
impl From<ron::error::SpannedError> for Error {
fn from(f: ron::error::SpannedError) -> Self {
Self::RonSpanned(f)
}
}
pub trait ConfigGet {
/// Get a configuration value
fn get<T: DeserializeOwned>(&self, key: &str) -> Result<T, Error>;
}
pub trait ConfigSet {
/// Set a configuration value
fn set<T: Serialize>(&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, Error> {
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<Self, Error> {
// 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<F>(&self, f: F) -> Result<notify::RecommendedWatcher, Error>
// 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<notify::Event, notify::Error>| {
// 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<PathBuf, Error> {
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<PathBuf, Error> {
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<T: DeserializeOwned>(&self, key: &str) -> Result<T, Error> {
// 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<T: Serialize>(&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<Vec<(PathBuf, String)>>,
}
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<T: Serialize>(&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(())
}
}

View file

@ -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"

View file

@ -0,0 +1,85 @@
// Copyright 2022 System76 <info@system76.com>
// 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::<ron::Value>(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::<bool>("example-bool")
);
println!(
"Get example-bool as u32: {:?}",
config.get::<u32>("example-bool")
);
println!(
"Get example-bool as String: {:?}",
config.get::<String>("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::<u32>("example-int")
);
println!(
"Get example-int as bool: {:?}",
config.get::<bool>("example-int")
);
println!(
"Get example-int as String: {:?}",
config.get::<String>("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::<String>("example-string")
);
println!(
"Get example-string as bool: {:?}",
config.get::<bool>("example-string")
);
println!(
"Get example-string as u32: {:?}",
config.get::<u32>("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());
}