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}