openlogi_core/config/settings.rs
1//! App-wide and per-device *value* settings: [`AppSettings`], [`Appearance`],
2//! [`Lighting`], [`ScrollResolution`], [`WheelMode`] / [`SmartShift`], and
3//! [`GestureOwner`], plus
4//! their serde `default_*` / `deserialize_*` helpers.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::binding::ButtonId;
11use crate::color::Rgb;
12
13/// Light/dark appearance preference. `System` follows the OS appearance (the
14/// historical behaviour); `Light` / `Dark` force a mode regardless of the OS.
15/// Platform-free so the core crate stays GUI-agnostic — the GUI maps this onto
16/// gpui-component's `ThemeMode`.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Appearance {
20 /// Follow the operating system's light/dark setting.
21 #[default]
22 System,
23 /// Always use the light variant of the selected theme.
24 Light,
25 /// Always use the dark variant of the selected theme.
26 Dark,
27}
28
29/// Preferred source for on-demand device assets.
30///
31/// `Automatic` races every built-in mirror; the other variants pin a sync to
32/// one source. The GUI maps this persisted preference to the shared asset
33/// client's source type, keeping endpoint URLs and npm routing out of config.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum AssetSourcePreference {
37 /// Use the first healthy built-in mirror.
38 #[default]
39 Automatic,
40 /// Use OpenLogi's official asset endpoint.
41 #[serde(rename = "openlogi")]
42 OpenLogi,
43 /// Use the versioned endpoint on Cloudflare's network.
44 Cloudflare,
45 /// Use the versioned npm packages through Fastly's network.
46 Fastly,
47}
48
49/// App-wide preferences not tied to any particular device.
50///
51/// All fields are `#[serde(default)]` so adding a new one is backward
52/// compatible — old config files just keep the default for the new field.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[allow(
55 clippy::struct_excessive_bools,
56 reason = "independent on/off user preferences, not a state machine"
57)]
58pub struct AppSettings {
59 /// When true, a macOS `LaunchAgent` plist at
60 /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed
61 /// so the app starts on login (P2.2). The plist is reconciled with
62 /// this field on every startup; flipping the flag and relaunching is
63 /// enough to install / remove it.
64 #[serde(default)]
65 pub launch_at_login: bool,
66 /// Opt-in update check (P2.8). **Off by default** to honour the
67 /// README's "no telemetry, no auto-update poller" promise. When true,
68 /// the app makes exactly one `HEAD /repos/AprilNEA/OpenLogi/releases/
69 /// latest` request per launch and logs whether a newer version is
70 /// available — no automatic download.
71 #[serde(default)]
72 pub check_for_updates: bool,
73 /// Opt-in automatic install. When true *and* [`Self::check_for_updates`]
74 /// surfaces a newer version, the GUI downloads and stages it in the
75 /// background; the update is applied on the next restart (never mid-session,
76 /// and never auto-relaunched). **Off by default** — it only acts after a
77 /// check the user already opted into, and stays inert in unsigned dev builds
78 /// where verification fails closed.
79 #[serde(default)]
80 pub auto_install_updates: bool,
81 /// True once the first-run "check for updates?" prompt has been answered
82 /// (either way), so it is never shown again. The prompt is how a
83 /// privacy-conscious default of `check_for_updates = false` still lets a
84 /// user opt in on first launch.
85 #[serde(default)]
86 pub update_prompt_seen: bool,
87 /// Whether OpenLogi shows a macOS menu-bar (status item) icon — and, on
88 /// Windows, the notification-area (tray) icon. `true` (default) → the
89 /// agent is visible in the menu bar / tray; `false` → it runs with no
90 /// visible presence (macOS additionally keeps the ordinary Dock icon
91 /// while a window is open). Ignored on Linux.
92 #[serde(default = "default_true")]
93 pub show_in_menu_bar: bool,
94 /// Whether the agent installs the OS-level mouse hook (CGEventTap /
95 /// exclusive `evdev` grab / `WH_MOUSE_LL`) that intercepts mouse events
96 /// for button remapping. `true` (default) keeps remapping active;
97 /// `false` is an escape hatch that leaves every input device untouched
98 /// (on Linux: no exclusive grabs at all; on macOS the agent also skips
99 /// the startup Accessibility prompt). HID++-side features — DPI,
100 /// SmartShift, the gesture button, the thumb wheel — are unaffected.
101 /// Takes effect on agent restart.
102 #[serde(default = "default_true")]
103 pub capture_mouse_events: bool,
104 /// Whether the GUI automatically downloads device images from
105 /// `assets.openlogi.org` when a device appears. `true` (default) keeps
106 /// the current behavior; `false` makes no asset network requests at all
107 /// (the app falls back to bundled art and the synthetic silhouette). A
108 /// manual "Refresh assets" in Settings still fetches on demand regardless.
109 /// Whether the GUI automatically downloads device images from the selected
110 /// source when a device appears. `true` (default) keeps the current behavior;
111 /// `false` makes no asset network requests at all (the app falls back to
112 /// bundled art and the synthetic silhouette). A manual "Refresh assets" in
113 /// Settings still fetches on demand regardless.
114 #[serde(default = "default_true")]
115 pub auto_download_assets: bool,
116 /// Preferred mirror for automatic and manual device-asset downloads.
117 /// Defaults to racing all built-in mirrors; `OPENLOGI_ASSETS` remains a
118 /// process-level override for development and diagnostics.
119 #[serde(default)]
120 pub asset_source: AssetSourcePreference,
121 /// UI language as a BCP-47-ish locale code matching the GUI's bundled
122 /// locales (e.g. `"en"`, `"de"`, `"pt-BR"`, `"zh-CN"`, `"zh-TW"`; see the
123 /// GUI's `i18n::SUPPORTED`). `None` means "follow the system locale", which
124 /// the GUI resolves at startup. Stored here so a user's explicit choice
125 /// survives restarts regardless of the OS setting.
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub language: Option<String>,
128 /// Thumb-wheel responsiveness, on a [`MIN_THUMBWHEEL_SENSITIVITY`]–
129 /// [`MAX_THUMBWHEEL_SENSITIVITY`] scale. It scales both the speed of the
130 /// wheel's continuous horizontal scroll and how few rotation increments a
131 /// custom wheel action needs to fire. [`DEFAULT_THUMBWHEEL_SENSITIVITY`]
132 /// (the out-of-the-box value) means 1× scroll speed; the wheel is only
133 /// diverted from native scrolling once this leaves the default.
134 #[serde(default = "default_thumbwheel_sensitivity")]
135 pub thumbwheel_sensitivity: i32,
136 /// Light/dark appearance preference. Defaults to following the OS.
137 #[serde(default)]
138 pub appearance: Appearance,
139 /// Name of the theme used in light mode (a [`crate`]-agnostic string
140 /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses
141 /// the OpenLogi brand light theme.
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub theme_light: Option<String>,
144 /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark
145 /// theme.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub theme_dark: Option<String>,
148 /// Corner-radius override for the UI, in pixels (the Appearance page offers
149 /// `0` / `6` / `12`). `None` keeps each theme's own radius.
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub ui_radius: Option<u8>,
152}
153
154/// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the
155/// wheel's horizontal scroll runs at 1× and the wheel is left to scroll
156/// natively (no HID++ diversion) unless a binding diverges from its default.
157pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
158/// Lowest selectable [`AppSettings::thumbwheel_sensitivity`].
159pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
160/// Highest selectable [`AppSettings::thumbwheel_sensitivity`].
161pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
162
163impl AppSettings {
164 /// `skip_serializing_if` helper: true when nothing diverges from the
165 /// default, so empty settings don't clutter `config.toml`.
166 #[must_use]
167 pub fn is_default(&self) -> bool {
168 self == &Self::default()
169 }
170}
171
172impl Default for AppSettings {
173 fn default() -> Self {
174 Self {
175 launch_at_login: false,
176 check_for_updates: false,
177 auto_install_updates: false,
178 update_prompt_seen: false,
179 show_in_menu_bar: true,
180 capture_mouse_events: true,
181 auto_download_assets: true,
182 asset_source: AssetSourcePreference::Automatic,
183 language: None,
184 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
185 appearance: Appearance::System,
186 theme_light: None,
187 theme_dark: None,
188 ui_radius: None,
189 }
190 }
191}
192
193/// serde default for the on-by-default [`AppSettings`] toggles
194/// ([`AppSettings::show_in_menu_bar`], [`AppSettings::capture_mouse_events`],
195/// [`AppSettings::auto_download_assets`]), so configs predating a field keep the
196/// out-of-the-box behavior.
197fn default_true() -> bool {
198 true
199}
200
201/// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs
202/// predating the field at the 1× default.
203const fn default_thumbwheel_sensitivity() -> i32 {
204 DEFAULT_THUMBWHEEL_SENSITIVITY
205}
206
207/// Per-device RGB lighting: a single static color, brightness, and on/off.
208/// Deliberately basic — per-key effects are a later addition.
209///
210/// Crosses the agent↔GUI IPC (`set_lighting`), so field order is wire format —
211/// changes require a `PROTOCOL_VERSION` bump (guarded by
212/// `openlogi-agent-core/tests/wire_format.rs`).
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct Lighting {
215 /// Master on/off for the device's lighting. The color and brightness
216 /// persist while disabled, so re-enabling restores the previous look.
217 #[serde(default = "default_lighting_enabled")]
218 pub enabled: bool,
219 /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value
220 /// that does not parse falls back to white on load — the same per-field
221 /// tolerance as `brightness`, because failing the whole load would
222 /// discard the user's entire config (see the `load_or_default` callers).
223 #[serde(
224 default = "default_lighting_color",
225 deserialize_with = "deserialize_lighting_color"
226 )]
227 pub color: Rgb,
228 /// Brightness percent, clamped to 0–100 on load.
229 #[serde(
230 default = "default_lighting_brightness",
231 deserialize_with = "deserialize_brightness"
232 )]
233 pub brightness: u8,
234}
235
236/// Persisted settings for a standalone light such as Logitech Litra.
237///
238/// Brightness is stored as a normalized percentage so the same config shape
239/// works for lumen-based, percentage-based, and stepped light protocols. The
240/// selected driver maps it to its native range when applying the setting.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242pub struct LightSettings {
243 /// Whether the light should be on.
244 #[serde(default = "default_true")]
245 pub enabled: bool,
246 /// Link power to aggregate host-camera activity. This is a policy setting:
247 /// brightness, colour temperature, and the persisted manual power choice
248 /// remain independent from the transient effective power state.
249 #[serde(default, skip_serializing_if = "is_false")]
250 pub auto_camera: bool,
251 /// Brightness across the device's advertised range.
252 #[serde(
253 default = "default_light_brightness",
254 deserialize_with = "deserialize_brightness"
255 )]
256 pub brightness_percent: u8,
257 /// Desired colour temperature, when the device supports it.
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub temperature_kelvin: Option<u16>,
260 /// Optional colour for a driver that exposes RGB controls.
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub color: Option<Rgb>,
263}
264
265const fn default_light_brightness() -> u8 {
266 100
267}
268
269impl Default for LightSettings {
270 fn default() -> Self {
271 Self {
272 enabled: true,
273 auto_camera: false,
274 brightness_percent: default_light_brightness(),
275 temperature_kelvin: None,
276 color: None,
277 }
278 }
279}
280
281impl LightSettings {
282 /// Create settings with a normalized brightness percentage.
283 #[must_use]
284 pub fn new(enabled: bool, brightness_percent: u8, temperature_kelvin: Option<u16>) -> Self {
285 Self {
286 enabled,
287 auto_camera: false,
288 brightness_percent,
289 temperature_kelvin,
290 color: None,
291 }
292 }
293}
294
295#[allow(
296 clippy::trivially_copy_pass_by_ref,
297 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
298)]
299const fn is_false(value: &bool) -> bool {
300 !*value
301}
302
303impl Default for Lighting {
304 fn default() -> Self {
305 Self {
306 enabled: default_lighting_enabled(),
307 color: default_lighting_color(),
308 brightness: default_lighting_brightness(),
309 }
310 }
311}
312
313fn default_lighting_enabled() -> bool {
314 true
315}
316
317fn default_lighting_color() -> Rgb {
318 Rgb::WHITE
319}
320
321fn default_lighting_brightness() -> u8 {
322 100
323}
324
325/// Clamp a deserialized brightness into the UI's `0..=100` range, so a
326/// hand-edited `config.toml` can't feed out-of-range values into the scaling
327/// math (which assumes `brightness <= 100`).
328fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
329where
330 D: serde::Deserializer<'de>,
331{
332 Ok(u8::deserialize(deserializer)?.min(100))
333}
334
335/// Accept the optional `#` prefix supported by older releases, then fall back
336/// to white when the configured color does not parse, mirroring the `brightness`
337/// clamp above instead of failing the whole config load.
338fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
339where
340 D: serde::Deserializer<'de>,
341{
342 let color = String::deserialize(deserializer)?;
343 Ok(color
344 .strip_prefix('#')
345 .unwrap_or(color.as_str())
346 .parse()
347 .unwrap_or(Rgb::WHITE))
348}
349
350/// Per-webcam UVC controls, keyed by control name (`brightness`, `focus`,
351/// `focus_auto`, …). Each value is the raw device unit (its scale comes from
352/// the camera's own min/max); auto toggles store 0/1. Persisted so values
353/// survive an unplug or reboot — the GUI re-applies them over USB when the
354/// camera is next viewed, since the hardware only retains them until it loses
355/// power. Serializes to the same TOML table the earlier fixed-field struct
356/// wrote, so existing saved controls load unchanged.
357#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(transparent)]
359pub struct CameraControls(pub BTreeMap<String, i32>);
360
361/// Vertical wheel reporting resolution for HID++ `0x2121 HiResWheel`.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum ScrollResolution {
365 /// One scroll report per physical ratchet step.
366 Low,
367 /// Finer-grained reports between physical ratchet steps.
368 High,
369}
370
371/// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky).
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum WheelMode {
375 /// Free-spin — the wheel rotates without détentes.
376 Free,
377 /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware
378 /// auto-releases into free-spin past the configured
379 /// [`auto_disengage`](SmartShift::auto_disengage) speed.
380 Ratchet,
381}
382
383/// SmartShift auto-disengage out-of-box default (`16` ≈ 4 turn/s, per the
384/// x2110 / x2111 spec). The sensitivity slider's default and the heal target
385/// for a corrupt persisted threshold.
386pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: u8 = 16;
387
388/// Smallest auto-disengage threshold OpenLogi will store or apply (`8` ≈
389/// 2 turn/s). Below this the ratchet releases into free-spin at everyday scroll
390/// speeds, leaving the wheel "stuck" spinning (#317); `0` is also the firmware
391/// "do not change" sentinel that must never be stored as a real value. A
392/// persisted threshold below this floor is a corrupt artifact and is healed to
393/// [`SMARTSHIFT_AUTO_DISENGAGE_DEFAULT`] on load.
394pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: u8 = 8;
395
396/// Heal a persisted auto-disengage threshold on load: anything below
397/// [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] (including the `0` sentinel) becomes the
398/// default. `0xFF` (permanent ratchet) and every real threshold at or above the
399/// floor pass through unchanged.
400fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error>
401where
402 D: serde::Deserializer<'de>,
403{
404 let value = u8::deserialize(deserializer)?;
405 Ok(if value < SMARTSHIFT_MIN_AUTO_DISENGAGE {
406 tracing::warn!(
407 value,
408 min = SMARTSHIFT_MIN_AUTO_DISENGAGE,
409 default = SMARTSHIFT_AUTO_DISENGAGE_DEFAULT,
410 "healed persisted SmartShift auto-disengage threshold below supported floor"
411 );
412 SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
413 } else {
414 value
415 })
416}
417
418/// Per-device SmartShift wheel configuration, persisted so the agent can
419/// re-apply it when the device reconnects: the values are written to device
420/// RAM and do not survive a power cycle (#189), despite earlier assumptions
421/// that the device kept them in NVM.
422///
423/// Config-file only — never crosses the IPC (the agent reads it from
424/// `config.toml` on reload), so it is free to evolve without a
425/// `PROTOCOL_VERSION` bump.
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
427pub struct SmartShift {
428 /// The persisted wheel mode, re-applied to device RAM on reconnect.
429 pub mode: WheelMode,
430 /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s
431 /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value
432 /// below [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] is healed to the default on load.
433 #[serde(deserialize_with = "deserialize_auto_disengage")]
434 pub auto_disengage: u8,
435 /// Tunable-torque force percentage (`1`–`100`), `0` when the device
436 /// doesn't support tunable torque.
437 pub tunable_torque: u8,
438}
439
440/// Which control owns a device's single gesture role.
441///
442/// Stored explicitly — rather than inferred from which button happens to carry a
443/// [`Binding::Gesture`](crate::binding::Binding::Gesture) — so switching the
444/// gesture button never has to collapse a button's gesture map to encode the
445/// choice: every gesture-capable button keeps its full direction map, and only
446/// the owner is dispatched. Serialized as a bare string (`"Off"` or a
447/// [`ButtonId`] name) so it stays a TOML scalar.
448#[derive(Clone, Copy, Debug, PartialEq, Eq)]
449pub enum GestureOwner {
450 /// Gestures are explicitly turned off for this device.
451 Off,
452 /// The named button owns the gesture role.
453 Button(ButtonId),
454}
455
456impl Serialize for GestureOwner {
457 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
458 match self {
459 // "Off" can't collide with a ButtonId variant name (all CamelCase
460 // control names), so the string space is unambiguous.
461 GestureOwner::Off => serializer.serialize_str("Off"),
462 GestureOwner::Button(id) => id.serialize(serializer),
463 }
464 }
465}
466
467/// Lenient field deserializer for `RawDeviceConfig::gesture_owner`
468/// (`crate::config::device`). An unrecognized or miscased value (`"back"`, a
469/// typo, a future-version button name) is treated as absent — i.e. "infer the
470/// owner" — rather than failing the whole-document parse and reverting *every*
471/// device's settings to defaults. Mirrors [`deserialize_brightness`], which
472/// clamps a bad value instead of erroring; a hand-editable config should
473/// degrade one field, not the document.
474pub(super) fn deserialize_gesture_owner<'de, D>(
475 deserializer: D,
476) -> Result<Option<GestureOwner>, D::Error>
477where
478 D: serde::Deserializer<'de>,
479{
480 let s = String::deserialize(deserializer)?;
481 if s == "Off" {
482 return Ok(Some(GestureOwner::Off));
483 }
484 // Parse the button name with a throwaway error type so an unknown token maps
485 // to `None` (infer) rather than propagating an error.
486 let button = ButtonId::deserialize(
487 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
488 )
489 .ok();
490 Ok(button.map(GestureOwner::Button))
491}
492
493#[cfg(test)]
494#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn low_auto_disengage_heals_to_default_on_load() {
500 // A pre-#317 config could persist a runaway-low threshold (or the `0`
501 // sentinel); loading it must heal to the default so reapply doesn't
502 // re-program free-spin-on-any-scroll into the device — while a real
503 // threshold and the `0xFF` permanent-ratchet value pass through.
504 let heal = |v: u8| {
505 let body = format!("mode = \"ratchet\"\nauto_disengage = {v}\ntunable_torque = 50\n");
506 toml::from_str::<SmartShift>(&body)
507 .expect("parse")
508 .auto_disengage
509 };
510 assert_eq!(heal(0), SMARTSHIFT_AUTO_DISENGAGE_DEFAULT);
511 assert_eq!(heal(1), SMARTSHIFT_AUTO_DISENGAGE_DEFAULT);
512 assert_eq!(
513 heal(SMARTSHIFT_MIN_AUTO_DISENGAGE - 1),
514 SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
515 );
516 assert_eq!(
517 heal(SMARTSHIFT_MIN_AUTO_DISENGAGE),
518 SMARTSHIFT_MIN_AUTO_DISENGAGE
519 );
520 assert_eq!(heal(16), 16);
521 assert_eq!(heal(0xff), 0xff);
522 }
523}