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 config.schema_version = SCHEMA_VERSION;
277 Ok((config, header.schema_version))
278}
279
280fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
281 let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
282 path: path.to_path_buf(),
283 source: Box::new(source),
284 })?;
285 let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
286 return Ok(());
287 };
288 for (device_key, value) in devices {
289 let Some(device) = value.as_table() else {
290 continue;
291 };
292 for (field, last_version) in [
293 ("button_bindings", 1),
294 ("gesture_bindings", 1),
295 ("gesture_owner", 3),
296 ] {
297 if version > last_version && device.contains_key(field) {
298 return Err(ConfigError::ObsoleteField {
299 path: path.to_path_buf(),
300 field: format!("devices.{device_key}.{field}"),
301 version,
302 });
303 }
304 }
305 }
306 Ok(())
307}
308
309fn render_config(
310 config: &Config,
311 original: Option<&str>,
312 path: &Path,
313) -> Result<String, ConfigError> {
314 let generated = toml::to_string_pretty(config)?;
315 let Some(original) = original else {
316 return Ok(generated);
317 };
318 let mut document = original
319 .parse::<DocumentMut>()
320 .map_err(|source| ConfigError::Edit {
321 path: path.to_path_buf(),
322 source: Box::new(source),
323 })?;
324 let generated = generated
325 .parse::<DocumentMut>()
326 .map_err(|source| ConfigError::Edit {
327 path: path.to_path_buf(),
328 source: Box::new(source),
329 })?;
330 reconcile_table(document.as_table_mut(), generated.as_table());
331 Ok(document.to_string())
332}
333
334fn reconcile_table(current: &mut Table, generated: &Table) {
335 let stale: Vec<String> = current
336 .iter()
337 .filter(|(key, _)| generated.get(key).is_none())
338 .map(|(key, _)| key.to_string())
339 .collect();
340 for key in stale {
341 current.remove(&key);
342 }
343 for (key, generated_item) in generated {
344 if let Some(current_item) = current.get_mut(key) {
345 reconcile_item(current_item, generated_item);
346 } else {
347 current.insert(key, generated_item.clone());
348 }
349 }
350}
351
352fn reconcile_item(current: &mut Item, generated: &Item) {
353 if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
354 reconcile_table(current, generated);
355 return;
356 }
357 let decor = current.as_value().map(|value| value.decor().clone());
358 *current = generated.clone();
359 if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
360 *value.decor_mut() = decor;
361 }
362}
363
364fn backup_config_once(path: &Path) -> io::Result<()> {
365 let mut backed_up = BACKED_UP_CONFIGS
366 .lock()
367 .unwrap_or_else(PoisonError::into_inner);
368 if backed_up.contains(path) {
369 return Ok(());
370 }
371 match fs::metadata(path) {
372 Ok(_) => backup_existing_config(path)?,
373 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
374 Err(error) => return Err(error),
375 }
376 backed_up.insert(path.to_path_buf());
377 Ok(())
378}
379
380pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
381 for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
382 let source = config_backup_path(path, generation)?;
383 match fs::read(&source) {
384 Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
385 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
386 Err(error) => return Err(error),
387 }
388 }
389 write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
390}
391
392pub(super) fn migration_backup_path(path: &Path, version: u32) -> io::Result<PathBuf> {
398 let Some(file_name) = path.file_name() else {
399 return Err(io::Error::new(
400 io::ErrorKind::InvalidInput,
401 "config path has no file name",
402 ));
403 };
404 let mut backup_name = OsString::from(file_name);
405 backup_name.push(format!(".v{version}.bak"));
406 Ok(path.with_file_name(backup_name))
407}
408
409pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
410 let Some(file_name) = path.file_name() else {
411 return Err(io::Error::new(
412 io::ErrorKind::InvalidInput,
413 "config path has no file name",
414 ));
415 };
416 let mut backup_name = OsString::from(file_name);
417 backup_name.push(format!(".backup.{generation}"));
418 Ok(path.with_file_name(backup_name))
419}
420
421fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
422 #[cfg_attr(
423 not(unix),
424 expect(unused_mut, reason = "only the unix path mutates the options")
425 )]
426 let mut options = AtomicWriteFile::options();
427 #[cfg(unix)]
428 {
429 use atomic_write_file::unix::OpenOptionsExt as _;
430 use std::os::unix::fs::OpenOptionsExt as _;
431 options.preserve_mode(false).mode(0o600);
432 }
433 let mut file = options.open(path)?;
434 io::Write::write_all(&mut file, bytes)?;
435 file.commit()
436}