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