Skip to main content

openlogi_core/config/
migrate.rs

1//! Schema migrations: the passes that rewrite a config parsed from an older
2//! schema into the current shape.
3//!
4//! [`SCHEMA_VERSION`](super::SCHEMA_VERSION) says what each version changed.
5//! The loader runs the passes a file's declared version calls for, oldest
6//! schema first.
7
8use std::collections::BTreeMap;
9
10#[cfg(feature = "fs")]
11use super::settings::GestureOwner;
12use super::{Config, identity};
13#[cfg(feature = "fs")]
14use crate::binding::{
15    Action, Binding, ButtonId, GestureDirection, default_binding, default_binding_for,
16    default_gesture_binding,
17};
18use crate::device_order::PhysicalDeviceKey;
19
20impl Config {
21    /// The single button the pre-v4 owner-locked runtime would have dispatched
22    /// gestures from, inferred from the binding shapes — the owner-lock-era
23    /// resolution rule, retained solely for
24    /// [`Self::migrate_owner_locked_gestures`]. `None` means gestures were off.
25    #[cfg(feature = "fs")]
26    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
27        // An OS-hook button left in gesture mode took the role over.
28        if let Some((id, _)) = bindings
29            .iter()
30            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
31        {
32            return Some(*id);
33        }
34        // A dedicated HID++ gesture button explicitly assigned non-gesture
35        // behavior means gestures were off.
36        if matches!(
37            bindings.get(&ButtonId::GestureButton),
38            Some(Binding::Single(_) | Binding::LongPress(_))
39        ) {
40            return None;
41        }
42        // Default: the dedicated HID++ gesture button owns the gesture role.
43        Some(ButtonId::GestureButton)
44    }
45
46    /// One-time load migration for owner-locked files (`schema_version <= 3`).
47    ///
48    /// Under the owner lock at most one button dispatched gestures; every other
49    /// gesture-capable button could keep a dormant direction map awaiting
50    /// re-selection, with [`DeviceConfig::gesture_owner`] recording the choice
51    /// (absent = infer). The shape-driven model has no dormant state — a stored
52    /// [`Binding::Gesture`] IS gesture mode — so this resolves the old owner
53    /// and rewrites the shapes to dispatch exactly what the old config did:
54    ///
55    /// - the owner keeps its gesture map. A HID++ owner whose stored binding
56    ///   is absent or `Single`-shaped gets the seeded default direction map
57    ///   materialized: the v3 runtime seeded at projection time and dispatched
58    ///   that map regardless of the stored shape, so leaving the shape
59    ///   non-gesture would silently lose gestures in the rewritten file. (An
60    ///   OS-hook owner is different — the v3 hook only dispatched a stored
61    ///   gesture map, so a `Single` owner stays single.)
62    /// - every other gesture-shaped binding is stashed into
63    ///   [`DeviceConfig::disabled_gestures`] — keeping the owner-lock model's
64    ///   restore-on-reselection promise — and demotes to a [`Binding::Single`]
65    ///   of its `Click`, the only part of a dormant map the old runtime
66    ///   dispatched;
67    /// - a non-owner dedicated gesture button with no stored binding is pinned
68    ///   with an explicit `Single` at its canonical default (absence would
69    ///   re-enter gesture mode under the gesture-shaped default), which the
70    ///   capture layer leaves native;
71    /// - the consumed `gesture_owner` never serializes again — the shape is
72    ///   the whole truth from here on.
73    ///
74    /// [`DeviceConfig::gesture_owner`]: super::DeviceConfig::gesture_owner
75    /// [`DeviceConfig::disabled_gestures`]: super::DeviceConfig::disabled_gestures
76    #[cfg(feature = "fs")]
77    pub(super) fn migrate_owner_locked_gestures(&mut self) {
78        for device in self.devices.values_mut() {
79            let owner = match device.gesture_owner.take() {
80                Some(GestureOwner::Off) => None,
81                Some(GestureOwner::Button(id)) => Some(id),
82                None => Self::infer_gesture_owner(&device.bindings),
83            };
84            for (id, binding) in &mut device.bindings {
85                if Some(*id) != owner {
86                    if let Binding::Gesture(map) = binding {
87                        device.disabled_gestures.insert(*id, map.clone());
88                    }
89                    binding.demote_to_single(default_binding(*id));
90                }
91            }
92            if let Some(owner) = owner
93                && owner.is_hidpp_gesture_source()
94            {
95                let seeded = || {
96                    Binding::Gesture(
97                        GestureDirection::ALL
98                            .iter()
99                            .copied()
100                            .map(|d| (d, default_gesture_binding(d)))
101                            .collect(),
102                    )
103                };
104                match device.bindings.get_mut(&owner) {
105                    // A stored non-gesture shape is replaced by the map v3
106                    // actually dispatched.
107                    Some(binding) if !binding.is_gesture() => *binding = seeded(),
108                    Some(_) => {}
109                    // An absent owner only needs materializing when its
110                    // canonical default is not gesture-shaped (the haptic
111                    // panel); an absent dedicated button already means
112                    // default gesture mode.
113                    None => {
114                        if !default_binding_for(owner).is_gesture() {
115                            device.bindings.insert(owner, seeded());
116                        }
117                    }
118                }
119            }
120            if owner != Some(ButtonId::GestureButton) {
121                device
122                    .bindings
123                    .entry(ButtonId::GestureButton)
124                    .or_insert_with(|| Binding::Single(default_binding(ButtonId::GestureButton)));
125            }
126        }
127    }
128
129    /// Rewrite v4 transport-scoped direct keys to identity keys.
130    ///
131    /// `direct:046d:c08d:unit:6be9d300` names one mouse *and the cable it was
132    /// plugged into*; `unit:6be9d300` names the mouse. The route it came from
133    /// is kept as a link so the index survives the rename. Receiver keys are
134    /// left alone — nothing on disk says which device is in a pairing slot, so
135    /// they are folded at runtime instead (see `adopt_route`).
136    ///
137    /// A `direct:` key can appear three ways: as a device's own map key, as
138    /// `selected_device`, or inside another device's `host_switch_targets` —
139    /// and the last of those can name a device with no `[devices.…]` table of
140    /// its own (nothing but the reference survives). The rename is computed
141    /// once over every occurrence so all three are rewritten consistently,
142    /// not just the ones that also own a device entry.
143    ///
144    /// Two entries can rename onto the same key — one mouse reached over both
145    /// USB and Bluetooth-direct has a v4 entry per route — so the second one
146    /// is folded in rather than inserted over the first. That is the one case
147    /// where this pass would otherwise not be lossless.
148    pub fn migrate_transport_scoped_keys(&mut self) {
149        // A v4 direct key is `direct:<vid>:<pid>:<identity-kind>:<identity>`.
150        // Splitting off the two leading id fields recovers the route to keep
151        // and the identity fragment that becomes the new key.
152        let parse_rename = |key: &str| -> Option<(String, String)> {
153            let rest = key.strip_prefix("direct:")?;
154            let mut parts = rest.splitn(3, ':');
155            let vendor = parts.next()?;
156            let product = parts.next()?;
157            let identity = parts.next()?;
158            PhysicalDeviceKey::parse(identity)?;
159            Some((identity.to_string(), format!("direct:{vendor}:{product}")))
160        };
161
162        let renames: BTreeMap<String, (String, String)> = self
163            .devices
164            .keys()
165            .cloned()
166            .chain(self.selected_device.iter().cloned())
167            .chain(
168                self.devices
169                    .values()
170                    .flat_map(|device| device.host_switch_targets.iter().cloned()),
171            )
172            .filter_map(|key| {
173                let renamed = parse_rename(&key)?;
174                Some((key, renamed))
175            })
176            .collect();
177
178        for (old, (new, route)) in &renames {
179            let Some(mut device) = self.devices.remove(old) else {
180                continue;
181            };
182            device.links.entry(route.clone()).or_default();
183            // One device reached on two direct routes — an MX Master 3S over
184            // USB and over Bluetooth-direct — has two v4 entries that rename
185            // to the same identity key. Inserting would drop whichever lost
186            // the `BTreeMap` ordering, bindings and all; folding is what
187            // makes this phase lossless, and it is the same merge adoption
188            // performs at runtime, so the second entry's disagreements land
189            // as overrides on the route they were set for.
190            match self.devices.get_mut(new) {
191                Some(existing) => identity::fold(existing, device, route),
192                None => {
193                    self.devices.insert(new.clone(), device);
194                }
195            }
196        }
197        if let Some(new) = self
198            .selected_device
199            .as_deref()
200            .and_then(|old| renames.get(old))
201            .map(|(new, _)| new.clone())
202        {
203            self.selected_device = Some(new);
204        }
205        for device in self.devices.values_mut() {
206            for target in &mut device.host_switch_targets {
207                if let Some((new, _)) = renames.get(target) {
208                    *target = new.clone();
209                }
210            }
211        }
212    }
213
214    /// Preserve the native behavior of explicit pre-v7 thumb-wheel defaults.
215    ///
216    /// Before v7 the built-in pair was forward/up → right and backward/down →
217    /// left. Because that pair equaled the defaults, an explicit copy in either
218    /// a device or per-app profile kept the wheel native. v7 swaps the defaults
219    /// after normalising device polarity; leaving an old pair in place would
220    /// now request a real reversal. Rewrite only profiles whose effective pair
221    /// was exactly the old default. Mixed/custom pairs already had literal
222    /// diverted semantics and must remain untouched.
223    #[cfg(feature = "fs")]
224    pub(super) fn migrate_thumbwheel_native_direction(&mut self) {
225        let old_up = Action::HorizontalScrollRight;
226        let old_down = Action::HorizontalScrollLeft;
227        let new_up = Action::HorizontalScrollLeft;
228        let new_down = Action::HorizontalScrollRight;
229        let is_old_default = |binding: Option<&Binding>, old: &Action| match binding {
230            None => true,
231            Some(Binding::Single(action)) => action == old,
232            Some(Binding::Gesture(_) | Binding::LongPress(_)) => false,
233        };
234
235        for device in self.devices.values_mut() {
236            let global_up_is_old =
237                is_old_default(device.bindings.get(&ButtonId::ThumbwheelScrollUp), &old_up);
238            let global_down_is_old = is_old_default(
239                device.bindings.get(&ButtonId::ThumbwheelScrollDown),
240                &old_down,
241            );
242
243            for overlay in device.per_app_bindings.values_mut() {
244                let overrides_direction = overlay.contains_key(&ButtonId::ThumbwheelScrollUp)
245                    || overlay.contains_key(&ButtonId::ThumbwheelScrollDown);
246                let effective_up_is_old = overlay
247                    .get(&ButtonId::ThumbwheelScrollUp)
248                    .map_or(global_up_is_old, |action| action == &old_up);
249                let effective_down_is_old = overlay
250                    .get(&ButtonId::ThumbwheelScrollDown)
251                    .map_or(global_down_is_old, |action| action == &old_down);
252                if overrides_direction && effective_up_is_old && effective_down_is_old {
253                    // Write the complete pair. This also handles a profile that
254                    // reached the old defaults by overriding one half of a
255                    // custom global pair and inheriting the other half.
256                    overlay.insert(ButtonId::ThumbwheelScrollUp, new_up.clone());
257                    overlay.insert(ButtonId::ThumbwheelScrollDown, new_down.clone());
258                }
259            }
260
261            if global_up_is_old && global_down_is_old {
262                if let Some(Binding::Single(action)) =
263                    device.bindings.get_mut(&ButtonId::ThumbwheelScrollUp)
264                {
265                    *action = new_up.clone();
266                }
267                if let Some(Binding::Single(action)) =
268                    device.bindings.get_mut(&ButtonId::ThumbwheelScrollDown)
269                {
270                    *action = new_down.clone();
271                }
272            }
273        }
274    }
275}