WIP: Config API
This commit is contained in:
parent
7b367daf38
commit
e3ec7e3b7d
5 changed files with 357 additions and 0 deletions
|
|
@ -76,6 +76,7 @@ optional = true
|
|||
|
||||
[workspace]
|
||||
members = [
|
||||
"cosmic-config",
|
||||
"examples/*",
|
||||
]
|
||||
exclude = [
|
||||
|
|
|
|||
11
cosmic-config/Cargo.toml
Normal file
11
cosmic-config/Cargo.toml
Normal 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
250
cosmic-config/src/lib.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
10
examples/config/Cargo.toml
Normal file
10
examples/config/Cargo.toml
Normal 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"
|
||||
85
examples/config/src/main.rs
Normal file
85
examples/config/src/main.rs
Normal 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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue