use std::{
collections::HashSet,
ffi::OsString,
fs, io,
path::{Path, PathBuf},
sync::{Mutex, OnceLock, PoisonError},
};
use atomic_write_file::AtomicWriteFile;
use serde::Deserialize;
use thiserror::Error;
use toml_edit::{DocumentMut, Item, Table};
use super::{Config, SCHEMA_VERSION};
use crate::paths::{self, PathsError};
const CONFIG_BACKUP_GENERATIONS: usize = 5;
static BACKED_UP_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("could not resolve config path: {0}")]
Path(#[from] PathsError),
#[error("could not read config at {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not parse config at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: Box<toml::de::Error>,
},
#[error("config at {path} uses obsolete field {field} with schema_version {version}")]
ObsoleteField {
path: PathBuf,
field: String,
version: u32,
},
#[error("config at {path} changed on disk; restart OpenLogi to reload it")]
Conflict {
path: PathBuf,
},
#[error("could not write config at {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not serialize config: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("could not preserve config formatting at {path}: {source}")]
Edit {
path: PathBuf,
#[source]
source: Box<toml_edit::TomlError>,
},
#[error("config at {path} has unsupported schema_version {found}")]
UnsupportedSchemaVersion {
path: PathBuf,
found: u32,
},
}
#[derive(Debug, Clone)]
pub struct ConfigFile {
path: PathBuf,
source: Option<String>,
}
#[derive(Deserialize)]
struct ConfigHeader {
schema_version: u32,
}
impl ConfigFile {
pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
Self::load_from_path(&paths::config_path()?)
}
pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
match fs::read_to_string(path) {
Ok(source) => {
let config = parse_config(path, &source)?;
Ok((
config,
Self {
path: path.to_path_buf(),
source: Some(source),
},
))
}
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
Config::default(),
Self {
path: path.to_path_buf(),
source: None,
},
)),
Err(source) => Err(ConfigError::Read {
path: path.to_path_buf(),
source,
}),
}
}
pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
let current = match fs::read_to_string(&self.path) {
Ok(source) => Some(source),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(source) => {
return Err(ConfigError::Read {
path: self.path.clone(),
source,
});
}
};
if current != self.source {
return Err(ConfigError::Conflict {
path: self.path.clone(),
});
}
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
path: self.path.clone(),
source,
})?;
}
let body = render_config(config, self.source.as_deref(), &self.path)?;
backup_config_once(&self.path).map_err(|source| ConfigError::Write {
path: self.path.clone(),
source,
})?;
write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
path: self.path.clone(),
source,
})?;
self.source = Some(body);
Ok(())
}
}
impl Config {
pub fn load_or_default() -> Result<Self, ConfigError> {
ConfigFile::load_or_default().map(|(config, _)| config)
}
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
ConfigFile::load_from_path(path).map(|(config, _)| config)
}
pub fn save_atomic(&self) -> Result<(), ConfigError> {
if self.ephemeral {
return Ok(());
}
self.save_to_path(&paths::config_path()?)
}
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
let (_, mut file) = ConfigFile::load_from_path(path)?;
file.save(self)
}
}
fn parse_config(path: &Path, source: &str) -> Result<Config, ConfigError> {
let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
return Err(ConfigError::UnsupportedSchemaVersion {
path: path.to_path_buf(),
found: header.schema_version,
});
}
reject_obsolete_fields(path, source, header.schema_version)?;
let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
if header.schema_version <= 3 {
config.migrate_owner_locked_gestures();
}
config.schema_version = SCHEMA_VERSION;
Ok(config)
}
fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
return Ok(());
};
for (device_key, value) in devices {
let Some(device) = value.as_table() else {
continue;
};
for (field, last_version) in [
("button_bindings", 1),
("gesture_bindings", 1),
("gesture_owner", 3),
] {
if version > last_version && device.contains_key(field) {
return Err(ConfigError::ObsoleteField {
path: path.to_path_buf(),
field: format!("devices.{device_key}.{field}"),
version,
});
}
}
}
Ok(())
}
fn render_config(
config: &Config,
original: Option<&str>,
path: &Path,
) -> Result<String, ConfigError> {
let generated = toml::to_string_pretty(config)?;
let Some(original) = original else {
return Ok(generated);
};
let mut document = original
.parse::<DocumentMut>()
.map_err(|source| ConfigError::Edit {
path: path.to_path_buf(),
source: Box::new(source),
})?;
let generated = generated
.parse::<DocumentMut>()
.map_err(|source| ConfigError::Edit {
path: path.to_path_buf(),
source: Box::new(source),
})?;
reconcile_table(document.as_table_mut(), generated.as_table());
Ok(document.to_string())
}
fn reconcile_table(current: &mut Table, generated: &Table) {
let stale: Vec<String> = current
.iter()
.filter(|(key, _)| generated.get(key).is_none())
.map(|(key, _)| key.to_string())
.collect();
for key in stale {
current.remove(&key);
}
for (key, generated_item) in generated {
if let Some(current_item) = current.get_mut(key) {
reconcile_item(current_item, generated_item);
} else {
current.insert(key, generated_item.clone());
}
}
}
fn reconcile_item(current: &mut Item, generated: &Item) {
if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
reconcile_table(current, generated);
return;
}
let decor = current.as_value().map(|value| value.decor().clone());
*current = generated.clone();
if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
*value.decor_mut() = decor;
}
}
fn backup_config_once(path: &Path) -> io::Result<()> {
let backed_up = BACKED_UP_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()));
let mut backed_up = backed_up.lock().unwrap_or_else(PoisonError::into_inner);
if backed_up.contains(path) {
return Ok(());
}
match fs::metadata(path) {
Ok(_) => backup_existing_config(path)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
backed_up.insert(path.to_path_buf());
Ok(())
}
pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
let source = config_backup_path(path, generation)?;
match fs::read(&source) {
Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
}
pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
let Some(file_name) = path.file_name() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"config path has no file name",
));
};
let mut backup_name = OsString::from(file_name);
backup_name.push(format!(".backup.{generation}"));
Ok(path.with_file_name(backup_name))
}
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
#[cfg_attr(
not(unix),
expect(unused_mut, reason = "only the unix path mutates the options")
)]
let mut options = AtomicWriteFile::options();
#[cfg(unix)]
{
use atomic_write_file::unix::OpenOptionsExt as _;
use std::os::unix::fs::OpenOptionsExt as _;
options.preserve_mode(false).mode(0o600);
}
let mut file = options.open(path)?;
io::Write::write_all(&mut file, bytes)?;
file.commit()
}