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,
11    fs, io,
12    path::{Path, PathBuf},
13};
14
15use atomic_write_file::AtomicWriteFile;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19mod device;
20mod key_trigger;
21mod settings;
22
23#[cfg(test)]
24mod tests;
25
26pub use device::{DeviceConfig, DeviceIdentity};
27pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
28pub use settings::LightSettings;
29pub use settings::{
30    AppSettings, Appearance, AssetSourcePreference, CameraControls, DEFAULT_THUMBWHEEL_SENSITIVITY,
31    GestureOwner, Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
32    SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
33    WheelMode,
34};
35
36use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
37use crate::paths::{self, PathsError};
38
39/// The schema version the current build produces. Bumped on breaking layout
40/// changes; readers branch on the parsed value before consuming the rest of
41/// the file.
42///
43/// v3 changes the device map from model keys to physical-device keys. No v2
44/// device entries are migrated because model-scoped settings cannot be assigned
45/// safely when two identical devices exist.
46///
47/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
48/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
49/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
50/// next save; [`Config::load_from_path`] rejects only versions *newer* than this
51/// so a forward file fails loudly instead of silently losing bindings.
52pub const SCHEMA_VERSION: u32 = 3;
53
54/// Top-level config document.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Config {
57    /// Schema version the file was written with. Compared against
58    /// [`SCHEMA_VERSION`] on load: older layouts migrate, newer ones are
59    /// rejected loudly rather than silently losing settings.
60    pub schema_version: u32,
61    /// Non-device-scoped preferences (autostart, tray, language, …).
62    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
63    pub app_settings: AppSettings,
64    /// Physical config key of the carousel-selected device, persisted so a
65    /// restart restores the last view rather than always landing on the
66    /// first paired device. `None` means "fall back to the first device".
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub selected_device: Option<String>,
69    /// When set (see [`Self::ephemeral`]), [`Self::save_atomic`] is a no-op:
70    /// this config never writes the on-disk file. Never true for a loaded or
71    /// default-constructed config.
72    #[serde(skip)]
73    ephemeral: bool,
74    /// Per-device state, keyed by the stable physical-device identifier
75    /// (e.g. `"receiver:abc123:slot:2"`) so two identical models never share
76    /// an entry.
77    #[serde(default)]
78    pub devices: BTreeMap<String, DeviceConfig>,
79    /// Keyboard remappings, independent of device. The function-key remapper
80    /// (M1) reads this; `#[serde(default)]` keeps older configs without a
81    /// `[keyboard]` section loading unchanged.
82    #[serde(default)]
83    pub keyboard: KeyboardConfig,
84}
85
86impl Default for Config {
87    fn default() -> Self {
88        Self {
89            schema_version: SCHEMA_VERSION,
90            app_settings: AppSettings::default(),
91            selected_device: None,
92            devices: BTreeMap::new(),
93            ephemeral: false,
94            keyboard: KeyboardConfig::default(),
95        }
96    }
97}
98
99/// Failure loading or persisting `config.toml`. The file-scoped variants
100/// carry the offending path so callers can surface an actionable message.
101#[derive(Debug, Error)]
102pub enum ConfigError {
103    /// The platform config directory could not be resolved (no home
104    /// directory for the current user).
105    #[error("could not resolve config path")]
106    Path(#[from] PathsError),
107    /// Reading the config file from disk failed.
108    #[error("could not read config at {path}")]
109    Read {
110        /// The config file the read targeted.
111        path: PathBuf,
112        /// The underlying I/O error.
113        #[source]
114        source: io::Error,
115    },
116    /// The file was read but is not valid TOML for this schema.
117    #[error("could not parse config at {path}")]
118    Parse {
119        /// The config file that failed to parse.
120        path: PathBuf,
121        /// The underlying TOML deserialization error.
122        #[source]
123        source: toml::de::Error,
124    },
125    /// Writing the updated config back to disk failed.
126    #[error("could not write config at {path}")]
127    Write {
128        /// The config file the write targeted.
129        path: PathBuf,
130        /// The underlying I/O error.
131        #[source]
132        source: io::Error,
133    },
134    /// The in-memory config could not be serialized to TOML — a bug in the
135    /// config types rather than user error, since [`Config`] always
136    /// serializes cleanly.
137    #[error("could not serialize config")]
138    Serialize(#[from] toml::ser::Error),
139    /// The file declares a `schema_version` newer than this build
140    /// understands; failing loudly avoids silently dropping settings a newer
141    /// build wrote.
142    #[error("config at {path} has unsupported schema_version {found}")]
143    UnsupportedSchemaVersion {
144        /// The config file carrying the unsupported version.
145        path: PathBuf,
146        /// The `schema_version` the file declared.
147        found: u32,
148    },
149}
150
151#[allow(
152    clippy::result_large_err,
153    reason = "Config I/O keeps rich parse/write context and is not a hot path"
154)]
155impl Config {
156    /// Loads the config from the default user path, returning
157    /// [`Config::default`] if the file does not exist yet.
158    pub fn load_or_default() -> Result<Self, ConfigError> {
159        Self::load_from_path(&paths::config_path()?)
160    }
161
162    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
163    /// to avoid touching the real user config.
164    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
165        match fs::read_to_string(path) {
166            Ok(text) => {
167                let mut config: Self =
168                    toml::from_str(&text).map_err(|source| ConfigError::Parse {
169                        path: path.to_path_buf(),
170                        source,
171                    })?;
172                // Accept any version up to the current one: older files migrate
173                // through the per-device [`RawDeviceConfig`] shim and self-heal on
174                // the next save. Only a *newer* file is rejected — loudly, so a
175                // downgraded binary refuses to load (and silently wipe) a config
176                // it can't represent.
177                if config.schema_version > SCHEMA_VERSION {
178                    return Err(ConfigError::UnsupportedSchemaVersion {
179                        path: path.to_path_buf(),
180                        found: config.schema_version,
181                    });
182                }
183                // Stamp the in-memory doc to the current version so a re-save
184                // writes the migrated v2 shape (the device shim already folded
185                // the legacy fields during deserialize).
186                config.schema_version = SCHEMA_VERSION;
187                Ok(config)
188            }
189            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
190            Err(source) => Err(ConfigError::Read {
191                path: path.to_path_buf(),
192                source,
193            }),
194        }
195    }
196
197    /// A config that never touches the on-disk file: [`Self::save_atomic`] is
198    /// a no-op. For tests that drive the state layer's persistence paths —
199    /// with a default config those would overwrite the developer's real
200    /// `config.toml` with test fixtures.
201    #[must_use]
202    pub fn ephemeral() -> Self {
203        Self {
204            ephemeral: true,
205            ..Self::default()
206        }
207    }
208
209    /// Writes the config atomically to the default user path: serialize to a
210    /// sibling temp file, then rename over the target. On Unix the temp file
211    /// is created with mode 0600. No-op for an [`Self::ephemeral`] config.
212    pub fn save_atomic(&self) -> Result<(), ConfigError> {
213        if self.ephemeral {
214            return Ok(());
215        }
216        self.save_to_path(&paths::config_path()?)
217    }
218
219    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
220    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
221        if let Some(parent) = path.parent() {
222            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
223                path: path.to_path_buf(),
224                source,
225            })?;
226        }
227        let body = toml::to_string_pretty(self)?;
228        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
229            path: path.to_path_buf(),
230            source,
231        })
232    }
233
234    /// Returns the bindings stored for `device_key`, or an empty map if the
235    /// device has no committed bindings yet.
236    #[must_use]
237    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
238        self.devices
239            .get(device_key)
240            .map(|d| d.bindings.clone())
241            .unwrap_or_default()
242    }
243
244    /// Records `binding` for `button` on `device_key`, creating the device
245    /// entry if needed. Replaces the whole binding (use
246    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
247    /// binding in place).
248    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
249        self.devices
250            .entry(device_key.to_string())
251            .or_default()
252            .bindings
253            .insert(button, binding);
254    }
255
256    /// Records (or, with `action = None`, clears) the F-key `trigger` binding
257    /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic —
258    /// one map applies across all keyboards — so this mirrors [`Self::set_binding`]
259    /// minus the device key.
260    pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
261        match action {
262            Some(a) => {
263                self.keyboard.bindings.insert(trigger, a);
264            }
265            None => {
266                self.keyboard.bindings.remove(&trigger);
267            }
268        }
269    }
270
271    /// The global keyboard F-key bindings (read accessor).
272    #[must_use]
273    pub fn keyboard_bindings(&self) -> &std::collections::HashMap<KeyTrigger, Action> {
274        &self.keyboard.bindings
275    }
276
277    /// Returns the gesture sub-bindings for `device_key`'s gesture button, or an
278    /// empty map if it isn't in gesture mode. Derived from the unified
279    /// [`DeviceConfig::bindings`]; kept as a convenience for the agent-side
280    /// per-direction adapter.
281    #[must_use]
282    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
283        match self
284            .devices
285            .get(device_key)
286            .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
287        {
288            Some(Binding::Gesture(map)) => map.clone(),
289            _ => BTreeMap::new(),
290        }
291    }
292
293    /// Records `action` for one `direction` of `button`'s gesture binding,
294    /// creating the device entry if needed.
295    ///
296    /// A button with no binding yet is seeded from its canonical
297    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
298    /// default direction map (including a [`GestureDirection::Click`]), so the
299    /// merged map never persists a gesture binding whose click projection is a
300    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
301    /// preserving its action as the `Click` entry.
302    pub fn set_gesture_direction(
303        &mut self,
304        device_key: &str,
305        button: ButtonId,
306        direction: GestureDirection,
307        action: Action,
308    ) {
309        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
310            map.insert(direction, action);
311        }
312    }
313
314    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
315    /// device + a default binding if needed and upgrading a [`Binding::Single`]
316    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
317    /// entry so the caller can finish it — seed every direction
318    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
319    /// [`Self::set_gesture_owner`] and [`Self::set_gesture_direction`] so the two
320    /// promote a button into gesture mode identically.
321    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
322        let entry = self
323            .devices
324            .entry(device_key.to_string())
325            .or_default()
326            .bindings
327            .entry(button)
328            .or_insert_with(|| default_binding_for(button));
329        entry.upgrade_to_gesture();
330        entry
331    }
332
333    /// The button that owns `device_key`'s single gesture role, or `None` when
334    /// gestures are turned off.
335    ///
336    /// Resolved from the explicit [`DeviceConfig::gesture_owner`] when present;
337    /// otherwise inferred (see `Self::infer_gesture_owner`) for configs
338    /// predating the field and freshly-migrated pre-v2 files. The dedicated
339    /// HID++ gesture button ([`ButtonId::GestureButton`]) owns the role by
340    /// default. At most one button gestures per device.
341    #[must_use]
342    pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
343        let Some(device) = self.devices.get(device_key) else {
344            // No config yet → the dedicated HID++ gesture button is the default gesture owner.
345            return Some(ButtonId::GestureButton);
346        };
347        match device.gesture_owner {
348            Some(GestureOwner::Off) => None,
349            Some(GestureOwner::Button(id)) => Some(id),
350            None => Self::infer_gesture_owner(&device.bindings),
351        }
352    }
353
354    /// Infer the gesture owner for a config predating the explicit
355    /// [`DeviceConfig::gesture_owner`] field, from the shape of `bindings` — the
356    /// pre-field behavior, so old/migrated configs keep working until the first
357    /// explicit owner change stamps the field.
358    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
359        // An OS-hook button left in gesture mode took the role over.
360        if let Some((id, _)) = bindings
361            .iter()
362            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
363        {
364            return Some(*id);
365        }
366        // A dedicated HID++ gesture button explicitly demoted to a single action means gestures off.
367        if matches!(
368            bindings.get(&ButtonId::GestureButton),
369            Some(Binding::Single(_))
370        ) {
371            return None;
372        }
373        // Default: the dedicated HID++ gesture button owns the gesture role.
374        Some(ButtonId::GestureButton)
375    }
376
377    /// Make `button` the device's sole gesture button.
378    ///
379    /// Records `button` as the explicit [`gesture_owner`](Self::gesture_owner), so
380    /// the one-gesture-button-per-device lock is a data-model fact rather than a
381    /// destructive demotion of the others — every other gesture-capable button
382    /// keeps its own gesture map intact, ready to restore if re-chosen, and is
383    /// simply not dispatched while it isn't the owner. `button` is given a full
384    /// [`Binding::Gesture`] map: a prior [`Binding::Single`] is kept as the
385    /// [`GestureDirection::Click`] action, any existing swipe arms are preserved,
386    /// and unbound directions are seeded from
387    /// [`default_gesture_binding`](crate::binding::default_gesture_binding) so every
388    /// gesture button exposes the same full five-direction set.
389    pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
390        self.devices
391            .entry(device_key.to_string())
392            .or_default()
393            .gesture_owner = Some(GestureOwner::Button(button));
394        self.ensure_gesture_binding(device_key, button)
395            .fill_gesture_defaults();
396    }
397
398    /// Turn gestures off for `device_key`, recording the explicit "off" choice.
399    /// Every button keeps its gesture map intact (nothing is destroyed), so
400    /// re-selecting a gesture owner later restores its directions exactly.
401    pub fn disable_gestures(&mut self, device_key: &str) {
402        self.devices
403            .entry(device_key.to_string())
404            .or_default()
405            .gesture_owner = Some(GestureOwner::Off);
406    }
407
408    /// Resolve the effective binding map for `device_key`, overlaying the
409    /// per-app entry for `bundle_id` (if any) on top of the global per-device
410    /// `bindings`. A per-app override replaces the whole button with a
411    /// [`Binding::Single`]; everything else falls through.
412    ///
413    /// Returns an empty map when the device has no recorded bindings yet.
414    /// Callers (the GUI / hook) layer their own defaults on top.
415    #[must_use]
416    pub fn effective_bindings(
417        &self,
418        device_key: &str,
419        bundle_id: Option<&str>,
420    ) -> BTreeMap<ButtonId, Binding> {
421        let Some(device) = self.devices.get(device_key) else {
422            return BTreeMap::new();
423        };
424        let mut out = device.bindings.clone();
425        if let Some(bid) = bundle_id
426            && let Some(overlay) = device.per_app_bindings.get(bid)
427        {
428            for (k, v) in overlay {
429                out.insert(*k, Binding::Single(v.clone()));
430            }
431        }
432        out
433    }
434
435    /// Records a per-app override. Creates the device + app entries as
436    /// needed; passing an action of `None` removes the override and prunes
437    /// the empty app map.
438    pub fn set_per_app_binding(
439        &mut self,
440        device_key: &str,
441        bundle_id: &str,
442        button: ButtonId,
443        action: Option<Action>,
444    ) {
445        let entry = self
446            .devices
447            .entry(device_key.to_string())
448            .or_default()
449            .per_app_bindings
450            .entry(bundle_id.to_string())
451            .or_default();
452        match action {
453            Some(a) => {
454                entry.insert(button, a);
455            }
456            None => {
457                entry.remove(&button);
458            }
459        }
460        if let Some(d) = self.devices.get_mut(device_key) {
461            d.per_app_bindings.retain(|_, m| !m.is_empty());
462        }
463    }
464
465    /// HID++ config key of the carousel-selected device, if any.
466    #[must_use]
467    pub fn selected_device(&self) -> Option<&str> {
468        self.selected_device.as_deref()
469    }
470
471    /// Update the carousel-selected device. Pass `None` to clear the
472    /// selection (e.g. when the previously-selected device disappears).
473    pub fn set_selected_device(&mut self, key: Option<String>) {
474        self.selected_device = key;
475    }
476
477    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
478    /// device has none configured yet.
479    #[must_use]
480    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
481        self.devices
482            .get(device_key)
483            .map(|d| d.dpi_presets.clone())
484            .unwrap_or_default()
485    }
486
487    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
488    /// clear (the device block is kept; the field is just omitted on save
489    /// thanks to `skip_serializing_if`).
490    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
491        self.devices
492            .entry(device_key.to_string())
493            .or_default()
494            .dpi_presets = presets;
495    }
496
497    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
498    /// device has never been seen online (or was configured before identities
499    /// were recorded).
500    #[must_use]
501    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
502        self.devices
503            .get(device_key)
504            .and_then(|d| d.identity.as_ref())
505    }
506
507    /// Record (or refresh) the identity captured for `device_key` while it was
508    /// online, creating the device entry if needed.
509    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
510        self.devices
511            .entry(device_key.to_string())
512            .or_default()
513            .identity = Some(identity);
514    }
515
516    /// Whether `device_key` has a non-empty per-app binding overlay for the
517    /// foreground app `app` (bundle id). Drives the menu-bar popover's "override
518    /// active" badge — when the current app has its own bindings for this
519    /// device, the global bindings are (partly) overridden.
520    #[must_use]
521    pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
522        self.devices.get(device_key).is_some_and(|d| {
523            d.per_app_bindings
524                .get(app)
525                .is_some_and(|overlay| !overlay.is_empty())
526        })
527    }
528
529    /// Iterate every device we've recorded an identity for, as
530    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
531    /// known device stays visible (with its panels) before any live probe.
532    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
533        self.devices
534            .iter()
535            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
536    }
537
538    /// The lighting config for `device_key`, or `None` if unset.
539    #[must_use]
540    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
541        self.devices
542            .get(device_key)
543            .and_then(|d| d.lighting.clone())
544    }
545
546    /// Replace the lighting config for `device_key`.
547    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
548        self.devices
549            .entry(device_key.to_string())
550            .or_default()
551            .lighting = Some(lighting);
552    }
553
554    /// The saved UVC image controls for `device_key`, or `None` if never set.
555    #[must_use]
556    pub fn camera_controls(&self, device_key: &str) -> Option<CameraControls> {
557        self.devices
558            .get(device_key)
559            .and_then(|d| d.camera_controls.clone())
560    }
561
562    /// Replace the saved UVC image controls for `device_key`.
563    pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) {
564        self.devices
565            .entry(device_key.to_string())
566            .or_default()
567            .camera_controls = Some(controls);
568    }
569
570    /// The saved custom camera profiles for `device_key` (name → snapshot).
571    #[must_use]
572    pub fn camera_profiles(&self, device_key: &str) -> BTreeMap<String, CameraControls> {
573        self.devices
574            .get(device_key)
575            .map(|d| d.camera_profiles.clone())
576            .unwrap_or_default()
577    }
578
579    /// Save (or overwrite) a custom camera profile for `device_key`.
580    pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) {
581        self.devices
582            .entry(device_key.to_string())
583            .or_default()
584            .camera_profiles
585            .insert(name.to_string(), snap);
586    }
587
588    /// Delete a custom camera profile, clearing the active selection if it
589    /// named it. Unknown names are a no-op.
590    pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) {
591        if let Some(device) = self.devices.get_mut(device_key) {
592            device.camera_profiles.remove(name);
593            if device.camera_profile.as_deref() == Some(name) {
594                device.camera_profile = None;
595            }
596        }
597    }
598
599    /// The last-applied camera profile name for `device_key`, if any.
600    #[must_use]
601    pub fn camera_active_profile(&self, device_key: &str) -> Option<String> {
602        self.devices
603            .get(device_key)
604            .and_then(|d| d.camera_profile.clone())
605    }
606
607    /// Record which camera profile `device_key` last applied.
608    pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option<String>) {
609        self.devices
610            .entry(device_key.to_string())
611            .or_default()
612            .camera_profile = name;
613    }
614
615    /// The standalone-light config for `device_key`, or `None` if unset.
616    #[must_use]
617    pub fn light(&self, device_key: &str) -> Option<LightSettings> {
618        self.devices.get(device_key).and_then(|d| d.light)
619    }
620
621    /// Replace the standalone-light config for `device_key`.
622    pub fn set_light(&mut self, device_key: &str, light: LightSettings) {
623        self.devices
624            .entry(device_key.to_string())
625            .or_default()
626            .light = Some(light);
627    }
628
629    /// The committed sensor DPI for `device_key`, or `None` if never set.
630    #[must_use]
631    pub fn dpi(&self, device_key: &str) -> Option<u32> {
632        self.devices.get(device_key).and_then(|d| d.dpi)
633    }
634
635    /// Record the committed sensor DPI for `device_key`, so the agent can
636    /// re-apply it when the device reconnects (#189).
637    pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
638        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
639    }
640
641    /// The SmartShift wheel config for `device_key`, or `None` if never set.
642    #[must_use]
643    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
644        self.devices.get(device_key).and_then(|d| d.smartshift)
645    }
646
647    /// The persisted keyboard Fn-lock state for `device_key`, or `None` when
648    /// the user never set one (the keyboard keeps its own state).
649    #[must_use]
650    pub fn fn_lock(&self, device_key: &str) -> Option<bool> {
651        self.devices.get(device_key).and_then(|d| d.fn_lock)
652    }
653
654    /// Record the SmartShift wheel config for `device_key`, so the agent can
655    /// re-apply it when the device reconnects (#189).
656    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
657        self.devices
658            .entry(device_key.to_string())
659            .or_default()
660            .smartshift = Some(smartshift);
661    }
662
663    /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false`
664    /// (the native direction) for an unconfigured or absent device.
665    #[must_use]
666    pub fn invert_scroll(&self, device_key: &str) -> bool {
667        self.devices
668            .get(device_key)
669            .is_some_and(|d| d.invert_scroll)
670    }
671
672    /// Set whether `device_key`'s scroll wheel is inverted. The agent reads this
673    /// on the next `ReloadConfig` and applies it in the OS hook.
674    pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
675        self.devices
676            .entry(device_key.to_string())
677            .or_default()
678            .invert_scroll = invert;
679    }
680
681    /// The configured wheel resolution for `device_key`, or `None` when
682    /// OpenLogi should leave the device's current resolution unchanged.
683    #[must_use]
684    pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
685        self.devices
686            .get(device_key)
687            .and_then(|device| device.scroll_resolution)
688    }
689
690    /// Set the wheel resolution OpenLogi should restore for `device_key`.
691    /// Passing `None` returns the device to its unmanaged default state.
692    pub fn set_scroll_resolution(
693        &mut self,
694        device_key: &str,
695        resolution: Option<ScrollResolution>,
696    ) {
697        self.devices
698            .entry(device_key.to_string())
699            .or_default()
700            .scroll_resolution = resolution;
701    }
702
703    /// Whether OpenLogi manages `device_key` at all (capture + volatile
704    /// re-apply). Unconfigured devices are managed.
705    #[must_use]
706    pub fn device_enabled(&self, device_key: &str) -> bool {
707        self.devices.get(device_key).is_none_or(|d| d.enabled)
708    }
709
710    /// Enable or disable OpenLogi's management of `device_key`.
711    pub fn set_device_enabled(&mut self, device_key: &str, enabled: bool) {
712        self.devices
713            .entry(device_key.to_string())
714            .or_default()
715            .enabled = enabled;
716    }
717
718    /// The effective thumb-wheel sensitivity for `device_key`: the device's
719    /// override when set, else the app-wide default.
720    #[must_use]
721    pub fn thumbwheel_sensitivity(&self, device_key: &str) -> i32 {
722        self.devices
723            .get(device_key)
724            .and_then(|d| d.thumbwheel_sensitivity)
725            .unwrap_or(self.app_settings.thumbwheel_sensitivity)
726    }
727
728    /// Set (or clear, with `None`) `device_key`'s thumb-wheel sensitivity
729    /// override.
730    pub fn set_device_thumbwheel_sensitivity(
731        &mut self,
732        device_key: &str,
733        sensitivity: Option<i32>,
734    ) {
735        self.devices
736            .entry(device_key.to_string())
737            .or_default()
738            .thumbwheel_sensitivity = sensitivity;
739    }
740}
741
742/// Write `bytes` to `path` atomically via a randomized temp file + rename,
743/// with the directory fsync the old hand-rolled writer lacked.
744fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
745    #[cfg_attr(
746        not(unix),
747        expect(unused_mut, reason = "only the unix path mutates the options")
748    )]
749    let mut options = AtomicWriteFile::options();
750    #[cfg(unix)]
751    {
752        use atomic_write_file::unix::OpenOptionsExt as _;
753        use std::os::unix::fs::OpenOptionsExt as _;
754        // Force 0600 on every save, matching the previous writer.
755        options.preserve_mode(false).mode(0o600);
756    }
757    let mut file = options.open(path)?;
758    io::Write::write_all(&mut file, bytes)?;
759    file.commit()
760}