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