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}