Skip to main content

openlogi_core/
config.rs

1//! User configuration, persisted as TOML at the platform-standard config
2//! path.
3//!
4//! Per-device state (button bindings, …) lives under the
5//! [`Config::devices`] map, keyed by the HID++ identifier returned by
6//! [`DeviceModelInfo::config_key`](crate::device::DeviceModelInfo::config_key)
7//! — e.g. `"2b042"` for an MX Master 4. Schema migrations branch on
8//! [`Config::schema_version`].
9
10use std::{
11    collections::BTreeMap,
12    fs, io,
13    path::{Path, PathBuf},
14};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::binding::{Action, ButtonId, GestureDirection};
20use crate::paths::{self, PathsError};
21
22/// The schema version the current build produces. Bumped on breaking layout
23/// changes; readers branch on the parsed value before consuming the rest of
24/// the file.
25pub const SCHEMA_VERSION: u32 = 1;
26
27/// Top-level config document.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Config {
30    pub schema_version: u32,
31    /// Non-device-scoped preferences (autostart, tray, language, …).
32    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
33    pub app_settings: AppSettings,
34    /// HID++ `config_key` of the carousel-selected device, persisted so a
35    /// restart restores the last view rather than always landing on the
36    /// first paired device. `None` means "fall back to the first device".
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub selected_device: Option<String>,
39    #[serde(default)]
40    pub devices: BTreeMap<String, DeviceConfig>,
41}
42
43impl Default for Config {
44    fn default() -> Self {
45        Self {
46            schema_version: SCHEMA_VERSION,
47            app_settings: AppSettings::default(),
48            selected_device: None,
49            devices: BTreeMap::new(),
50        }
51    }
52}
53
54/// App-wide preferences not tied to any particular device.
55///
56/// All fields are `#[serde(default)]` so adding a new one is backward
57/// compatible — old config files just keep the default for the new field.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct AppSettings {
60    /// When true, a macOS `LaunchAgent` plist at
61    /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed
62    /// so the app starts on login (P2.2). The plist is reconciled with
63    /// this field on every startup; flipping the flag and relaunching is
64    /// enough to install / remove it.
65    #[serde(default)]
66    pub launch_at_login: bool,
67    /// Opt-in update check (P2.8). **Off by default** to honour the
68    /// README's "no telemetry, no auto-update poller" promise. When true,
69    /// the app makes exactly one `HEAD /repos/AprilNEA/OpenLogi/releases/
70    /// latest` request per launch and logs whether a newer version is
71    /// available — no automatic download.
72    #[serde(default)]
73    pub check_for_updates: bool,
74    /// UI language as a BCP-47-ish locale code matching the GUI's bundled
75    /// locales (`"en"`, `"zh-CN"`, `"zh-HK"`). `None` means "follow the
76    /// system locale", which the GUI resolves at startup. Stored here so a
77    /// user's explicit choice survives restarts regardless of the OS setting.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub language: Option<String>,
80}
81
82impl AppSettings {
83    /// `skip_serializing_if` helper: true when nothing diverges from the
84    /// default, so empty settings don't clutter `config.toml`.
85    #[must_use]
86    pub fn is_default(&self) -> bool {
87        self == &Self::default()
88    }
89}
90
91/// Settings scoped to a single physical device (keyed by HID++ model+ext).
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct DeviceConfig {
94    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95    pub button_bindings: BTreeMap<ButtonId, Action>,
96    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
97    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
98    /// id matches a key here, those bindings take precedence; anything not
99    /// listed falls through to `button_bindings`.
100    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
102    /// Sub-bindings for the gesture button: hold + swipe direction or a
103    /// plain click. Edited via the gesture picker; the legacy single
104    /// `button_bindings[GestureButton]` entry is ignored on devices that
105    /// have entries here. Hardware dispatch is a P1.5 follow-up.
106    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
107    pub gesture_bindings: BTreeMap<GestureDirection, Action>,
108    /// Ordered list of DPI presets cycled through by
109    /// [`Action::CycleDpiPresets`] and indexed by
110    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
111    /// the cycle action becomes a no-op until the user adds at least one.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub dpi_presets: Vec<u32>,
114}
115
116#[derive(Debug, Error)]
117pub enum ConfigError {
118    #[error("could not resolve config path")]
119    Path(#[from] PathsError),
120    #[error("could not read config at {path}")]
121    Read {
122        path: PathBuf,
123        #[source]
124        source: io::Error,
125    },
126    #[error("could not parse config at {path}")]
127    Parse {
128        path: PathBuf,
129        #[source]
130        source: toml::de::Error,
131    },
132    #[error("could not write config at {path}")]
133    Write {
134        path: PathBuf,
135        #[source]
136        source: io::Error,
137    },
138    #[error("could not serialize config")]
139    Serialize(#[from] toml::ser::Error),
140    #[error("config at {path} has unsupported schema_version {found}")]
141    UnsupportedSchemaVersion { path: PathBuf, found: u32 },
142}
143
144impl Config {
145    /// Loads the config from the default user path, returning
146    /// [`Config::default`] if the file does not exist yet.
147    pub fn load_or_default() -> Result<Self, ConfigError> {
148        Self::load_from_path(&paths::config_path()?)
149    }
150
151    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
152    /// to avoid touching the real user config.
153    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
154        match fs::read_to_string(path) {
155            Ok(text) => {
156                let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
157                    path: path.to_path_buf(),
158                    source,
159                })?;
160                if config.schema_version != SCHEMA_VERSION {
161                    return Err(ConfigError::UnsupportedSchemaVersion {
162                        path: path.to_path_buf(),
163                        found: config.schema_version,
164                    });
165                }
166                Ok(config)
167            }
168            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
169            Err(source) => Err(ConfigError::Read {
170                path: path.to_path_buf(),
171                source,
172            }),
173        }
174    }
175
176    /// Writes the config atomically to the default user path: serialize to a
177    /// sibling temp file, then rename over the target. On Unix the temp file
178    /// is created with mode 0600.
179    pub fn save_atomic(&self) -> Result<(), ConfigError> {
180        self.save_to_path(&paths::config_path()?)
181    }
182
183    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
184    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
185        if let Some(parent) = path.parent() {
186            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
187                path: path.to_path_buf(),
188                source,
189            })?;
190        }
191        let body = toml::to_string_pretty(self)?;
192        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
193            path: path.to_path_buf(),
194            source,
195        })
196    }
197
198    /// Returns the bindings stored for `device_key`, or an empty map if the
199    /// device has no committed bindings yet.
200    #[must_use]
201    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Action> {
202        self.devices
203            .get(device_key)
204            .map(|d| d.button_bindings.clone())
205            .unwrap_or_default()
206    }
207
208    /// Records `action` as the binding for `button` on `device_key`,
209    /// creating the device entry if needed.
210    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, action: Action) {
211        self.devices
212            .entry(device_key.to_string())
213            .or_default()
214            .button_bindings
215            .insert(button, action);
216    }
217
218    /// Returns the gesture sub-bindings stored for `device_key`, or an empty
219    /// map if none are set yet.
220    #[must_use]
221    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
222        self.devices
223            .get(device_key)
224            .map(|d| d.gesture_bindings.clone())
225            .unwrap_or_default()
226    }
227
228    /// Records `action` for `direction` of `device_key`'s gesture button.
229    pub fn set_gesture_binding(
230        &mut self,
231        device_key: &str,
232        direction: GestureDirection,
233        action: Action,
234    ) {
235        self.devices
236            .entry(device_key.to_string())
237            .or_default()
238            .gesture_bindings
239            .insert(direction, action);
240    }
241
242    /// Resolve the effective binding map for `device_key`, overlaying the
243    /// per-app entry for `bundle_id` (if any) on top of the global per-device
244    /// `button_bindings`. Per-app values win; everything else falls through.
245    ///
246    /// Returns an empty map when the device has no recorded bindings yet.
247    /// Callers (the GUI / hook) layer their own defaults on top.
248    #[must_use]
249    pub fn effective_bindings(
250        &self,
251        device_key: &str,
252        bundle_id: Option<&str>,
253    ) -> BTreeMap<ButtonId, Action> {
254        let Some(device) = self.devices.get(device_key) else {
255            return BTreeMap::new();
256        };
257        let mut out = device.button_bindings.clone();
258        if let Some(bid) = bundle_id {
259            if let Some(overlay) = device.per_app_bindings.get(bid) {
260                for (k, v) in overlay {
261                    out.insert(*k, v.clone());
262                }
263            }
264        }
265        out
266    }
267
268    /// Records a per-app override. Creates the device + app entries as
269    /// needed; passing an action of `None` removes the override and prunes
270    /// the empty app map.
271    pub fn set_per_app_binding(
272        &mut self,
273        device_key: &str,
274        bundle_id: &str,
275        button: ButtonId,
276        action: Option<Action>,
277    ) {
278        let entry = self
279            .devices
280            .entry(device_key.to_string())
281            .or_default()
282            .per_app_bindings
283            .entry(bundle_id.to_string())
284            .or_default();
285        match action {
286            Some(a) => {
287                entry.insert(button, a);
288            }
289            None => {
290                entry.remove(&button);
291            }
292        }
293        if let Some(d) = self.devices.get_mut(device_key) {
294            d.per_app_bindings.retain(|_, m| !m.is_empty());
295        }
296    }
297
298    /// HID++ config key of the carousel-selected device, if any.
299    #[must_use]
300    pub fn selected_device(&self) -> Option<&str> {
301        self.selected_device.as_deref()
302    }
303
304    /// Update the carousel-selected device. Pass `None` to clear the
305    /// selection (e.g. when the previously-selected device disappears).
306    pub fn set_selected_device(&mut self, key: Option<String>) {
307        self.selected_device = key;
308    }
309
310    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
311    /// device has none configured yet.
312    #[must_use]
313    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
314        self.devices
315            .get(device_key)
316            .map(|d| d.dpi_presets.clone())
317            .unwrap_or_default()
318    }
319
320    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
321    /// clear (the device block is kept; the field is just omitted on save
322    /// thanks to `skip_serializing_if`).
323    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
324        self.devices
325            .entry(device_key.to_string())
326            .or_default()
327            .dpi_presets = presets;
328    }
329}
330
331fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
332    let tmp = path.with_extension("toml.tmp");
333    {
334        #[cfg(unix)]
335        {
336            use std::os::unix::fs::OpenOptionsExt;
337            let mut f = fs::OpenOptions::new()
338                .write(true)
339                .create(true)
340                .truncate(true)
341                .mode(0o600)
342                .open(&tmp)?;
343            io::Write::write_all(&mut f, bytes)?;
344            f.sync_all()?;
345        }
346        #[cfg(not(unix))]
347        {
348            let mut f = fs::OpenOptions::new()
349                .write(true)
350                .create(true)
351                .truncate(true)
352                .open(&tmp)?;
353            io::Write::write_all(&mut f, bytes)?;
354            f.sync_all()?;
355        }
356    }
357    fs::rename(&tmp, path)
358}
359
360#[cfg(test)]
361#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
362mod tests {
363    use super::*;
364
365    fn write_and_read(config: &Config) -> Config {
366        let dir = tempfile::tempdir().expect("tempdir");
367        let path = dir.path().join("config.toml");
368        config.save_to_path(&path).expect("save");
369        Config::load_from_path(&path).expect("load")
370    }
371
372    #[test]
373    fn missing_file_yields_default() {
374        let dir = tempfile::tempdir().expect("tempdir");
375        let path = dir.path().join("nonexistent.toml");
376        let cfg = Config::load_from_path(&path).expect("load");
377        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
378        assert!(cfg.devices.is_empty());
379    }
380
381    #[test]
382    fn bindings_roundtrip_per_device() {
383        let mut cfg = Config::default();
384        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
385        cfg.set_binding(
386            "2b042",
387            ButtonId::DpiToggle,
388            Action::CustomShortcut(crate::binding::KeyCombo {
389                modifiers: crate::binding::KeyCombo::MOD_CMD,
390                key_code: 0x23, // kVK_ANSI_P
391                display: "⌘P".into(),
392            }),
393        );
394        cfg.set_binding("4082d", ButtonId::Back, Action::Paste);
395
396        let parsed = write_and_read(&cfg);
397
398        // Per-device isolation.
399        let a = parsed.bindings_for("2b042");
400        assert_eq!(a.get(&ButtonId::Back), Some(&Action::Copy));
401        assert_eq!(
402            a.get(&ButtonId::DpiToggle),
403            Some(&Action::CustomShortcut(crate::binding::KeyCombo {
404                modifiers: crate::binding::KeyCombo::MOD_CMD,
405                key_code: 0x23,
406                display: "⌘P".into(),
407            }))
408        );
409
410        let b = parsed.bindings_for("4082d");
411        assert_eq!(b.get(&ButtonId::Back), Some(&Action::Paste));
412        assert_eq!(b.len(), 1, "device b should only see its own bindings");
413
414        // Unknown device returns empty map without panic.
415        assert!(parsed.bindings_for("deadbeef").is_empty());
416    }
417
418    #[test]
419    fn human_readable_toml_layout() {
420        let mut cfg = Config::default();
421        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
422        let body = toml::to_string_pretty(&cfg).expect("serialize");
423
424        // The model id only contains [A-Za-z0-9_], so TOML emits it as a
425        // bare-word table key (no surrounding quotes). The test asserts the
426        // observable structure rather than locking in a specific quoting.
427        assert!(body.contains("schema_version = 1"), "got: {body}");
428        assert!(
429            body.contains("[devices.2b042.button_bindings]"),
430            "got: {body}"
431        );
432        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
433    }
434
435    #[test]
436    fn rejects_unknown_schema_version() {
437        let dir = tempfile::tempdir().expect("tempdir");
438        let path = dir.path().join("config.toml");
439        fs::write(&path, "schema_version = 99\n").expect("write");
440        let err = Config::load_from_path(&path).expect_err("should fail");
441        assert!(matches!(
442            err,
443            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
444        ));
445    }
446
447    #[test]
448    fn dpi_presets_roundtrip_per_device() {
449        let mut cfg = Config::default();
450        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
451        cfg.set_dpi_presets("4082d", vec![400, 1600]);
452
453        let parsed = write_and_read(&cfg);
454
455        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
456        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
457        assert!(parsed.dpi_presets("unknown").is_empty());
458    }
459
460    #[test]
461    fn empty_dpi_presets_skip_serialization() {
462        let mut cfg = Config::default();
463        // Add a binding so the device block exists.
464        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
465        cfg.set_dpi_presets("2b042", vec![800]);
466        cfg.set_dpi_presets("2b042", vec![]); // clear
467
468        let body = toml::to_string_pretty(&cfg).expect("serialize");
469        assert!(
470            !body.contains("dpi_presets"),
471            "empty dpi_presets should be omitted: {body}"
472        );
473    }
474
475    #[test]
476    fn selected_device_roundtrips() {
477        let mut cfg = Config::default();
478        assert_eq!(cfg.selected_device(), None);
479        cfg.set_selected_device(Some("2b042".into()));
480        let parsed = write_and_read(&cfg);
481        assert_eq!(parsed.selected_device(), Some("2b042"));
482    }
483
484    #[test]
485    fn per_app_overlay_takes_precedence() {
486        let mut cfg = Config::default();
487        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
488        cfg.set_binding("2b042", ButtonId::Forward, Action::BrowserForward);
489        cfg.set_per_app_binding(
490            "2b042",
491            "com.microsoft.VSCode",
492            ButtonId::Back,
493            Some(Action::Undo),
494        );
495
496        // Global: both buttons are browser nav.
497        let global = cfg.effective_bindings("2b042", None);
498        assert_eq!(global.get(&ButtonId::Back), Some(&Action::BrowserBack));
499        assert_eq!(
500            global.get(&ButtonId::Forward),
501            Some(&Action::BrowserForward)
502        );
503
504        // VSCode: Back overridden, Forward inherits.
505        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
506        assert_eq!(vscode.get(&ButtonId::Back), Some(&Action::Undo));
507        assert_eq!(
508            vscode.get(&ButtonId::Forward),
509            Some(&Action::BrowserForward)
510        );
511
512        // Unrelated app falls through.
513        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
514        assert_eq!(other.get(&ButtonId::Back), Some(&Action::BrowserBack));
515    }
516
517    #[test]
518    fn per_app_binding_removal_prunes_empty_app() {
519        let mut cfg = Config::default();
520        cfg.set_per_app_binding(
521            "2b042",
522            "com.example.App",
523            ButtonId::Back,
524            Some(Action::Copy),
525        );
526        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
527        assert!(
528            cfg.devices["2b042"].per_app_bindings.is_empty(),
529            "removing last override should prune the app entry"
530        );
531    }
532
533    #[test]
534    fn app_settings_default_omits_block() {
535        let cfg = Config::default();
536        let body = toml::to_string_pretty(&cfg).expect("serialize");
537        assert!(
538            !body.contains("app_settings"),
539            "default app_settings should be omitted: {body}"
540        );
541    }
542
543    #[test]
544    fn app_settings_launch_at_login_roundtrips() {
545        let mut cfg = Config::default();
546        cfg.app_settings.launch_at_login = true;
547        let parsed = write_and_read(&cfg);
548        assert!(parsed.app_settings.launch_at_login);
549    }
550
551    #[test]
552    fn cleared_selected_device_omits_field() {
553        let mut cfg = Config::default();
554        cfg.set_selected_device(Some("2b042".into()));
555        cfg.set_selected_device(None);
556        let body = toml::to_string_pretty(&cfg).expect("serialize");
557        assert!(
558            !body.contains("selected_device"),
559            "cleared selection should not appear: {body}"
560        );
561    }
562
563    #[test]
564    fn empty_device_block_is_skipped_in_output() {
565        // Inserting then clearing should not leave a [devices."x"] header
566        // with no bindings under it (skip_serializing_if on button_bindings).
567        let mut cfg = Config::default();
568        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
569        cfg.devices
570            .get_mut("2b042")
571            .expect("entry")
572            .button_bindings
573            .clear();
574        let body = toml::to_string_pretty(&cfg).expect("serialize");
575        assert!(
576            !body.contains("Back"),
577            "cleared bindings should not appear: {body}"
578        );
579    }
580}