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}
104
105#[derive(Deserialize)]
106struct ConfigHeader {
107 schema_version: u32,
108}
109
110impl ConfigFile {
111 pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
114 Self::load_from_path(&paths::config_path()?)
115 }
116
117 pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
119 match fs::read_to_string(path) {
120 Ok(source) => {
121 let config = parse_config(path, &source)?;
122 Ok((
123 config,
124 Self {
125 path: path.to_path_buf(),
126 source: Some(source),
127 },
128 ))
129 }
130 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
131 Config::default(),
132 Self {
133 path: path.to_path_buf(),
134 source: None,
135 },
136 )),
137 Err(source) => Err(ConfigError::Read {
138 path: path.to_path_buf(),
139 source,
140 }),
141 }
142 }
143
144 pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
146 let current = match fs::read_to_string(&self.path) {
147 Ok(source) => Some(source),
148 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
149 Err(source) => {
150 return Err(ConfigError::Read {
151 path: self.path.clone(),
152 source,
153 });
154 }
155 };
156 if current != self.source {
157 return Err(ConfigError::Conflict {
158 path: self.path.clone(),
159 });
160 }
161
162 if let Some(parent) = self.path.parent() {
163 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
164 path: self.path.clone(),
165 source,
166 })?;
167 }
168 let body = render_config(config, self.source.as_deref(), &self.path)?;
169 backup_config_once(&self.path).map_err(|source| ConfigError::Write {
170 path: self.path.clone(),
171 source,
172 })?;
173 write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
174 path: self.path.clone(),
175 source,
176 })?;
177 self.source = Some(body);
178 Ok(())
179 }
180}
181
182impl Config {
183 pub fn load_or_default() -> Result<Self, ConfigError> {
186 ConfigFile::load_or_default().map(|(config, _)| config)
187 }
188
189 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
191 ConfigFile::load_from_path(path).map(|(config, _)| config)
192 }
193
194 pub fn save_atomic(&self) -> Result<(), ConfigError> {
197 if self.ephemeral {
198 return Ok(());
199 }
200 self.save_to_path(&paths::config_path()?)
201 }
202
203 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
207 let (_, mut file) = ConfigFile::load_from_path(path)?;
208 file.save(self)
209 }
210}
211
212fn parse_config(path: &Path, source: &str) -> Result<Config, ConfigError> {
213 let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
214 path: path.to_path_buf(),
215 source: Box::new(source),
216 })?;
217 if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
218 return Err(ConfigError::UnsupportedSchemaVersion {
219 path: path.to_path_buf(),
220 found: header.schema_version,
221 });
222 }
223 reject_obsolete_fields(path, source, header.schema_version)?;
224 let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
225 path: path.to_path_buf(),
226 source: Box::new(source),
227 })?;
228 if header.schema_version <= 3 {
229 config.migrate_owner_locked_gestures();
230 }
231 config.schema_version = SCHEMA_VERSION;
232 Ok(config)
233}
234
235fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
236 let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
237 path: path.to_path_buf(),
238 source: Box::new(source),
239 })?;
240 let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
241 return Ok(());
242 };
243 for (device_key, value) in devices {
244 let Some(device) = value.as_table() else {
245 continue;
246 };
247 for (field, last_version) in [
248 ("button_bindings", 1),
249 ("gesture_bindings", 1),
250 ("gesture_owner", 3),
251 ] {
252 if version > last_version && device.contains_key(field) {
253 return Err(ConfigError::ObsoleteField {
254 path: path.to_path_buf(),
255 field: format!("devices.{device_key}.{field}"),
256 version,
257 });
258 }
259 }
260 }
261 Ok(())
262}
263
264fn render_config(
265 config: &Config,
266 original: Option<&str>,
267 path: &Path,
268) -> Result<String, ConfigError> {
269 let generated = toml::to_string_pretty(config)?;
270 let Some(original) = original else {
271 return Ok(generated);
272 };
273 let mut document = original
274 .parse::<DocumentMut>()
275 .map_err(|source| ConfigError::Edit {
276 path: path.to_path_buf(),
277 source: Box::new(source),
278 })?;
279 let generated = generated
280 .parse::<DocumentMut>()
281 .map_err(|source| ConfigError::Edit {
282 path: path.to_path_buf(),
283 source: Box::new(source),
284 })?;
285 reconcile_table(document.as_table_mut(), generated.as_table());
286 Ok(document.to_string())
287}
288
289fn reconcile_table(current: &mut Table, generated: &Table) {
290 let stale: Vec<String> = current
291 .iter()
292 .filter(|(key, _)| generated.get(key).is_none())
293 .map(|(key, _)| key.to_string())
294 .collect();
295 for key in stale {
296 current.remove(&key);
297 }
298 for (key, generated_item) in generated {
299 if let Some(current_item) = current.get_mut(key) {
300 reconcile_item(current_item, generated_item);
301 } else {
302 current.insert(key, generated_item.clone());
303 }
304 }
305}
306
307fn reconcile_item(current: &mut Item, generated: &Item) {
308 if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
309 reconcile_table(current, generated);
310 return;
311 }
312 let decor = current.as_value().map(|value| value.decor().clone());
313 *current = generated.clone();
314 if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
315 *value.decor_mut() = decor;
316 }
317}
318
319fn backup_config_once(path: &Path) -> io::Result<()> {
320 let mut backed_up = BACKED_UP_CONFIGS
321 .lock()
322 .unwrap_or_else(PoisonError::into_inner);
323 if backed_up.contains(path) {
324 return Ok(());
325 }
326 match fs::metadata(path) {
327 Ok(_) => backup_existing_config(path)?,
328 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
329 Err(error) => return Err(error),
330 }
331 backed_up.insert(path.to_path_buf());
332 Ok(())
333}
334
335pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
336 for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
337 let source = config_backup_path(path, generation)?;
338 match fs::read(&source) {
339 Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
340 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
341 Err(error) => return Err(error),
342 }
343 }
344 write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
345}
346
347pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
348 let Some(file_name) = path.file_name() else {
349 return Err(io::Error::new(
350 io::ErrorKind::InvalidInput,
351 "config path has no file name",
352 ));
353 };
354 let mut backup_name = OsString::from(file_name);
355 backup_name.push(format!(".backup.{generation}"));
356 Ok(path.with_file_name(backup_name))
357}
358
359fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
360 #[cfg_attr(
361 not(unix),
362 expect(unused_mut, reason = "only the unix path mutates the options")
363 )]
364 let mut options = AtomicWriteFile::options();
365 #[cfg(unix)]
366 {
367 use atomic_write_file::unix::OpenOptionsExt as _;
368 use std::os::unix::fs::OpenOptionsExt as _;
369 options.preserve_mode(false).mode(0o600);
370 }
371 let mut file = options.open(path)?;
372 io::Write::write_all(&mut file, bytes)?;
373 file.commit()
374}