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)]
101pub struct ConfigFile {
102 path: PathBuf,
103 source: Option<String>,
104 migrated_from: Option<(u32, String)>,
109}
110
111#[derive(Deserialize)]
112struct ConfigHeader {
113 schema_version: u32,
114}
115
116impl ConfigFile {
117 pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
120 Self::load_from_path(&paths::config_path()?)
121 }
122
123 pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
125 match fs::read_to_string(path) {
126 Ok(source) => {
127 let (config, loaded_version) = parse_config(path, &source)?;
128 let migrated_from =
129 (loaded_version < SCHEMA_VERSION).then(|| (loaded_version, source.clone()));
130 Ok((
131 config,
132 Self {
133 path: path.to_path_buf(),
134 source: Some(source),
135 migrated_from,
136 },
137 ))
138 }
139 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
140 Config::default(),
141 Self {
142 path: path.to_path_buf(),
143 source: None,
144 migrated_from: None,
145 },
146 )),
147 Err(source) => Err(ConfigError::Read {
148 path: path.to_path_buf(),
149 source,
150 }),
151 }
152 }
153
154 pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
158 let target = resolve_symlinks(&self.path).map_err(|source| ConfigError::Read {
162 path: self.path.clone(),
163 source,
164 })?;
165 if let Some(parent) = target.parent() {
166 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
167 path: target.clone(),
168 source,
169 })?;
170 }
171 let lock_path = target.with_added_extension("lock");
176 let lock = fs::OpenOptions::new()
177 .read(true)
178 .write(true)
179 .create(true)
180 .truncate(false)
181 .open(&lock_path)
182 .map_err(|source| ConfigError::Write {
183 path: lock_path.clone(),
184 source,
185 })?;
186 lock.try_lock().map_err(|source| ConfigError::Write {
187 path: lock_path,
188 source: source.into(),
189 })?;
190 let current = match fs::read_to_string(&target) {
191 Ok(source) => Some(source),
192 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
193 Err(source) => {
194 return Err(ConfigError::Read {
195 path: self.path.clone(),
196 source,
197 });
198 }
199 };
200 if current != self.source {
201 return Err(ConfigError::Conflict {
202 path: self.path.clone(),
203 });
204 }
205
206 if let Some((version, original)) = self.migrated_from.as_ref() {
212 let backup = migration_backup_path(&self.path, *version).map_err(|source| {
213 ConfigError::Write {
214 path: self.path.clone(),
215 source,
216 }
217 })?;
218 write_atomic(&backup, original.as_bytes()).map_err(|source| ConfigError::Write {
222 path: backup,
223 source,
224 })?;
225 }
226
227 let body = render_config(config, self.source.as_deref(), &self.path)?;
228 backup_config_once(&self.path, &target).map_err(|source| ConfigError::Write {
232 path: self.path.clone(),
233 source,
234 })?;
235 write_atomic(&target, body.as_bytes()).map_err(|source| ConfigError::Write {
236 path: target,
237 source,
238 })?;
239 self.migrated_from = None;
244 self.source = Some(body);
245 Ok(())
246 }
247}
248
249impl Config {
250 pub fn load_or_default() -> Result<Self, ConfigError> {
253 ConfigFile::load_or_default().map(|(config, _)| config)
254 }
255
256 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
258 ConfigFile::load_from_path(path).map(|(config, _)| config)
259 }
260
261 pub fn save_atomic(&self) -> Result<(), ConfigError> {
264 if self.ephemeral {
265 return Ok(());
266 }
267 self.save_to_path(&paths::config_path()?)
268 }
269
270 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
274 let (_, mut file) = ConfigFile::load_from_path(path)?;
275 file.save(self)
276 }
277}
278
279fn parse_config(path: &Path, source: &str) -> Result<(Config, u32), ConfigError> {
283 let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
284 path: path.to_path_buf(),
285 source: Box::new(source),
286 })?;
287 if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
288 return Err(ConfigError::UnsupportedSchemaVersion {
289 path: path.to_path_buf(),
290 found: header.schema_version,
291 });
292 }
293 reject_obsolete_fields(path, source, header.schema_version)?;
294 let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
295 path: path.to_path_buf(),
296 source: Box::new(source),
297 })?;
298 if header.schema_version <= 3 {
299 config.migrate_owner_locked_gestures();
300 }
301 if header.schema_version <= 4 {
306 config.migrate_transport_scoped_keys();
307 }
308 if header.schema_version <= 6 {
311 config.migrate_thumbwheel_native_direction();
312 }
313 config.repair_duplicate_routes();
314 config.schema_version = SCHEMA_VERSION;
315 Ok((config, header.schema_version))
316}
317
318fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
319 let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
320 path: path.to_path_buf(),
321 source: Box::new(source),
322 })?;
323 let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
324 return Ok(());
325 };
326 for (device_key, value) in devices {
327 let Some(device) = value.as_table() else {
328 continue;
329 };
330 for (field, last_version) in [
331 ("button_bindings", 1),
332 ("gesture_bindings", 1),
333 ("gesture_owner", 3),
334 ] {
335 if version > last_version && device.contains_key(field) {
336 return Err(ConfigError::ObsoleteField {
337 path: path.to_path_buf(),
338 field: format!("devices.{device_key}.{field}"),
339 version,
340 });
341 }
342 }
343 }
344 Ok(())
345}
346
347fn render_config(
348 config: &Config,
349 original: Option<&str>,
350 path: &Path,
351) -> Result<String, ConfigError> {
352 let generated = toml::to_string_pretty(config)?;
353 let Some(original) = original else {
354 return Ok(generated);
355 };
356 let mut document = original
357 .parse::<DocumentMut>()
358 .map_err(|source| ConfigError::Edit {
359 path: path.to_path_buf(),
360 source: Box::new(source),
361 })?;
362 let generated = generated
363 .parse::<DocumentMut>()
364 .map_err(|source| ConfigError::Edit {
365 path: path.to_path_buf(),
366 source: Box::new(source),
367 })?;
368 reconcile_table(document.as_table_mut(), generated.as_table());
369 Ok(document.to_string())
370}
371
372fn reconcile_table(current: &mut Table, generated: &Table) {
373 let stale: Vec<String> = current
374 .iter()
375 .filter(|(key, _)| generated.get(key).is_none())
376 .map(|(key, _)| key.to_string())
377 .collect();
378 for key in stale {
379 current.remove(&key);
380 }
381 for (key, generated_item) in generated {
382 if let Some(current_item) = current.get_mut(key) {
383 reconcile_item(current_item, generated_item);
384 } else {
385 current.insert(key, generated_item.clone());
386 }
387 }
388}
389
390fn reconcile_item(current: &mut Item, generated: &Item) {
391 if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
392 reconcile_table(current, generated);
393 return;
394 }
395 let decor = current.as_value().map(|value| value.decor().clone());
396 *current = generated.clone();
397 if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
398 *value.decor_mut() = decor;
399 }
400}
401
402fn backup_config_once(path: &Path, target: &Path) -> io::Result<()> {
405 let mut backed_up = BACKED_UP_CONFIGS
406 .lock()
407 .unwrap_or_else(PoisonError::into_inner);
408 if backed_up.contains(path) {
409 return Ok(());
410 }
411 match fs::read(target) {
412 Ok(current) => backup_existing_config(path, ¤t)?,
413 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
414 Err(error) => return Err(error),
415 }
416 backed_up.insert(path.to_path_buf());
417 Ok(())
418}
419
420pub(super) fn backup_existing_config(path: &Path, current: &[u8]) -> io::Result<()> {
422 for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
423 let source = config_backup_path(path, generation)?;
424 match fs::read(&source) {
425 Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
426 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
427 Err(error) => return Err(error),
428 }
429 }
430 write_atomic(&config_backup_path(path, 1)?, current)
431}
432
433pub(super) fn migration_backup_path(path: &Path, version: u32) -> io::Result<PathBuf> {
439 let Some(file_name) = path.file_name() else {
440 return Err(io::Error::new(
441 io::ErrorKind::InvalidInput,
442 "config path has no file name",
443 ));
444 };
445 let mut backup_name = OsString::from(file_name);
446 backup_name.push(format!(".v{version}.bak"));
447 Ok(path.with_file_name(backup_name))
448}
449
450pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
451 let Some(file_name) = path.file_name() else {
452 return Err(io::Error::new(
453 io::ErrorKind::InvalidInput,
454 "config path has no file name",
455 ));
456 };
457 let mut backup_name = OsString::from(file_name);
458 backup_name.push(format!(".backup.{generation}"));
459 Ok(path.with_file_name(backup_name))
460}
461
462pub(super) fn resolve_symlinks(path: &Path) -> io::Result<PathBuf> {
466 const MAX_LINKS: usize = 40;
469 let mut current = path.to_path_buf();
470 for _ in 0..=MAX_LINKS {
471 match fs::symlink_metadata(¤t) {
472 Ok(metadata) if metadata.file_type().is_symlink() => {
473 let link = fs::read_link(¤t)?;
474 current = match current.parent() {
475 Some(parent) => parent.join(link),
476 None => link,
477 };
478 }
479 Ok(_) => return Ok(current),
480 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(current),
481 Err(error) => return Err(error),
482 }
483 }
484 Err(io::Error::new(
485 io::ErrorKind::InvalidInput,
486 "too many levels of symbolic links",
487 ))
488}
489
490fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
491 #[cfg_attr(
492 not(unix),
493 expect(unused_mut, reason = "only the unix path mutates the options")
494 )]
495 let mut options = AtomicWriteFile::options();
496 #[cfg(unix)]
497 {
498 use atomic_write_file::unix::OpenOptionsExt as _;
499 use std::os::unix::fs::OpenOptionsExt as _;
500 options.preserve_mode(false).mode(0o600);
501 }
502 let mut file = options.open(path)?;
503 io::Write::write_all(&mut file, bytes)?;
504 file.commit()
505}