use std::{
collections::{BTreeMap, HashSet},
ffi::OsString,
fs, io,
path::{Path, PathBuf},
sync::{Mutex, OnceLock, PoisonError},
};
use atomic_write_file::AtomicWriteFile;
use serde::{Deserialize, Serialize};
use thiserror::Error;
mod device;
mod key_trigger;
mod settings;
#[cfg(test)]
mod tests;
pub use device::{DeviceConfig, DeviceIdentity};
pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
pub use settings::LightSettings;
pub use settings::{
AppSettings, Appearance, AssetSourcePreference, CameraControls, DEFAULT_THUMBWHEEL_SENSITIVITY,
GestureOwner, Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
WheelMode,
};
use crate::binding::{
Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GestureDirection,
RingAction, default_binding, default_binding_for, default_gesture_binding,
};
use crate::paths::{self, PathsError};
pub const SCHEMA_VERSION: u32 = 4;
const CONFIG_BACKUP_GENERATIONS: usize = 5;
static BACKED_UP_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "AppSettings::is_default")]
pub app_settings: AppSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_device: Option<String>,
#[serde(skip)]
ephemeral: bool,
#[serde(default)]
pub devices: BTreeMap<String, DeviceConfig>,
#[serde(default)]
pub keyboard: KeyboardConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
app_settings: AppSettings::default(),
selected_device: None,
devices: BTreeMap::new(),
ephemeral: false,
keyboard: KeyboardConfig::default(),
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("could not resolve config path")]
Path(#[from] PathsError),
#[error("could not read config at {path}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not parse config at {path}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("could not write config at {path}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not serialize config")]
Serialize(#[from] toml::ser::Error),
#[error("config at {path} has unsupported schema_version {found}")]
UnsupportedSchemaVersion {
path: PathBuf,
found: u32,
},
}
#[allow(
clippy::result_large_err,
reason = "Config I/O keeps rich parse/write context and is not a hot path"
)]
impl Config {
pub fn load_or_default() -> Result<Self, ConfigError> {
Self::load_from_path(&paths::config_path()?)
}
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
match fs::read_to_string(path) {
Ok(text) => {
let mut config: Self =
toml::from_str(&text).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
if config.schema_version > SCHEMA_VERSION {
return Err(ConfigError::UnsupportedSchemaVersion {
path: path.to_path_buf(),
found: config.schema_version,
});
}
if config.schema_version <= 3 {
config.migrate_owner_locked_gestures();
}
config.schema_version = SCHEMA_VERSION;
Ok(config)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(ConfigError::Read {
path: path.to_path_buf(),
source,
}),
}
}
#[must_use]
pub fn ephemeral() -> Self {
Self {
ephemeral: true,
..Self::default()
}
}
pub fn save_atomic(&self) -> Result<(), ConfigError> {
if self.ephemeral {
return Ok(());
}
self.save_to_path(&paths::config_path()?)
}
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
path: path.to_path_buf(),
source,
})?;
}
let body = toml::to_string_pretty(self)?;
backup_config_once(path).map_err(|source| ConfigError::Write {
path: path.to_path_buf(),
source,
})?;
write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
path: path.to_path_buf(),
source,
})
}
#[must_use]
pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
self.devices
.get(device_key)
.map(|d| d.bindings.clone())
.unwrap_or_default()
}
pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
self.devices
.entry(device_key.to_string())
.or_default()
.bindings
.insert(button, binding);
}
pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
match action {
Some(a) => {
self.keyboard.bindings.insert(trigger, a);
}
None => {
self.keyboard.bindings.remove(&trigger);
}
}
}
#[must_use]
pub fn keyboard_bindings(&self) -> &std::collections::HashMap<KeyTrigger, Action> {
&self.keyboard.bindings
}
pub fn set_gesture_direction(
&mut self,
device_key: &str,
button: ButtonId,
direction: GestureDirection,
action: Action,
) {
if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
map.insert(direction, action);
}
}
fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
let entry = self
.devices
.entry(device_key.to_string())
.or_default()
.bindings
.entry(button)
.or_insert_with(|| default_binding_for(button));
entry.upgrade_to_gesture();
entry
}
fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
if let Some((id, _)) = bindings
.iter()
.find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
{
return Some(*id);
}
if matches!(
bindings.get(&ButtonId::GestureButton),
Some(Binding::Single(_))
) {
return None;
}
Some(ButtonId::GestureButton)
}
#[must_use]
pub fn is_gesture_mode(&self, device_key: &str, button: ButtonId) -> bool {
self.devices
.get(device_key)
.and_then(|d| d.bindings.get(&button))
.map_or_else(
|| default_binding_for(button).is_gesture(),
Binding::is_gesture,
)
}
#[must_use]
pub fn gesture_mode_buttons(&self, device_key: &str) -> Vec<ButtonId> {
ButtonId::ALL
.iter()
.copied()
.filter(|b| self.is_gesture_mode(device_key, *b))
.collect()
}
pub fn set_gesture_mode(&mut self, device_key: &str, button: ButtonId, enabled: bool) {
if enabled {
let device = self.devices.entry(device_key.to_string()).or_default();
if let Some(map) = device.disabled_gestures.remove(&button) {
device.bindings.insert(button, Binding::Gesture(map));
} else {
self.ensure_gesture_binding(device_key, button)
.fill_gesture_defaults();
}
return;
}
let device = self.devices.entry(device_key.to_string()).or_default();
match device.bindings.get_mut(&button) {
Some(binding) => {
if let Binding::Gesture(map) = binding {
device.disabled_gestures.insert(button, map.clone());
}
binding.demote_to_single(default_binding(button));
}
None => {
if default_binding_for(button).is_gesture() {
device.disabled_gestures.insert(
button,
GestureDirection::ALL
.iter()
.copied()
.map(|d| (d, default_gesture_binding(d)))
.collect(),
);
device
.bindings
.insert(button, Binding::Single(default_binding(button)));
}
}
}
}
fn migrate_owner_locked_gestures(&mut self) {
for device in self.devices.values_mut() {
let owner = match device.gesture_owner.take() {
Some(GestureOwner::Off) => None,
Some(GestureOwner::Button(id)) => Some(id),
None => Self::infer_gesture_owner(&device.bindings),
};
for (id, binding) in &mut device.bindings {
if Some(*id) != owner {
if let Binding::Gesture(map) = binding {
device.disabled_gestures.insert(*id, map.clone());
}
binding.demote_to_single(default_binding(*id));
}
}
if let Some(owner) = owner
&& owner.is_hidpp_gesture_source()
{
let seeded = || {
Binding::Gesture(
GestureDirection::ALL
.iter()
.copied()
.map(|d| (d, default_gesture_binding(d)))
.collect(),
)
};
match device.bindings.get_mut(&owner) {
Some(binding) if !binding.is_gesture() => *binding = seeded(),
Some(_) => {}
None => {
if !default_binding_for(owner).is_gesture() {
device.bindings.insert(owner, seeded());
}
}
}
}
if owner != Some(ButtonId::GestureButton) {
device
.bindings
.entry(ButtonId::GestureButton)
.or_insert_with(|| Binding::Single(default_binding(ButtonId::GestureButton)));
}
}
}
#[must_use]
pub fn effective_bindings(
&self,
device_key: &str,
bundle_id: Option<&str>,
) -> BTreeMap<ButtonId, Binding> {
let Some(device) = self.devices.get(device_key) else {
return BTreeMap::new();
};
let mut out = device.bindings.clone();
if let Some(bid) = bundle_id
&& let Some(overlay) = app_overlay(&device.per_app_bindings, bid)
{
for (k, v) in overlay {
out.insert(*k, Binding::Single(v.clone()));
}
}
out
}
pub fn set_per_app_binding(
&mut self,
device_key: &str,
bundle_id: &str,
button: ButtonId,
action: Option<Action>,
) {
let entry = self
.devices
.entry(device_key.to_string())
.or_default()
.per_app_bindings
.entry(bundle_id.to_string())
.or_default();
match action {
Some(a) => {
entry.insert(button, a);
}
None => {
entry.remove(&button);
}
}
if let Some(d) = self.devices.get_mut(device_key) {
d.per_app_bindings.retain(|_, m| !m.is_empty());
}
}
#[must_use]
pub fn action_ring(&self, device_key: &str) -> ActionRingConfig {
self.devices
.get(device_key)
.map(|device| device.action_ring.clone())
.unwrap_or_default()
}
pub fn set_action_ring_enabled(&mut self, device_key: &str, enabled: bool) {
self.devices
.entry(device_key.to_string())
.or_default()
.action_ring
.enabled = enabled;
}
pub fn set_action_ring_haptics(&mut self, device_key: &str, enabled: bool) {
self.devices
.entry(device_key.to_string())
.or_default()
.action_ring
.haptics = enabled;
}
pub fn set_action_ring_slot(
&mut self,
device_key: &str,
slot: ActionRingSlot,
action: Option<RingAction>,
) {
self.devices
.entry(device_key.to_string())
.or_default()
.action_ring
.default
.set_action(slot, action);
}
pub fn set_action_ring_icon(
&mut self,
device_key: &str,
slot: ActionRingSlot,
icon: Option<ActionRingIcon>,
) {
self.devices
.entry(device_key.to_string())
.or_default()
.action_ring
.default
.set_icon(slot, icon);
}
#[must_use]
pub fn selected_device(&self) -> Option<&str> {
self.selected_device.as_deref()
}
pub fn set_selected_device(&mut self, key: Option<String>) {
self.selected_device = key;
}
#[must_use]
pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
self.devices
.get(device_key)
.map(|d| d.dpi_presets.clone())
.unwrap_or_default()
}
pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
self.devices
.entry(device_key.to_string())
.or_default()
.dpi_presets = presets;
}
#[must_use]
pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
self.devices
.get(device_key)
.and_then(|d| d.identity.as_ref())
}
pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
self.devices
.entry(device_key.to_string())
.or_default()
.identity = Some(identity);
}
#[must_use]
pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
self.devices.get(device_key).is_some_and(|d| {
app_overlay(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty())
})
}
pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
self.devices
.iter()
.filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
}
#[must_use]
pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
self.devices
.get(device_key)
.and_then(|d| d.lighting.clone())
}
pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
self.devices
.entry(device_key.to_string())
.or_default()
.lighting = Some(lighting);
}
#[must_use]
pub fn camera_controls(&self, device_key: &str) -> Option<CameraControls> {
self.devices
.get(device_key)
.and_then(|d| d.camera_controls.clone())
}
pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) {
self.devices
.entry(device_key.to_string())
.or_default()
.camera_controls = Some(controls);
}
#[must_use]
pub fn camera_profiles(&self, device_key: &str) -> BTreeMap<String, CameraControls> {
self.devices
.get(device_key)
.map(|d| d.camera_profiles.clone())
.unwrap_or_default()
}
pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) {
self.devices
.entry(device_key.to_string())
.or_default()
.camera_profiles
.insert(name.to_string(), snap);
}
pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) {
if let Some(device) = self.devices.get_mut(device_key) {
device.camera_profiles.remove(name);
if device.camera_profile.as_deref() == Some(name) {
device.camera_profile = None;
}
}
}
#[must_use]
pub fn camera_active_profile(&self, device_key: &str) -> Option<String> {
self.devices
.get(device_key)
.and_then(|d| d.camera_profile.clone())
}
pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option<String>) {
self.devices
.entry(device_key.to_string())
.or_default()
.camera_profile = name;
}
#[must_use]
pub fn light(&self, device_key: &str) -> Option<LightSettings> {
self.devices.get(device_key).and_then(|d| d.light)
}
pub fn set_light(&mut self, device_key: &str, light: LightSettings) {
self.devices
.entry(device_key.to_string())
.or_default()
.light = Some(light);
}
#[must_use]
pub fn dpi(&self, device_key: &str) -> Option<u32> {
self.devices.get(device_key).and_then(|d| d.dpi)
}
pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
}
#[must_use]
pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
self.devices.get(device_key).and_then(|d| d.smartshift)
}
#[must_use]
pub fn fn_lock(&self, device_key: &str) -> Option<bool> {
self.devices.get(device_key).and_then(|d| d.fn_lock)
}
pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
self.devices
.entry(device_key.to_string())
.or_default()
.smartshift = Some(smartshift);
}
#[must_use]
pub fn invert_scroll(&self, device_key: &str) -> bool {
self.devices
.get(device_key)
.is_some_and(|d| d.invert_scroll)
}
pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
self.devices
.entry(device_key.to_string())
.or_default()
.invert_scroll = invert;
}
#[must_use]
pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
self.devices
.get(device_key)
.and_then(|device| device.scroll_resolution)
}
pub fn set_scroll_resolution(
&mut self,
device_key: &str,
resolution: Option<ScrollResolution>,
) {
self.devices
.entry(device_key.to_string())
.or_default()
.scroll_resolution = resolution;
}
#[must_use]
pub fn device_enabled(&self, device_key: &str) -> bool {
self.devices.get(device_key).is_none_or(|d| d.enabled)
}
pub fn set_device_enabled(&mut self, device_key: &str, enabled: bool) {
self.devices
.entry(device_key.to_string())
.or_default()
.enabled = enabled;
}
#[must_use]
pub fn thumbwheel_sensitivity(&self, device_key: &str) -> i32 {
self.devices
.get(device_key)
.and_then(|d| d.thumbwheel_sensitivity)
.unwrap_or(self.app_settings.thumbwheel_sensitivity)
}
pub fn set_device_thumbwheel_sensitivity(
&mut self,
device_key: &str,
sensitivity: Option<i32>,
) {
self.devices
.entry(device_key.to_string())
.or_default()
.thumbwheel_sensitivity = sensitivity;
}
}
fn app_overlay<'a, T>(overlays: &'a BTreeMap<String, T>, app: &str) -> Option<&'a T> {
overlays.get(app).or_else(|| {
let executable_name = app.rsplit(['\\', '/']).next()?;
if executable_name.is_empty()
|| !Path::new(executable_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
{
return None;
}
overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase()))
})
}
fn backup_config_once(path: &Path) -> io::Result<()> {
let backed_up = BACKED_UP_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()));
let mut backed_up = backed_up.lock().unwrap_or_else(PoisonError::into_inner);
if backed_up.contains(path) {
return Ok(());
}
match fs::metadata(path) {
Ok(_) => backup_existing_config(path)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
backed_up.insert(path.to_path_buf());
Ok(())
}
fn backup_existing_config(path: &Path) -> io::Result<()> {
for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
let source = config_backup_path(path, generation)?;
match fs::read(&source) {
Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
}
fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
let Some(file_name) = path.file_name() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"config path has no file name",
));
};
let mut backup_name = OsString::from(file_name);
backup_name.push(format!(".backup.{generation}"));
Ok(path.with_file_name(backup_name))
}
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
#[cfg_attr(
not(unix),
expect(unused_mut, reason = "only the unix path mutates the options")
)]
let mut options = AtomicWriteFile::options();
#[cfg(unix)]
{
use atomic_write_file::unix::OpenOptionsExt as _;
use std::os::unix::fs::OpenOptionsExt as _;
options.preserve_mode(false).mode(0o600);
}
let mut file = options.open(path)?;
io::Write::write_all(&mut file, bytes)?;
file.commit()
}