1use std::{
4 collections::HashSet,
5 ffi::OsString,
6 fs, io,
7 path::{Path, PathBuf},
8 sync::{Mutex, OnceLock, PoisonError},
9};
10
11use atomic_write_file::AtomicWriteFile;
12use serde::Deserialize;
13use thiserror::Error;
14use toml_edit::{DocumentMut, Item, Table};
15
16use super::{Config, SCHEMA_VERSION};
17use crate::paths::{self, PathsError};
18
19const CONFIG_BACKUP_GENERATIONS: usize = 5;
20static BACKED_UP_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
21
22#[derive(Debug, Error)]
24pub enum ConfigError {
25 #[error("could not resolve config path: {0}")]
27 Path(#[from] PathsError),
28 #[error("could not read config at {path}: {source}")]
30 Read {
31 path: PathBuf,
33 #[source]
35 source: io::Error,
36 },
37 #[error("could not parse config at {path}: {source}")]
39 Parse {
40 path: PathBuf,
42 #[source]
44 source: Box<toml::de::Error>,
45 },
46 #[error("config at {path} uses obsolete field {field} with schema_version {version}")]
48 ObsoleteField {
49 path: PathBuf,
51 field: String,
53 version: u32,
55 },
56 #[error("config at {path} changed on disk; restart OpenLogi to reload it")]
58 Conflict {
59 path: PathBuf,
61 },
62 #[error("could not write config at {path}: {source}")]
64 Write {
65 path: PathBuf,
67 #[source]
69 source: io::Error,
70 },
71 #[error("could not serialize config: {0}")]
73 Serialize(#[from] toml::ser::Error),
74 #[error("could not preserve config formatting at {path}: {source}")]
76 Edit {
77 path: PathBuf,
79 #[source]
81 source: Box<toml_edit::TomlError>,
82 },
83 #[error("config at {path} has unsupported schema_version {found}")]
85 UnsupportedSchemaVersion {
86 path: PathBuf,
88 found: u32,
90 },
91}
92
93#[derive(Debug, Clone)]
99pub struct ConfigFile {
100 path: PathBuf,
101 source: Option<String>,
102}
103
104#[derive(Deserialize)]
105struct ConfigHeader {
106 schema_version: u32,
107}
108
109impl ConfigFile {
110 pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
113 Self::load_from_path(&paths::config_path()?)
114 }
115
116 pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
118 match fs::read_to_string(path) {
119 Ok(source) => {
120 let config = parse_config(path, &source)?;
121 Ok((
122 config,
123 Self {
124 path: path.to_path_buf(),
125 source: Some(source),
126 },
127 ))
128 }
129 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
130 Config::default(),
131 Self {
132 path: path.to_path_buf(),
133 source: None,
134 },
135 )),
136 Err(source) => Err(ConfigError::Read {
137 path: path.to_path_buf(),
138 source,
139 }),
140 }
141 }
142
143 pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
145 let current = match fs::read_to_string(&self.path) {
146 Ok(source) => Some(source),
147 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
148 Err(source) => {
149 return Err(ConfigError::Read {
150 path: self.path.clone(),
151 source,
152 });
153 }
154 };
155 if current != self.source {
156 return Err(ConfigError::Conflict {
157 path: self.path.clone(),
158 });
159 }
160
161 if let Some(parent) = self.path.parent() {
162 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
163 path: self.path.clone(),
164 source,
165 })?;
166 }
167 let body = render_config(config, self.source.as_deref(), &self.path)?;
168 backup_config_once(&self.path).map_err(|source| ConfigError::Write {
169 path: self.path.clone(),
170 source,
171 })?;
172 write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
173 path: self.path.clone(),
174 source,
175 })?;
176 self.source = Some(body);
177 Ok(())
178 }
179}
180
181impl Config {
182 pub fn load_or_default() -> Result<Self, ConfigError> {
185 ConfigFile::load_or_default().map(|(config, _)| config)
186 }
187
188 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
190 ConfigFile::load_from_path(path).map(|(config, _)| config)
191 }
192
193 pub fn save_atomic(&self) -> Result<(), ConfigError> {
196 if self.ephemeral {
197 return Ok(());
198 }
199 self.save_to_path(&paths::config_path()?)
200 }
201
202 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
206 let (_, mut file) = ConfigFile::load_from_path(path)?;
207 file.save(self)
208 }
209}
210
211fn parse_config(path: &Path, source: &str) -> Result<Config, ConfigError> {
212 let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
213 path: path.to_path_buf(),
214 source: Box::new(source),
215 })?;
216 if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
217 return Err(ConfigError::UnsupportedSchemaVersion {
218 path: path.to_path_buf(),
219 found: header.schema_version,
220 });
221 }
222 reject_obsolete_fields(path, source, header.schema_version)?;
223 let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
224 path: path.to_path_buf(),
225 source: Box::new(source),
226 })?;
227 if header.schema_version <= 3 {
228 config.migrate_owner_locked_gestures();
229 }
230 config.schema_version = SCHEMA_VERSION;
231 Ok(config)
232}
233
234fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
235 let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
236 path: path.to_path_buf(),
237 source: Box::new(source),
238 })?;
239 let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
240 return Ok(());
241 };
242 for (device_key, value) in devices {
243 let Some(device) = value.as_table() else {
244 continue;
245 };
246 for (field, last_version) in [
247 ("button_bindings", 1),
248 ("gesture_bindings", 1),
249 ("gesture_owner", 3),
250 ] {
251 if version > last_version && device.contains_key(field) {
252 return Err(ConfigError::ObsoleteField {
253 path: path.to_path_buf(),
254 field: format!("devices.{device_key}.{field}"),
255 version,
256 });
257 }
258 }
259 }
260 Ok(())
261}
262
263fn render_config(
264 config: &Config,
265 original: Option<&str>,
266 path: &Path,
267) -> Result<String, ConfigError> {
268 let generated = toml::to_string_pretty(config)?;
269 let Some(original) = original else {
270 return Ok(generated);
271 };
272 let mut document = original
273 .parse::<DocumentMut>()
274 .map_err(|source| ConfigError::Edit {
275 path: path.to_path_buf(),
276 source: Box::new(source),
277 })?;
278 let generated = generated
279 .parse::<DocumentMut>()
280 .map_err(|source| ConfigError::Edit {
281 path: path.to_path_buf(),
282 source: Box::new(source),
283 })?;
284 reconcile_table(document.as_table_mut(), generated.as_table());
285 Ok(document.to_string())
286}
287
288fn reconcile_table(current: &mut Table, generated: &Table) {
289 let stale: Vec<String> = current
290 .iter()
291 .filter(|(key, _)| generated.get(key).is_none())
292 .map(|(key, _)| key.to_string())
293 .collect();
294 for key in stale {
295 current.remove(&key);
296 }
297 for (key, generated_item) in generated {
298 if let Some(current_item) = current.get_mut(key) {
299 reconcile_item(current_item, generated_item);
300 } else {
301 current.insert(key, generated_item.clone());
302 }
303 }
304}
305
306fn reconcile_item(current: &mut Item, generated: &Item) {
307 if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
308 reconcile_table(current, generated);
309 return;
310 }
311 let decor = current.as_value().map(|value| value.decor().clone());
312 *current = generated.clone();
313 if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
314 *value.decor_mut() = decor;
315 }
316}
317
318fn backup_config_once(path: &Path) -> io::Result<()> {
319 let backed_up = BACKED_UP_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()));
320 let mut backed_up = backed_up.lock().unwrap_or_else(PoisonError::into_inner);
321 if backed_up.contains(path) {
322 return Ok(());
323 }
324 match fs::metadata(path) {
325 Ok(_) => backup_existing_config(path)?,
326 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
327 Err(error) => return Err(error),
328 }
329 backed_up.insert(path.to_path_buf());
330 Ok(())
331}
332
333pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
334 for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
335 let source = config_backup_path(path, generation)?;
336 match fs::read(&source) {
337 Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
338 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
339 Err(error) => return Err(error),
340 }
341 }
342 write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
343}
344
345pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
346 let Some(file_name) = path.file_name() else {
347 return Err(io::Error::new(
348 io::ErrorKind::InvalidInput,
349 "config path has no file name",
350 ));
351 };
352 let mut backup_name = OsString::from(file_name);
353 backup_name.push(format!(".backup.{generation}"));
354 Ok(path.with_file_name(backup_name))
355}
356
357fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
358 #[cfg_attr(
359 not(unix),
360 expect(unused_mut, reason = "only the unix path mutates the options")
361 )]
362 let mut options = AtomicWriteFile::options();
363 #[cfg(unix)]
364 {
365 use atomic_write_file::unix::OpenOptionsExt as _;
366 use std::os::unix::fs::OpenOptionsExt as _;
367 options.preserve_mode(false).mode(0o600);
368 }
369 let mut file = options.open(path)?;
370 io::Write::write_all(&mut file, bytes)?;
371 file.commit()
372}