Skip to main content

openlogi_core/config/
settings.rs

1//! App-wide and per-device *value* settings: [`AppSettings`], [`Appearance`],
2//! [`Lighting`], [`ScrollResolution`], [`WheelMode`] / [`SmartShift`], and
3//! the legacy [`GestureOwner`], plus their serde helpers.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::binding::ButtonId;
10use crate::color::Rgb;
11
12/// Light/dark appearance preference. `System` follows the OS appearance (the
13/// historical behaviour); `Light` / `Dark` force a mode regardless of the OS.
14/// Platform-free so the core crate stays GUI-agnostic — the GUI maps this onto
15/// gpui-component's `ThemeMode`.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Appearance {
19    /// Follow the operating system's light/dark setting.
20    #[default]
21    System,
22    /// Always use the light variant of the selected theme.
23    Light,
24    /// Always use the dark variant of the selected theme.
25    Dark,
26}
27
28/// Preferred source for on-demand device assets.
29///
30/// `Automatic` races every built-in mirror; the other variants pin a sync to
31/// one source. The GUI maps this persisted preference to the shared asset
32/// client's source type, keeping endpoint URLs and npm routing out of config.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AssetSourcePreference {
36    /// Use the first healthy built-in mirror.
37    #[default]
38    Automatic,
39    /// Use OpenLogi's official asset endpoint.
40    #[serde(rename = "openlogi")]
41    OpenLogi,
42    /// Use the versioned endpoint on Cloudflare's network.
43    Cloudflare,
44    /// Use the versioned npm packages through Fastly's network.
45    Fastly,
46}
47
48/// App-wide preferences not tied to any particular device.
49///
50/// All fields are `#[serde(default)]` so adding a new one is backward
51/// compatible — old config files just keep the default for the new field.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
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(
135        default = "default_thumbwheel_sensitivity",
136        deserialize_with = "deserialize_thumbwheel_sensitivity"
137    )]
138    pub thumbwheel_sensitivity: i32,
139    /// Light/dark appearance preference. Defaults to following the OS.
140    #[serde(default)]
141    pub appearance: Appearance,
142    /// Name of the theme used in light mode (a [`crate`]-agnostic string
143    /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses
144    /// the OpenLogi brand light theme.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub theme_light: Option<String>,
147    /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark
148    /// theme.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub theme_dark: Option<String>,
151    /// Corner-radius override for the UI, in pixels (the Appearance page offers
152    /// `0` / `6` / `12`). `None` keeps each theme's own radius.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub ui_radius: Option<u8>,
155}
156
157/// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the
158/// wheel's horizontal scroll runs at 1× and the wheel is left to scroll
159/// natively (no HID++ diversion) unless a binding diverges from its default.
160pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
161/// Lowest selectable [`AppSettings::thumbwheel_sensitivity`].
162pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
163/// Highest selectable [`AppSettings::thumbwheel_sensitivity`].
164pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
165
166/// Clamp a UI-provided thumb-wheel sensitivity to the persisted range.
167#[must_use]
168pub fn clamp_thumbwheel_sensitivity(value: i32) -> i32 {
169    value.clamp(MIN_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY)
170}
171
172impl AppSettings {
173    /// `skip_serializing_if` helper: true when nothing diverges from the
174    /// default, so empty settings don't clutter `config.toml`.
175    #[must_use]
176    pub fn is_default(&self) -> bool {
177        self == &Self::default()
178    }
179}
180
181impl Default for AppSettings {
182    fn default() -> Self {
183        Self {
184            launch_at_login: false,
185            check_for_updates: false,
186            auto_install_updates: false,
187            update_prompt_seen: false,
188            show_in_menu_bar: true,
189            capture_mouse_events: true,
190            auto_download_assets: true,
191            asset_source: AssetSourcePreference::Automatic,
192            language: None,
193            thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
194            appearance: Appearance::System,
195            theme_light: None,
196            theme_dark: None,
197            ui_radius: None,
198        }
199    }
200}
201
202/// serde default for the on-by-default [`AppSettings`] toggles
203/// ([`AppSettings::show_in_menu_bar`], [`AppSettings::capture_mouse_events`],
204/// [`AppSettings::auto_download_assets`]), so configs predating a field keep the
205/// out-of-the-box behavior.
206fn default_true() -> bool {
207    true
208}
209
210/// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs
211/// predating the field at the 1× default.
212const fn default_thumbwheel_sensitivity() -> i32 {
213    DEFAULT_THUMBWHEEL_SENSITIVITY
214}
215
216pub(super) fn deserialize_thumbwheel_sensitivity<'de, D>(deserializer: D) -> Result<i32, D::Error>
217where
218    D: serde::Deserializer<'de>,
219{
220    let value = i32::deserialize(deserializer)?;
221    if (MIN_THUMBWHEEL_SENSITIVITY..=MAX_THUMBWHEEL_SENSITIVITY).contains(&value) {
222        Ok(value)
223    } else {
224        Err(serde::de::Error::custom(format_args!(
225            "thumbwheel sensitivity must be between {MIN_THUMBWHEEL_SENSITIVITY} and {MAX_THUMBWHEEL_SENSITIVITY}, got {value}"
226        )))
227    }
228}
229
230pub(super) fn deserialize_optional_thumbwheel_sensitivity<'de, D>(
231    deserializer: D,
232) -> Result<Option<i32>, D::Error>
233where
234    D: serde::Deserializer<'de>,
235{
236    let value = Option::<i32>::deserialize(deserializer)?;
237    value
238        .map(|value| {
239            if (MIN_THUMBWHEEL_SENSITIVITY..=MAX_THUMBWHEEL_SENSITIVITY).contains(&value) {
240                Ok(value)
241            } else {
242                Err(serde::de::Error::custom(format_args!(
243                    "thumbwheel sensitivity must be between {MIN_THUMBWHEEL_SENSITIVITY} and {MAX_THUMBWHEEL_SENSITIVITY}, got {value}"
244                )))
245            }
246        })
247        .transpose()
248}
249
250/// Per-device RGB lighting: a single static color, brightness, and on/off.
251/// Deliberately basic — per-key effects are a later addition.
252///
253/// Crosses the agent↔GUI IPC (`set_lighting`), so field order is wire format —
254/// changes require a `PROTOCOL_VERSION` bump (guarded by
255/// `openlogi-ipc/tests/wire_format.rs`).
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(deny_unknown_fields)]
258pub struct Lighting {
259    /// Master on/off for the device's lighting. The color and brightness
260    /// persist while disabled, so re-enabling restores the previous look.
261    #[serde(default = "default_lighting_enabled")]
262    pub enabled: bool,
263    /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value
264    /// that does not parse is rejected with its TOML location.
265    #[serde(
266        default = "default_lighting_color",
267        deserialize_with = "deserialize_lighting_color"
268    )]
269    pub color: Rgb,
270    /// Brightness percent (`0`–`100`).
271    #[serde(
272        default = "default_lighting_brightness",
273        deserialize_with = "deserialize_brightness"
274    )]
275    pub brightness: u8,
276}
277
278/// Persisted settings for a standalone light such as Logitech Litra.
279///
280/// Brightness is stored as a normalized percentage so the same config shape
281/// works for lumen-based, percentage-based, and stepped light protocols. The
282/// selected driver maps it to its native range when applying the setting.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct LightSettings {
286    /// Whether the light should be on.
287    #[serde(default = "default_true")]
288    pub enabled: bool,
289    /// Link power to aggregate host-camera activity. This is a policy setting:
290    /// brightness, colour temperature, and the persisted manual power choice
291    /// remain independent from the transient effective power state.
292    #[serde(default, skip_serializing_if = "is_false")]
293    pub auto_camera: bool,
294    /// Brightness across the device's advertised range.
295    #[serde(
296        default = "default_light_brightness",
297        deserialize_with = "deserialize_brightness"
298    )]
299    pub brightness_percent: u8,
300    /// Desired colour temperature, when the device supports it.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub temperature_kelvin: Option<u16>,
303    /// Optional colour for a driver that exposes RGB controls.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub color: Option<Rgb>,
306}
307
308const fn default_light_brightness() -> u8 {
309    100
310}
311
312impl Default for LightSettings {
313    fn default() -> Self {
314        Self {
315            enabled: true,
316            auto_camera: false,
317            brightness_percent: default_light_brightness(),
318            temperature_kelvin: None,
319            color: None,
320        }
321    }
322}
323
324impl LightSettings {
325    /// Create settings with a normalized brightness percentage.
326    #[must_use]
327    pub fn new(enabled: bool, brightness_percent: u8, temperature_kelvin: Option<u16>) -> Self {
328        Self {
329            enabled,
330            auto_camera: false,
331            brightness_percent: brightness_percent.min(100),
332            temperature_kelvin,
333            color: None,
334        }
335    }
336}
337
338#[allow(
339    clippy::trivially_copy_pass_by_ref,
340    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
341)]
342const fn is_false(value: &bool) -> bool {
343    !*value
344}
345
346impl Default for Lighting {
347    fn default() -> Self {
348        Self {
349            enabled: default_lighting_enabled(),
350            color: default_lighting_color(),
351            brightness: default_lighting_brightness(),
352        }
353    }
354}
355
356fn default_lighting_enabled() -> bool {
357    true
358}
359
360fn default_lighting_color() -> Rgb {
361    Rgb::WHITE
362}
363
364fn default_lighting_brightness() -> u8 {
365    100
366}
367
368/// Reject brightness outside the UI and hardware contract.
369fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
370where
371    D: serde::Deserializer<'de>,
372{
373    let value = u8::deserialize(deserializer)?;
374    if value <= 100 {
375        Ok(value)
376    } else {
377        Err(serde::de::Error::custom(format_args!(
378            "brightness must be between 0 and 100, got {value}"
379        )))
380    }
381}
382
383/// Accept the optional `#` prefix supported by older releases, then parse the
384/// validated RGB value.
385fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
386where
387    D: serde::Deserializer<'de>,
388{
389    let color = String::deserialize(deserializer)?;
390    color
391        .strip_prefix('#')
392        .unwrap_or(color.as_str())
393        .parse()
394        .map_err(serde::de::Error::custom)
395}
396
397/// Per-webcam UVC controls, keyed by control name (`brightness`, `focus`,
398/// `focus_auto`, …). Each value is the raw device unit (its scale comes from
399/// the camera's own min/max); auto toggles store 0/1. Persisted so values
400/// survive an unplug or reboot — the GUI re-applies them over USB when the
401/// camera is next viewed, since the hardware only retains them until it loses
402/// power. Serializes to the same TOML table the earlier fixed-field struct
403/// wrote, so existing saved controls load unchanged.
404#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(transparent)]
406pub struct CameraControls(pub BTreeMap<String, i32>);
407
408/// Vertical wheel reporting resolution for HID++ `0x2121 HiResWheel`.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum ScrollResolution {
412    /// One scroll report per physical ratchet step.
413    Low,
414    /// Finer-grained reports between physical ratchet steps.
415    High,
416}
417
418/// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky).
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum WheelMode {
422    /// Free-spin — the wheel rotates without détentes.
423    Free,
424    /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware
425    /// auto-releases into free-spin past the configured
426    /// [`auto_disengage`](SmartShift::auto_disengage) speed.
427    Ratchet,
428}
429
430/// SmartShift auto-disengage out-of-box default (`16` ≈ 4 turn/s, per the
431/// x2110 / x2111 spec). The sensitivity slider's default.
432pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: u8 = 16;
433
434/// Smallest auto-disengage threshold OpenLogi will store or apply (`8` ≈
435/// 2 turn/s). Below this the ratchet releases into free-spin at everyday scroll
436/// speeds, leaving the wheel "stuck" spinning (#317); `0` is also the firmware
437/// "do not change" sentinel that must never be stored as a real value. A
438/// persisted threshold below this floor is rejected on load.
439pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: u8 = 8;
440
441/// Reject a persisted auto-disengage threshold below the supported floor.
442fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error>
443where
444    D: serde::Deserializer<'de>,
445{
446    let value = u8::deserialize(deserializer)?;
447    if value >= SMARTSHIFT_MIN_AUTO_DISENGAGE {
448        Ok(value)
449    } else {
450        Err(serde::de::Error::custom(format_args!(
451            "SmartShift auto_disengage must be between {SMARTSHIFT_MIN_AUTO_DISENGAGE} and 255, got {value}"
452        )))
453    }
454}
455
456/// Per-device SmartShift wheel configuration, persisted so the agent can
457/// re-apply it when the device reconnects: the values are written to device
458/// RAM and do not survive a power cycle (#189), despite earlier assumptions
459/// that the device kept them in NVM.
460///
461/// Config-file only — never crosses the IPC (the agent reads it from
462/// `config.toml` on reload), so it is free to evolve without a
463/// `PROTOCOL_VERSION` bump.
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(deny_unknown_fields)]
466pub struct SmartShift {
467    /// The persisted wheel mode, re-applied to device RAM on reconnect.
468    pub mode: WheelMode,
469    /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s
470    /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value
471    /// below [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] is rejected on load.
472    #[serde(deserialize_with = "deserialize_auto_disengage")]
473    pub auto_disengage: u8,
474    /// Firmware tunable-torque level (`1`–`255`), `0` when the device does not
475    /// expose tunable torque. HID++ defines the full non-zero byte range.
476    pub tunable_torque: u8,
477}
478
479/// The v3-and-older owner-lock choice: which control owned a device's single
480/// gesture role. Deserialize-only since v4 — the load migration
481/// (`Config::migrate_owner_locked_gestures`) consumes it and rewrites the
482/// binding shapes, which are the whole truth from then on. Read as a bare TOML
483/// scalar (`"Off"` or a [`ButtonId`] name).
484#[derive(Clone, Copy, Debug, PartialEq, Eq)]
485pub(super) enum GestureOwner {
486    /// Gestures were explicitly turned off for this device.
487    Off,
488    /// The named button owned the gesture role.
489    Button(ButtonId),
490}
491
492/// Lenient legacy deserializer for v3-and-older `gesture_owner`. Those releases
493/// already treated an unknown value as absent and inferred the owner; preserving
494/// that behavior keeps migration compatible. Current schemas reject the field
495/// before device deserialization.
496pub(super) fn deserialize_gesture_owner<'de, D>(
497    deserializer: D,
498) -> Result<Option<GestureOwner>, D::Error>
499where
500    D: serde::Deserializer<'de>,
501{
502    let s = String::deserialize(deserializer)?;
503    if s == "Off" {
504        return Ok(Some(GestureOwner::Off));
505    }
506    // Parse the button name with a throwaway error type so an unknown token maps
507    // to `None` (infer) rather than propagating an error.
508    let button = ButtonId::deserialize(
509        serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
510    )
511    .ok();
512    Ok(button.map(GestureOwner::Button))
513}
514
515#[cfg(test)]
516#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn smartshift_rejects_values_outside_the_persisted_contract() {
522        let parse = |auto_disengage: u8, tunable_torque: u8| {
523            let body = format!(
524                "mode = \"ratchet\"\nauto_disengage = {auto_disengage}\ntunable_torque = {tunable_torque}\n"
525            );
526            toml::from_str::<SmartShift>(&body)
527        };
528        parse(SMARTSHIFT_MIN_AUTO_DISENGAGE - 1, 50)
529            .expect_err("auto_disengage below the persisted minimum must be rejected");
530        parse(SMARTSHIFT_MIN_AUTO_DISENGAGE, 50).expect("the minimum itself is in contract");
531        parse(0xff, 0xff).expect("the top of both ranges is in contract");
532    }
533}