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}