1use std::{
4 collections::HashSet,
5 ffi::OsString,
6 fs, io,
7 path::{Path, PathBuf},
8 sync::{LazyLock, Mutex, 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: LazyLock<Mutex<HashSet<PathBuf>>> =
21 LazyLock::new(|| Mutex::new(HashSet::new()));
22
23#[derive(Debug, Error)]
25pub enum ConfigError {
26 #[error("could not resolve config path: {0}")]
28 Path(#[from] PathsError),
29 #[error("could not read config at {path}: {source}")]
31 Read {
32 path: PathBuf,
34 #[source]
36 source: io::Error,
37 },
38 #[error("could not parse config at {path}: {source}")]
40 Parse {
41 path: PathBuf,
43 #[source]
45 source: Box<toml::de::Error>,
46 },
47 #[error("config at {path} uses obsolete field {field} with schema_version {version}")]
49 ObsoleteField {
50 path: PathBuf,
52 field: String,
54 version: u32,
56 },
57 #[error("config at {path} changed on disk; restart OpenLogi to reload it")]
59 Conflict {
60 path: PathBuf,
62 },
63 #[error("could not write config at {path}: {source}")]
65 Write {
66 path: PathBuf,
68 #[source]
70 source: io::Error,
71 },
72 #[error("could not serialize config: {0}")]
74 Serialize(#[from] toml::ser::Error),
75 #[error("could not preserve config formatting at {path}: {source}")]
77 Edit {
78 path: PathBuf,
80 #[source]
82 source: Box<toml_edit::TomlError>,
83 },
84 #[error("config at {path} has unsupported schema_version {found}")]
86 UnsupportedSchemaVersion {
87 path: PathBuf,
89 found: u32,
91 },
92}
93
94#[derive(Debug, Clone)]
100pub struct ConfigFile {
101 path: PathBuf,
102 source: Option<String>,
103 migrated_from: Option<(u32, String)>,
108}
109
110#[derive(Deserialize)]
111struct ConfigHeader {
112 schema_version: u32,
113}
114
115impl ConfigFile {
116 pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
119 Self::load_from_path(&paths::config_path()?)
120 }
121
122 pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
124 match fs::read_to_string(path) {
125 Ok(source) => {
126 let (config, loaded_version) = parse_config(path, &source)?;
127 let migrated_from =
128 (loaded_version < SCHEMA_VERSION).then(|| (loaded_version, source.clone()));
129 Ok((
130 config,
131 Self {
132 path: path.to_path_buf(),
133 source: Some(source),
134 migrated_from,
135 },
136 ))
137 }
138 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
139 Config::default(),
140 Self {
141 path: path.to_path_buf(),
142 source: None,
143 migrated_from: None,
144 },
145 )),
146 Err(source) => Err(ConfigError::Read {
147 path: path.to_path_buf(),
148 source,
149 }),
150 }
151 }
152
153 pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
155 let current = match fs::read_to_string(&self.path) {
156 Ok(source) => Some(source),
157 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
158 Err(source) => {
159 return Err(ConfigError::Read {
160 path: self.path.clone(),
161 source,
162 });
163 }
164 };
165 if current != self.source {
166 return Err(ConfigError::Conflict {
167 path: self.path.clone(),
168 });
169 }
170
171 if let Some((version, original)) = self.migrated_from.as_ref() {
177 let backup = migration_backup_path(&self.path, *version).map_err(|source| {
178 ConfigError::Write {
179 path: self.path.clone(),
180 source,
181 }
182 })?;
183 write_atomic(&backup, original.as_bytes()).map_err(|source| ConfigError::Write {
187 path: backup,
188 source,
189 })?;
190 }
191
192 if let Some(parent) = self.path.parent() {
193 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
194 path: self.path.clone(),
195 source,
196 })?;
197 }
198 let body = render_config(config, self.source.as_deref(), &self.path)?;
199 backup_config_once(&self.path).map_err(|source| ConfigError::Write {
200 path: self.path.clone(),
201 source,
202 })?;
203 write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
204 path: self.path.clone(),
205 source,
206 })?;
207 self.migrated_from = None;
212 self.source = Some(body);
213 Ok(())
214 }
215}
216
217impl Config {
218 pub fn load_or_default() -> Result<Self, ConfigError> {
221 ConfigFile::load_or_default().map(|(config, _)| config)
222 }
223
224 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
226 ConfigFile::load_from_path(path).map(|(config, _)| config)
227 }
228
229 pub fn save_atomic(&self) -> Result<(), ConfigError> {
232 if self.ephemeral {
233 return Ok(());
234 }
235 self.save_to_path(&paths::config_path()?)
236 }
237
238 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
242 let (_, mut file) = ConfigFile::load_from_path(path)?;
243 file.save(self)
244 }
245}
246
247fn parse_config(path: &Path, source: &str) -> Result<(Config, u32), ConfigError> {
251 let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
252 path: path.to_path_buf(),
253 source: Box::new(source),
254 })?;
255 if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
256 return Err(ConfigError::UnsupportedSchemaVersion {
257 path: path.to_path_buf(),
258 found: header.schema_version,
259 });
260 }
261 reject_obsolete_fields(path, source, header.schema_version)?;
262 let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
263 path: path.to_path_buf(),
264 source: Box::new(source),
265 })?;
266 if header.schema_version <= 3 {
267 config.migrate_owner_locked_gestures();
268 }
269 if header.schema_version <= 4 {
274 config.migrate_transport_scoped_keys();
275 }
276 if header.schema_version <= 6 {
279 config.migrate_thumbwheel_native_direction();
280 }
281 config.repair_duplicate_routes();
282 config.schema_version = SCHEMA_VERSION;
283 Ok((config, header.schema_version))
284}
285
286fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
287 let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
288 path: path.to_path_buf(),
289 source: Box::new(source),
290 })?;
291 let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
292 return Ok(());
293 };
294 for (device_key, value) in devices {
295 let Some(device) = value.as_table() else {
296 continue;
297 };
298 for (field, last_version) in [
299 ("button_bindings", 1),
300 ("gesture_bindings", 1),
301 ("gesture_owner", 3),
302 ] {
303 if version > last_version && device.contains_key(field) {
304 return Err(ConfigError::ObsoleteField {
305 path: path.to_path_buf(),
306 field: format!("devices.{device_key}.{field}"),
307 version,
308 });
309 }
310 }
311 }
312 Ok(())
313}
314
315fn render_config(
316 config: &Config,
317 original: Option<&str>,
318 path: &Path,
319) -> Result<String, ConfigError> {
320 let generated = toml::to_string_pretty(config)?;
321 let Some(original) = original else {
322 return Ok(generated);
323 };
324 let mut document = original
325 .parse::<DocumentMut>()
326 .map_err(|source| ConfigError::Edit {
327 path: path.to_path_buf(),
328 source: Box::new(source),
329 })?;
330 let generated = generated
331 .parse::<DocumentMut>()
332 .map_err(|source| ConfigError::Edit {
333 path: path.to_path_buf(),
334 source: Box::new(source),
335 })?;
336 reconcile_table(document.as_table_mut(), generated.as_table());
337 Ok(document.to_string())
338}
339
340fn reconcile_table(current: &mut Table, generated: &Table) {
341 let stale: Vec<String> = current
342 .iter()
343 .filter(|(key, _)| generated.get(key).is_none())
344 .map(|(key, _)| key.to_string())
345 .collect();
346 for key in stale {
347 current.remove(&key);
348 }
349 for (key, generated_item) in generated {
350 if let Some(current_item) = current.get_mut(key) {
351 reconcile_item(current_item, generated_item);
352 } else {
353 current.insert(key, generated_item.clone());
354 }
355 }
356}
357
358fn reconcile_item(current: &mut Item, generated: &Item) {
359 if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
360 reconcile_table(current, generated);
361 return;
362 }
363 let decor = current.as_value().map(|value| value.decor().clone());
364 *current = generated.clone();
365 if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
366 *value.decor_mut() = decor;
367 }
368}
369
370fn backup_config_once(path: &Path) -> io::Result<()> {
371 let mut backed_up = BACKED_UP_CONFIGS
372 .lock()
373 .unwrap_or_else(PoisonError::into_inner);
374 if backed_up.contains(path) {
375 return Ok(());
376 }
377 match fs::metadata(path) {
378 Ok(_) => backup_existing_config(path)?,
379 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
380 Err(error) => return Err(error),
381 }
382 backed_up.insert(path.to_path_buf());
383 Ok(())
384}
385
386pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
387 for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
388 let source = config_backup_path(path, generation)?;
389 match fs::read(&source) {
390 Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
391 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
392 Err(error) => return Err(error),
393 }
394 }
395 write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
396}
397
398pub(super) fn migration_backup_path(path: &Path, version: u32) -> io::Result<PathBuf> {
404 let Some(file_name) = path.file_name() else {
405 return Err(io::Error::new(
406 io::ErrorKind::InvalidInput,
407 "config path has no file name",
408 ));
409 };
410 let mut backup_name = OsString::from(file_name);
411 backup_name.push(format!(".v{version}.bak"));
412 Ok(path.with_file_name(backup_name))
413}
414
415pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
416 let Some(file_name) = path.file_name() else {
417 return Err(io::Error::new(
418 io::ErrorKind::InvalidInput,
419 "config path has no file name",
420 ));
421 };
422 let mut backup_name = OsString::from(file_name);
423 backup_name.push(format!(".backup.{generation}"));
424 Ok(path.with_file_name(backup_name))
425}
426
427fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
428 #[cfg_attr(
429 not(unix),
430 expect(unused_mut, reason = "only the unix path mutates the options")
431 )]
432 let mut options = AtomicWriteFile::options();
433 #[cfg(unix)]
434 {
435 use atomic_write_file::unix::OpenOptionsExt as _;
436 use std::os::unix::fs::OpenOptionsExt as _;
437 options.preserve_mode(false).mode(0o600);
438 }
439 let mut file = options.open(path)?;
440 io::Write::write_all(&mut file, bytes)?;
441 file.commit()
442}