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 a stable physical-device identifier such
6//! as `"receiver:abc123:slot:2"`. Schema migrations branch on
7//! [`Config::schema_version`].
8
9use std::{
10    collections::{BTreeMap, HashSet},
11    ffi::OsString,
12    fs, io,
13    path::{Path, PathBuf},
14    sync::{Mutex, OnceLock, PoisonError},
15};
16
17use atomic_write_file::AtomicWriteFile;
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21mod device;
22mod key_trigger;
23mod settings;
24
25#[cfg(test)]
26mod tests;
27
28pub use device::{DeviceConfig, DeviceIdentity};
29pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
30pub use settings::LightSettings;
31pub use settings::{
32    AppSettings, Appearance, AssetSourcePreference, CameraControls, DEFAULT_THUMBWHEEL_SENSITIVITY,
33    GestureOwner, Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
34    SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
35    WheelMode,
36};
37
38use crate::binding::{
39    Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GestureDirection,
40    RingAction, default_binding, default_binding_for, default_gesture_binding,
41};
42use crate::paths::{self, PathsError};
43
44/// The schema version the current build produces. Bumped on breaking layout
45/// changes; readers branch on the parsed value before consuming the rest of
46/// the file.
47///
48/// v4 removes the one-gesture-button-per-device owner lock: gesture mode is a
49/// per-button fact read from the binding shape, so `gesture_owner` no longer
50/// serializes. Loading a v3-or-older file resolves the old owner and rewrites
51/// the shapes to dispatch identically
52/// (see `Config::migrate_owner_locked_gestures`); the version gate is what
53/// keeps that pass off v4 files, where several gesture-shaped buttons are a
54/// deliberate state, not a dormant leftover.
55///
56/// v3 changes the device map from model keys to physical-device keys. No v2
57/// device entries are migrated because model-scoped settings cannot be assigned
58/// safely when two identical devices exist.
59///
60/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
61/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
62/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
63/// next save; [`Config::load_from_path`] rejects only versions *newer* than this
64/// so a forward file fails loudly instead of silently losing bindings.
65pub const SCHEMA_VERSION: u32 = 4;
66
67const CONFIG_BACKUP_GENERATIONS: usize = 5;
68static BACKED_UP_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
69
70/// Top-level config document.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Config {
73    /// Schema version the file was written with. Compared against
74    /// [`SCHEMA_VERSION`] on load: older layouts migrate, newer ones are
75    /// rejected loudly rather than silently losing settings.
76    pub schema_version: u32,
77    /// Non-device-scoped preferences (autostart, tray, language, …).
78    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
79    pub app_settings: AppSettings,
80    /// Physical config key of the carousel-selected device, persisted so a
81    /// restart restores the last view rather than always landing on the
82    /// first paired device. `None` means "fall back to the first device".
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub selected_device: Option<String>,
85    /// When set (see [`Self::ephemeral`]), [`Self::save_atomic`] is a no-op:
86    /// this config never writes the on-disk file. Never true for a loaded or
87    /// default-constructed config.
88    #[serde(skip)]
89    ephemeral: bool,
90    /// Per-device state, keyed by the stable physical-device identifier
91    /// (e.g. `"receiver:abc123:slot:2"`) so two identical models never share
92    /// an entry.
93    #[serde(default)]
94    pub devices: BTreeMap<String, DeviceConfig>,
95    /// Keyboard remappings, independent of device. The function-key remapper
96    /// (M1) reads this; `#[serde(default)]` keeps older configs without a
97    /// `[keyboard]` section loading unchanged.
98    #[serde(default)]
99    pub keyboard: KeyboardConfig,
100}
101
102impl Default for Config {
103    fn default() -> Self {
104        Self {
105            schema_version: SCHEMA_VERSION,
106            app_settings: AppSettings::default(),
107            selected_device: None,
108            devices: BTreeMap::new(),
109            ephemeral: false,
110            keyboard: KeyboardConfig::default(),
111        }
112    }
113}
114
115/// Failure loading or persisting `config.toml`. The file-scoped variants
116/// carry the offending path so callers can surface an actionable message.
117#[derive(Debug, Error)]
118pub enum ConfigError {
119    /// The platform config directory could not be resolved (no home
120    /// directory for the current user).
121    #[error("could not resolve config path")]
122    Path(#[from] PathsError),
123    /// Reading the config file from disk failed.
124    #[error("could not read config at {path}")]
125    Read {
126        /// The config file the read targeted.
127        path: PathBuf,
128        /// The underlying I/O error.
129        #[source]
130        source: io::Error,
131    },
132    /// The file was read but is not valid TOML for this schema.
133    #[error("could not parse config at {path}")]
134    Parse {
135        /// The config file that failed to parse.
136        path: PathBuf,
137        /// The underlying TOML deserialization error.
138        #[source]
139        source: toml::de::Error,
140    },
141    /// Writing the updated config back to disk failed.
142    #[error("could not write config at {path}")]
143    Write {
144        /// The config file the write targeted.
145        path: PathBuf,
146        /// The underlying I/O error.
147        #[source]
148        source: io::Error,
149    },
150    /// The in-memory config could not be serialized to TOML — a bug in the
151    /// config types rather than user error, since [`Config`] always
152    /// serializes cleanly.
153    #[error("could not serialize config")]
154    Serialize(#[from] toml::ser::Error),
155    /// The file declares a `schema_version` newer than this build
156    /// understands; failing loudly avoids silently dropping settings a newer
157    /// build wrote.
158    #[error("config at {path} has unsupported schema_version {found}")]
159    UnsupportedSchemaVersion {
160        /// The config file carrying the unsupported version.
161        path: PathBuf,
162        /// The `schema_version` the file declared.
163        found: u32,
164    },
165}
166
167#[allow(
168    clippy::result_large_err,
169    reason = "Config I/O keeps rich parse/write context and is not a hot path"
170)]
171impl Config {
172    /// Loads the config from the default user path, returning
173    /// [`Config::default`] if the file does not exist yet.
174    pub fn load_or_default() -> Result<Self, ConfigError> {
175        Self::load_from_path(&paths::config_path()?)
176    }
177
178    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
179    /// to avoid touching the real user config.
180    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
181        match fs::read_to_string(path) {
182            Ok(text) => {
183                let mut config: Self =
184                    toml::from_str(&text).map_err(|source| ConfigError::Parse {
185                        path: path.to_path_buf(),
186                        source,
187                    })?;
188                // Accept any version up to the current one: older files migrate
189                // through the per-device [`RawDeviceConfig`] shim and self-heal on
190                // the next save. Only a *newer* file is rejected — loudly, so a
191                // downgraded binary refuses to load (and silently wipe) a config
192                // it can't represent.
193                if config.schema_version > SCHEMA_VERSION {
194                    return Err(ConfigError::UnsupportedSchemaVersion {
195                        path: path.to_path_buf(),
196                        found: config.schema_version,
197                    });
198                }
199                // An owner-locked file (v3 and older) rewrites its gesture
200                // shapes to shape-driven form. Version-gated: on a v4 file
201                // several gesture-shaped buttons are a deliberate state that
202                // must round-trip untouched.
203                if config.schema_version <= 3 {
204                    config.migrate_owner_locked_gestures();
205                }
206                // Stamp the in-memory doc to the current version so a re-save
207                // writes the migrated shape (the device shim already folded
208                // the legacy fields during deserialize).
209                config.schema_version = SCHEMA_VERSION;
210                Ok(config)
211            }
212            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
213            Err(source) => Err(ConfigError::Read {
214                path: path.to_path_buf(),
215                source,
216            }),
217        }
218    }
219
220    /// A config that never touches the on-disk file: [`Self::save_atomic`] is
221    /// a no-op. For tests that drive the state layer's persistence paths —
222    /// with a default config those would overwrite the developer's real
223    /// `config.toml` with test fixtures.
224    #[must_use]
225    pub fn ephemeral() -> Self {
226        Self {
227            ephemeral: true,
228            ..Self::default()
229        }
230    }
231
232    /// Writes the config atomically to the default user path: serialize to a
233    /// sibling temp file, then rename over the target. On Unix the temp file
234    /// is created with mode 0600. No-op for an [`Self::ephemeral`] config.
235    pub fn save_atomic(&self) -> Result<(), ConfigError> {
236        if self.ephemeral {
237            return Ok(());
238        }
239        self.save_to_path(&paths::config_path()?)
240    }
241
242    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
243    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
244        if let Some(parent) = path.parent() {
245            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
246                path: path.to_path_buf(),
247                source,
248            })?;
249        }
250        let body = toml::to_string_pretty(self)?;
251        backup_config_once(path).map_err(|source| ConfigError::Write {
252            path: path.to_path_buf(),
253            source,
254        })?;
255        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
256            path: path.to_path_buf(),
257            source,
258        })
259    }
260
261    /// Returns the bindings stored for `device_key`, or an empty map if the
262    /// device has no committed bindings yet.
263    #[must_use]
264    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
265        self.devices
266            .get(device_key)
267            .map(|d| d.bindings.clone())
268            .unwrap_or_default()
269    }
270
271    /// Records `binding` for `button` on `device_key`, creating the device
272    /// entry if needed. Replaces the whole binding (use
273    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
274    /// binding in place).
275    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
276        self.devices
277            .entry(device_key.to_string())
278            .or_default()
279            .bindings
280            .insert(button, binding);
281    }
282
283    /// Records (or, with `action = None`, clears) the F-key `trigger` binding
284    /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic —
285    /// one map applies across all keyboards — so this mirrors [`Self::set_binding`]
286    /// minus the device key.
287    pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
288        match action {
289            Some(a) => {
290                self.keyboard.bindings.insert(trigger, a);
291            }
292            None => {
293                self.keyboard.bindings.remove(&trigger);
294            }
295        }
296    }
297
298    /// The global keyboard F-key bindings (read accessor).
299    #[must_use]
300    pub fn keyboard_bindings(&self) -> &std::collections::HashMap<KeyTrigger, Action> {
301        &self.keyboard.bindings
302    }
303
304    /// Records `action` for one `direction` of `button`'s gesture binding,
305    /// creating the device entry if needed.
306    ///
307    /// A button with no binding yet is seeded from its canonical
308    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
309    /// default direction map (including a [`GestureDirection::Click`]), so the
310    /// merged map never persists a gesture binding whose click projection is a
311    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
312    /// preserving its action as the `Click` entry.
313    pub fn set_gesture_direction(
314        &mut self,
315        device_key: &str,
316        button: ButtonId,
317        direction: GestureDirection,
318        action: Action,
319    ) {
320        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
321            map.insert(direction, action);
322        }
323    }
324
325    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
326    /// device + a default binding if needed and upgrading a [`Binding::Single`]
327    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
328    /// entry so the caller can finish it — seed every direction
329    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
330    /// [`Self::set_gesture_mode`] and [`Self::set_gesture_direction`] so the two
331    /// promote a button into gesture mode identically.
332    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
333        let entry = self
334            .devices
335            .entry(device_key.to_string())
336            .or_default()
337            .bindings
338            .entry(button)
339            .or_insert_with(|| default_binding_for(button));
340        entry.upgrade_to_gesture();
341        entry
342    }
343
344    /// The single button the pre-v4 owner-locked runtime would have dispatched
345    /// gestures from, inferred from the binding shapes — the owner-lock-era
346    /// resolution rule, retained solely for
347    /// [`Self::migrate_owner_locked_gestures`]. `None` means gestures were off.
348    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
349        // An OS-hook button left in gesture mode took the role over.
350        if let Some((id, _)) = bindings
351            .iter()
352            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
353        {
354            return Some(*id);
355        }
356        // A dedicated HID++ gesture button explicitly demoted to a single action means gestures off.
357        if matches!(
358            bindings.get(&ButtonId::GestureButton),
359            Some(Binding::Single(_))
360        ) {
361            return None;
362        }
363        // Default: the dedicated HID++ gesture button owns the gesture role.
364        Some(ButtonId::GestureButton)
365    }
366
367    /// Whether `button` on `device_key` is in gesture mode — a per-button fact
368    /// read straight from the binding shape: a stored [`Binding::Gesture`], or
369    /// no stored binding on a button whose canonical default
370    /// ([`default_binding_for`]) is gesture-shaped (the dedicated HID++ gesture
371    /// button starts in gesture mode).
372    ///
373    /// Gesture mode is not exclusive: any number of buttons may gesture at
374    /// once, each with its own direction map. This replaces the former
375    /// one-gesture-button-per-device owner lock — see [`Self::set_gesture_mode`].
376    #[must_use]
377    pub fn is_gesture_mode(&self, device_key: &str, button: ButtonId) -> bool {
378        self.devices
379            .get(device_key)
380            .and_then(|d| d.bindings.get(&button))
381            .map_or_else(
382                || default_binding_for(button).is_gesture(),
383                Binding::is_gesture,
384            )
385    }
386
387    /// Every button of `device_key` currently in gesture mode, in [`ButtonId`]
388    /// declaration order. Purely config-derived: callers cross it with the
389    /// device's actual controls (a model without the dedicated gesture button
390    /// simply never captures it).
391    #[must_use]
392    pub fn gesture_mode_buttons(&self, device_key: &str) -> Vec<ButtonId> {
393        ButtonId::ALL
394            .iter()
395            .copied()
396            .filter(|b| self.is_gesture_mode(device_key, *b))
397            .collect()
398    }
399
400    /// Turn gesture mode on or off for one button, independently of every
401    /// other button.
402    ///
403    /// On: restore the button's stashed map when one exists (see
404    /// [`DeviceConfig::disabled_gestures`]) — an off/on round trip hands back
405    /// the user's customized arms exactly. Otherwise promote the stored
406    /// binding in place ([`Binding::upgrade_to_gesture`] keeps a prior single
407    /// action as the [`GestureDirection::Click`] entry) and seed unbound
408    /// directions from [`default_gesture_binding`].
409    ///
410    /// Off: stash the live map, then demote to a [`Binding::Single`] of the
411    /// map's `Click` action, falling back to the button's canonical
412    /// [`default_binding`] when the map has no explicit `Click` — a demoted
413    /// button always keeps a meaningful press. A button gesturing only by
414    /// default (no stored binding) stashes its seeded default map and is
415    /// pinned off with an explicit `Single` at its canonical default, which
416    /// the capture layer leaves native.
417    pub fn set_gesture_mode(&mut self, device_key: &str, button: ButtonId, enabled: bool) {
418        if enabled {
419            let device = self.devices.entry(device_key.to_string()).or_default();
420            if let Some(map) = device.disabled_gestures.remove(&button) {
421                device.bindings.insert(button, Binding::Gesture(map));
422            } else {
423                self.ensure_gesture_binding(device_key, button)
424                    .fill_gesture_defaults();
425            }
426            return;
427        }
428        let device = self.devices.entry(device_key.to_string()).or_default();
429        match device.bindings.get_mut(&button) {
430            Some(binding) => {
431                if let Binding::Gesture(map) = binding {
432                    device.disabled_gestures.insert(button, map.clone());
433                }
434                binding.demote_to_single(default_binding(button));
435            }
436            None => {
437                if default_binding_for(button).is_gesture() {
438                    device.disabled_gestures.insert(
439                        button,
440                        GestureDirection::ALL
441                            .iter()
442                            .copied()
443                            .map(|d| (d, default_gesture_binding(d)))
444                            .collect(),
445                    );
446                    device
447                        .bindings
448                        .insert(button, Binding::Single(default_binding(button)));
449                }
450            }
451        }
452    }
453
454    /// One-time load migration for owner-locked files (`schema_version <= 3`).
455    ///
456    /// Under the owner lock at most one button dispatched gestures; every other
457    /// gesture-capable button could keep a dormant direction map awaiting
458    /// re-selection, with [`DeviceConfig::gesture_owner`] recording the choice
459    /// (absent = infer). The shape-driven model has no dormant state — a stored
460    /// [`Binding::Gesture`] IS gesture mode — so this resolves the old owner
461    /// and rewrites the shapes to dispatch exactly what the old config did:
462    ///
463    /// - the owner keeps its gesture map. A HID++ owner whose stored binding
464    ///   is absent or `Single`-shaped gets the seeded default direction map
465    ///   materialized: the v3 runtime seeded at projection time and dispatched
466    ///   that map regardless of the stored shape, so leaving the shape
467    ///   non-gesture would silently lose gestures in the rewritten file. (An
468    ///   OS-hook owner is different — the v3 hook only dispatched a stored
469    ///   gesture map, so a `Single` owner stays single.)
470    /// - every other gesture-shaped binding is stashed into
471    ///   [`DeviceConfig::disabled_gestures`] — keeping the owner-lock model's
472    ///   restore-on-reselection promise — and demotes to a [`Binding::Single`]
473    ///   of its `Click`, the only part of a dormant map the old runtime
474    ///   dispatched;
475    /// - a non-owner dedicated gesture button with no stored binding is pinned
476    ///   with an explicit `Single` at its canonical default (absence would
477    ///   re-enter gesture mode under the gesture-shaped default), which the
478    ///   capture layer leaves native;
479    /// - the consumed `gesture_owner` never serializes again — the shape is
480    ///   the whole truth from here on.
481    fn migrate_owner_locked_gestures(&mut self) {
482        for device in self.devices.values_mut() {
483            let owner = match device.gesture_owner.take() {
484                Some(GestureOwner::Off) => None,
485                Some(GestureOwner::Button(id)) => Some(id),
486                None => Self::infer_gesture_owner(&device.bindings),
487            };
488            for (id, binding) in &mut device.bindings {
489                if Some(*id) != owner {
490                    if let Binding::Gesture(map) = binding {
491                        device.disabled_gestures.insert(*id, map.clone());
492                    }
493                    binding.demote_to_single(default_binding(*id));
494                }
495            }
496            if let Some(owner) = owner
497                && owner.is_hidpp_gesture_source()
498            {
499                let seeded = || {
500                    Binding::Gesture(
501                        GestureDirection::ALL
502                            .iter()
503                            .copied()
504                            .map(|d| (d, default_gesture_binding(d)))
505                            .collect(),
506                    )
507                };
508                match device.bindings.get_mut(&owner) {
509                    // A stored non-gesture shape is replaced by the map v3
510                    // actually dispatched.
511                    Some(binding) if !binding.is_gesture() => *binding = seeded(),
512                    Some(_) => {}
513                    // An absent owner only needs materializing when its
514                    // canonical default is not gesture-shaped (the haptic
515                    // panel); an absent dedicated button already means
516                    // default gesture mode.
517                    None => {
518                        if !default_binding_for(owner).is_gesture() {
519                            device.bindings.insert(owner, seeded());
520                        }
521                    }
522                }
523            }
524            if owner != Some(ButtonId::GestureButton) {
525                device
526                    .bindings
527                    .entry(ButtonId::GestureButton)
528                    .or_insert_with(|| Binding::Single(default_binding(ButtonId::GestureButton)));
529            }
530        }
531    }
532
533    /// Resolve the effective binding map for `device_key`, overlaying the
534    /// per-app entry for `bundle_id` (if any) on top of the global per-device
535    /// `bindings`. A per-app override replaces the whole button with a
536    /// [`Binding::Single`]; everything else falls through.
537    ///
538    /// Returns an empty map when the device has no recorded bindings yet.
539    /// Callers (the GUI / hook) layer their own defaults on top.
540    #[must_use]
541    pub fn effective_bindings(
542        &self,
543        device_key: &str,
544        bundle_id: Option<&str>,
545    ) -> BTreeMap<ButtonId, Binding> {
546        let Some(device) = self.devices.get(device_key) else {
547            return BTreeMap::new();
548        };
549        let mut out = device.bindings.clone();
550        if let Some(bid) = bundle_id
551            && let Some(overlay) = app_overlay(&device.per_app_bindings, bid)
552        {
553            for (k, v) in overlay {
554                out.insert(*k, Binding::Single(v.clone()));
555            }
556        }
557        out
558    }
559
560    /// Records a per-app override. Creates the device + app entries as
561    /// needed; passing an action of `None` removes the override and prunes
562    /// the empty app map.
563    pub fn set_per_app_binding(
564        &mut self,
565        device_key: &str,
566        bundle_id: &str,
567        button: ButtonId,
568        action: Option<Action>,
569    ) {
570        let entry = self
571            .devices
572            .entry(device_key.to_string())
573            .or_default()
574            .per_app_bindings
575            .entry(bundle_id.to_string())
576            .or_default();
577        match action {
578            Some(a) => {
579                entry.insert(button, a);
580            }
581            None => {
582                entry.remove(&button);
583            }
584        }
585        if let Some(d) = self.devices.get_mut(device_key) {
586            d.per_app_bindings.retain(|_, m| !m.is_empty());
587        }
588    }
589
590    /// Actions Ring settings for `device_key`, falling back to defaults when
591    /// the device has no saved ring configuration.
592    #[must_use]
593    pub fn action_ring(&self, device_key: &str) -> ActionRingConfig {
594        self.devices
595            .get(device_key)
596            .map(|device| device.action_ring.clone())
597            .unwrap_or_default()
598    }
599
600    /// Enable or disable `device_key`'s Actions Ring.
601    pub fn set_action_ring_enabled(&mut self, device_key: &str, enabled: bool) {
602        self.devices
603            .entry(device_key.to_string())
604            .or_default()
605            .action_ring
606            .enabled = enabled;
607    }
608
609    /// Enable or disable ring hover and activation haptics.
610    pub fn set_action_ring_haptics(&mut self, device_key: &str, enabled: bool) {
611        self.devices
612            .entry(device_key.to_string())
613            .or_default()
614            .action_ring
615            .haptics = enabled;
616    }
617
618    /// Replace or clear one slot in the default Actions Ring layout.
619    pub fn set_action_ring_slot(
620        &mut self,
621        device_key: &str,
622        slot: ActionRingSlot,
623        action: Option<RingAction>,
624    ) {
625        self.devices
626            .entry(device_key.to_string())
627            .or_default()
628            .action_ring
629            .default
630            .set_action(slot, action);
631    }
632
633    /// Set or restore the action-derived icon for one default ring slot.
634    pub fn set_action_ring_icon(
635        &mut self,
636        device_key: &str,
637        slot: ActionRingSlot,
638        icon: Option<ActionRingIcon>,
639    ) {
640        self.devices
641            .entry(device_key.to_string())
642            .or_default()
643            .action_ring
644            .default
645            .set_icon(slot, icon);
646    }
647
648    /// HID++ config key of the carousel-selected device, if any.
649    #[must_use]
650    pub fn selected_device(&self) -> Option<&str> {
651        self.selected_device.as_deref()
652    }
653
654    /// Update the carousel-selected device. Pass `None` to clear the
655    /// selection (e.g. when the previously-selected device disappears).
656    pub fn set_selected_device(&mut self, key: Option<String>) {
657        self.selected_device = key;
658    }
659
660    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
661    /// device has none configured yet.
662    #[must_use]
663    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
664        self.devices
665            .get(device_key)
666            .map(|d| d.dpi_presets.clone())
667            .unwrap_or_default()
668    }
669
670    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
671    /// clear (the device block is kept; the field is just omitted on save
672    /// thanks to `skip_serializing_if`).
673    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
674        self.devices
675            .entry(device_key.to_string())
676            .or_default()
677            .dpi_presets = presets;
678    }
679
680    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
681    /// device has never been seen online (or was configured before identities
682    /// were recorded).
683    #[must_use]
684    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
685        self.devices
686            .get(device_key)
687            .and_then(|d| d.identity.as_ref())
688    }
689
690    /// Record (or refresh) the identity captured for `device_key` while it was
691    /// online, creating the device entry if needed.
692    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
693        self.devices
694            .entry(device_key.to_string())
695            .or_default()
696            .identity = Some(identity);
697    }
698
699    /// Whether `device_key` has a non-empty per-app binding overlay for the
700    /// foreground app `app` (bundle id). Drives the menu-bar popover's "override
701    /// active" badge — when the current app has its own bindings for this
702    /// device, the global bindings are (partly) overridden.
703    #[must_use]
704    pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
705        self.devices.get(device_key).is_some_and(|d| {
706            app_overlay(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty())
707        })
708    }
709
710    /// Iterate every device we've recorded an identity for, as
711    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
712    /// known device stays visible (with its panels) before any live probe.
713    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
714        self.devices
715            .iter()
716            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
717    }
718
719    /// The lighting config for `device_key`, or `None` if unset.
720    #[must_use]
721    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
722        self.devices
723            .get(device_key)
724            .and_then(|d| d.lighting.clone())
725    }
726
727    /// Replace the lighting config for `device_key`.
728    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
729        self.devices
730            .entry(device_key.to_string())
731            .or_default()
732            .lighting = Some(lighting);
733    }
734
735    /// The saved UVC image controls for `device_key`, or `None` if never set.
736    #[must_use]
737    pub fn camera_controls(&self, device_key: &str) -> Option<CameraControls> {
738        self.devices
739            .get(device_key)
740            .and_then(|d| d.camera_controls.clone())
741    }
742
743    /// Replace the saved UVC image controls for `device_key`.
744    pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) {
745        self.devices
746            .entry(device_key.to_string())
747            .or_default()
748            .camera_controls = Some(controls);
749    }
750
751    /// The saved custom camera profiles for `device_key` (name → snapshot).
752    #[must_use]
753    pub fn camera_profiles(&self, device_key: &str) -> BTreeMap<String, CameraControls> {
754        self.devices
755            .get(device_key)
756            .map(|d| d.camera_profiles.clone())
757            .unwrap_or_default()
758    }
759
760    /// Save (or overwrite) a custom camera profile for `device_key`.
761    pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) {
762        self.devices
763            .entry(device_key.to_string())
764            .or_default()
765            .camera_profiles
766            .insert(name.to_string(), snap);
767    }
768
769    /// Delete a custom camera profile, clearing the active selection if it
770    /// named it. Unknown names are a no-op.
771    pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) {
772        if let Some(device) = self.devices.get_mut(device_key) {
773            device.camera_profiles.remove(name);
774            if device.camera_profile.as_deref() == Some(name) {
775                device.camera_profile = None;
776            }
777        }
778    }
779
780    /// The last-applied camera profile name for `device_key`, if any.
781    #[must_use]
782    pub fn camera_active_profile(&self, device_key: &str) -> Option<String> {
783        self.devices
784            .get(device_key)
785            .and_then(|d| d.camera_profile.clone())
786    }
787
788    /// Record which camera profile `device_key` last applied.
789    pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option<String>) {
790        self.devices
791            .entry(device_key.to_string())
792            .or_default()
793            .camera_profile = name;
794    }
795
796    /// The standalone-light config for `device_key`, or `None` if unset.
797    #[must_use]
798    pub fn light(&self, device_key: &str) -> Option<LightSettings> {
799        self.devices.get(device_key).and_then(|d| d.light)
800    }
801
802    /// Replace the standalone-light config for `device_key`.
803    pub fn set_light(&mut self, device_key: &str, light: LightSettings) {
804        self.devices
805            .entry(device_key.to_string())
806            .or_default()
807            .light = Some(light);
808    }
809
810    /// The committed sensor DPI for `device_key`, or `None` if never set.
811    #[must_use]
812    pub fn dpi(&self, device_key: &str) -> Option<u32> {
813        self.devices.get(device_key).and_then(|d| d.dpi)
814    }
815
816    /// Record the committed sensor DPI for `device_key`, so the agent can
817    /// re-apply it when the device reconnects (#189).
818    pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
819        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
820    }
821
822    /// The SmartShift wheel config for `device_key`, or `None` if never set.
823    #[must_use]
824    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
825        self.devices.get(device_key).and_then(|d| d.smartshift)
826    }
827
828    /// The persisted keyboard Fn-lock state for `device_key`, or `None` when
829    /// the user never set one (the keyboard keeps its own state).
830    #[must_use]
831    pub fn fn_lock(&self, device_key: &str) -> Option<bool> {
832        self.devices.get(device_key).and_then(|d| d.fn_lock)
833    }
834
835    /// Record the SmartShift wheel config for `device_key`, so the agent can
836    /// re-apply it when the device reconnects (#189).
837    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
838        self.devices
839            .entry(device_key.to_string())
840            .or_default()
841            .smartshift = Some(smartshift);
842    }
843
844    /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false`
845    /// (the native direction) for an unconfigured or absent device.
846    #[must_use]
847    pub fn invert_scroll(&self, device_key: &str) -> bool {
848        self.devices
849            .get(device_key)
850            .is_some_and(|d| d.invert_scroll)
851    }
852
853    /// Set whether `device_key`'s scroll wheel is inverted. The agent reads this
854    /// on the next `ReloadConfig` and applies it in the OS hook.
855    pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
856        self.devices
857            .entry(device_key.to_string())
858            .or_default()
859            .invert_scroll = invert;
860    }
861
862    /// The configured wheel resolution for `device_key`, or `None` when
863    /// OpenLogi should leave the device's current resolution unchanged.
864    #[must_use]
865    pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
866        self.devices
867            .get(device_key)
868            .and_then(|device| device.scroll_resolution)
869    }
870
871    /// Set the wheel resolution OpenLogi should restore for `device_key`.
872    /// Passing `None` returns the device to its unmanaged default state.
873    pub fn set_scroll_resolution(
874        &mut self,
875        device_key: &str,
876        resolution: Option<ScrollResolution>,
877    ) {
878        self.devices
879            .entry(device_key.to_string())
880            .or_default()
881            .scroll_resolution = resolution;
882    }
883
884    /// Whether OpenLogi manages `device_key` at all (capture + volatile
885    /// re-apply). Unconfigured devices are managed.
886    #[must_use]
887    pub fn device_enabled(&self, device_key: &str) -> bool {
888        self.devices.get(device_key).is_none_or(|d| d.enabled)
889    }
890
891    /// Enable or disable OpenLogi's management of `device_key`.
892    pub fn set_device_enabled(&mut self, device_key: &str, enabled: bool) {
893        self.devices
894            .entry(device_key.to_string())
895            .or_default()
896            .enabled = enabled;
897    }
898
899    /// The effective thumb-wheel sensitivity for `device_key`: the device's
900    /// override when set, else the app-wide default.
901    #[must_use]
902    pub fn thumbwheel_sensitivity(&self, device_key: &str) -> i32 {
903        self.devices
904            .get(device_key)
905            .and_then(|d| d.thumbwheel_sensitivity)
906            .unwrap_or(self.app_settings.thumbwheel_sensitivity)
907    }
908
909    /// Set (or clear, with `None`) `device_key`'s thumb-wheel sensitivity
910    /// override.
911    pub fn set_device_thumbwheel_sensitivity(
912        &mut self,
913        device_key: &str,
914        sensitivity: Option<i32>,
915    ) {
916        self.devices
917            .entry(device_key.to_string())
918            .or_default()
919            .thumbwheel_sensitivity = sensitivity;
920    }
921}
922
923/// Resolve the most specific application overlay for a foreground identifier.
924///
925/// Exact keys retain precedence. On Windows the foreground identifier is a
926/// lower-cased executable path, so `exe:<filename>` provides a stable fallback
927/// for Store and self-updating applications whose install directory changes
928/// between versions. Recognizing both path separators keeps hand-authored
929/// Windows config inspectable on every platform without changing macOS bundle
930/// identifiers or Linux application classes.
931fn app_overlay<'a, T>(overlays: &'a BTreeMap<String, T>, app: &str) -> Option<&'a T> {
932    overlays.get(app).or_else(|| {
933        let executable_name = app.rsplit(['\\', '/']).next()?;
934        if executable_name.is_empty()
935            || !Path::new(executable_name)
936                .extension()
937                .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
938        {
939            return None;
940        }
941
942        overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase()))
943    })
944}
945
946fn backup_config_once(path: &Path) -> io::Result<()> {
947    let backed_up = BACKED_UP_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()));
948    let mut backed_up = backed_up.lock().unwrap_or_else(PoisonError::into_inner);
949    if backed_up.contains(path) {
950        return Ok(());
951    }
952    match fs::metadata(path) {
953        Ok(_) => backup_existing_config(path)?,
954        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
955        Err(error) => return Err(error),
956    }
957    backed_up.insert(path.to_path_buf());
958    Ok(())
959}
960
961fn backup_existing_config(path: &Path) -> io::Result<()> {
962    for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
963        let source = config_backup_path(path, generation)?;
964        match fs::read(&source) {
965            Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
966            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
967            Err(error) => return Err(error),
968        }
969    }
970    write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
971}
972
973fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
974    let Some(file_name) = path.file_name() else {
975        return Err(io::Error::new(
976            io::ErrorKind::InvalidInput,
977            "config path has no file name",
978        ));
979    };
980    let mut backup_name = OsString::from(file_name);
981    backup_name.push(format!(".backup.{generation}"));
982    Ok(path.with_file_name(backup_name))
983}
984
985/// Write `bytes` to `path` atomically via a randomized temp file + rename,
986/// with the directory fsync the old hand-rolled writer lacked.
987fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
988    #[cfg_attr(
989        not(unix),
990        expect(unused_mut, reason = "only the unix path mutates the options")
991    )]
992    let mut options = AtomicWriteFile::options();
993    #[cfg(unix)]
994    {
995        use atomic_write_file::unix::OpenOptionsExt as _;
996        use std::os::unix::fs::OpenOptionsExt as _;
997        // Force 0600 on every save, matching the previous writer.
998        options.preserve_mode(false).mode(0o600);
999    }
1000    let mut file = options.open(path)?;
1001    io::Write::write_all(&mut file, bytes)?;
1002    file.commit()
1003}