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