Skip to main content

openlogi_device/session/host_switch/
restore.rs

1//! Owned firmware restoration state for host-switch capture.
2
3use std::{
4    fmt,
5    sync::{Arc, Weak},
6};
7
8use hidpp::channel::HidppChannel;
9use thiserror::Error;
10
11use super::{ArmedControl, HostSwitchError, restore_host_controls};
12use crate::{
13    ChannelRegistry, DeviceIoGate, DeviceRoute, SharedChannel, reprog_controls::ReprogControlsV4,
14};
15
16/// How a host-switch session released its temporary firmware reporting state.
17#[must_use = "pending firmware restoration must be retained by the session manager"]
18pub enum HostSwitchSessionOutcome {
19    /// Every host control was restored before the session returned.
20    Restored {
21        /// Host requested by the keyboard, if the session ended on a key press.
22        requested_host: Option<u8>,
23    },
24    /// Restoration is incomplete and must precede any successor session.
25    RestorePending {
26        /// Host requested before teardown began, if any.
27        requested_host: Option<u8>,
28        /// Owned capability for retrying restoration on a current publication.
29        restore: PendingHostSwitchRestore,
30    },
31}
32
33impl HostSwitchSessionOutcome {
34    /// Split the transition intent from any retained firmware ownership.
35    #[must_use]
36    pub fn into_parts(self) -> (Option<u8>, Option<PendingHostSwitchRestore>) {
37        match self {
38            Self::Restored { requested_host } => (requested_host, None),
39            Self::RestorePending {
40                requested_host,
41                restore,
42            } => (requested_host, Some(restore)),
43        }
44    }
45}
46
47/// A host-switch setup failure plus any rollback state still owned by OpenLogi.
48#[derive(Debug, Error)]
49#[error("{error}")]
50pub struct HostSwitchSessionFailure {
51    #[source]
52    error: HostSwitchError,
53    pending_restore: Option<PendingHostSwitchRestore>,
54}
55
56impl HostSwitchSessionFailure {
57    pub(super) fn clean(error: HostSwitchError) -> Self {
58        Self {
59            error,
60            pending_restore: None,
61        }
62    }
63
64    pub(super) fn with_pending(
65        error: HostSwitchError,
66        pending_restore: PendingHostSwitchRestore,
67    ) -> Self {
68        Self {
69            error,
70            pending_restore: Some(pending_restore),
71        }
72    }
73
74    /// Split the setup error from firmware ownership the caller must retain.
75    #[must_use]
76    pub fn into_parts(self) -> (HostSwitchError, Option<PendingHostSwitchRestore>) {
77        (self.error, self.pending_restore)
78    }
79}
80
81impl From<HostSwitchError> for HostSwitchSessionFailure {
82    fn from(error: HostSwitchError) -> Self {
83        Self::clean(error)
84    }
85}
86
87/// Result of one bounded pending-restoration attempt.
88#[must_use = "a failed restoration returns ownership that must be retained"]
89pub enum HostSwitchRestoreOutcome {
90    /// Every host control was restored on a publication that remained current.
91    Restored,
92    /// Restoration remains incomplete.
93    RestorePending(PendingHostSwitchRestore),
94}
95
96#[derive(Clone, Copy)]
97enum RetiredChannelPolicy {
98    ReplacementOnly,
99    CurrentAllowed,
100}
101
102/// Opaque host-control restoration state that outlives its original channel.
103///
104/// The token retains the exact route, feature index, reporting mode, and
105/// original reporting bits. It deliberately holds only a weak reference to
106/// the retired channel; every retry resolves the exact-route winner from the
107/// current inventory publication.
108pub struct PendingHostSwitchRestore {
109    route: DeviceRoute,
110    retired_channel: Weak<HidppChannel>,
111    retired_policy: RetiredChannelPolicy,
112    feature_index: u8,
113    controls: Vec<ArmedControl>,
114}
115
116impl fmt::Debug for PendingHostSwitchRestore {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.debug_struct("PendingHostSwitchRestore")
119            .field("route", &self.route)
120            .field("reporting_count", &self.controls.len())
121            .finish_non_exhaustive()
122    }
123}
124
125impl PendingHostSwitchRestore {
126    pub(super) fn new(
127        retired: &SharedChannel,
128        feature_index: u8,
129        controls: Vec<ArmedControl>,
130    ) -> Option<Self> {
131        (!controls.is_empty()).then(|| Self {
132            route: retired.route().clone(),
133            retired_channel: Arc::downgrade(retired.channel()),
134            retired_policy: RetiredChannelPolicy::ReplacementOnly,
135            feature_index,
136            controls,
137        })
138    }
139
140    pub(super) fn allow_current_channel(mut self) -> Self {
141        self.retired_policy = RetiredChannelPolicy::CurrentAllowed;
142        self
143    }
144
145    /// Retry through the exact-route channel currently published by inventory.
146    ///
147    /// A successful write is accepted only if that same publication remains
148    /// current after all awaited writes. If it was replaced during the pass,
149    /// all original controls remain pending for the replacement.
150    pub async fn retry(self, registry: &ChannelRegistry) -> HostSwitchRestoreOutcome {
151        let Some(current) = registry.lookup(&self.route) else {
152            return HostSwitchRestoreOutcome::RestorePending(self);
153        };
154        if matches!(self.retired_policy, RetiredChannelPolicy::ReplacementOnly)
155            && self
156                .retired_channel
157                .upgrade()
158                .is_some_and(|retired| Arc::ptr_eq(current.channel(), &retired))
159        {
160            return HostSwitchRestoreOutcome::RestorePending(self);
161        }
162
163        let controls = ReprogControlsV4::new(
164            Arc::clone(current.channel()),
165            current.device_index(),
166            self.feature_index,
167        );
168        let restored = restore_host_controls(&controls, &self.controls).await;
169        if restored && registry.is_current(&current) {
170            HostSwitchRestoreOutcome::Restored
171        } else {
172            HostSwitchRestoreOutcome::RestorePending(self)
173        }
174    }
175}
176
177pub(super) async fn rollback_host_switch_start(
178    error: HostSwitchError,
179    pending: Option<PendingHostSwitchRestore>,
180    registry: &ChannelRegistry,
181    device_io: &DeviceIoGate,
182) -> HostSwitchSessionFailure {
183    let Some(pending) = pending else {
184        return HostSwitchSessionFailure::clean(error);
185    };
186    let pending = pending.allow_current_channel();
187    if !device_io.allows_io() {
188        return HostSwitchSessionFailure::with_pending(error, pending);
189    }
190    match pending.retry(registry).await {
191        HostSwitchRestoreOutcome::Restored => HostSwitchSessionFailure::clean(error),
192        HostSwitchRestoreOutcome::RestorePending(pending) => {
193            HostSwitchSessionFailure::with_pending(error, pending)
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::super::{HostSwitchStopReason, monitor_host_switch, run_host_switch_session};
201    use super::*;
202    use crate::backend::NodeId;
203    use crate::channel::scripted::{ScriptedRawHidChannel, feature_error, scripted_channel};
204    use crate::reprog_controls::{CidReporting, ControlId};
205    use crate::session::host_switch::{ArmedControl, ReportingMode};
206
207    fn capture_keyboard(request: &[u8], fail_second_control: bool) -> Option<Vec<u8>> {
208        let mut response = vec![0; 20];
209        response[0] = 0x11;
210        response[1..4].copy_from_slice(&request[1..4]);
211        match (request[2], request[3] >> 4) {
212            (0, 1) => response[4] = 4,
213            (0, 0) => response[4] = 0x22,
214            (0x22, 0) => response[4] = if fail_second_control { 2 } else { 1 },
215            (0x22, 1) => {
216                if request[4] == 1 {
217                    return Some(feature_error(request, 0x08));
218                }
219                // Host key 1, divertable. Unrelated task/group fields are zero.
220                response[4..9].copy_from_slice(&[0, 0xd1, 0, 0, 0x20]);
221            }
222            (0x22, 2) => {
223                response[4..6].copy_from_slice(&request[4..6]);
224                // Original raw XY, persistent diversion, and force raw XY set.
225                response[6] = 0x54;
226            }
227            (0x22, 3) => return Some(request.to_vec()),
228            _ => return None,
229        }
230        Some(response)
231    }
232
233    #[tokio::test]
234    async fn failed_session_and_partial_arm_release_old_channels_and_restore_on_reconnect() {
235        for partial_arm in [false, true] {
236            let route = DeviceRoute::Direct {
237                vendor_id: 0x046d,
238                product_id: 0xb35b,
239            };
240            let node = NodeId::from("host-keyboard".to_owned());
241            let registry = ChannelRegistry::default();
242            let (raw, writes) = ScriptedRawHidChannel::with_dynamic_responder(move |request| {
243                if request[2] == 0x22 && request[3] >> 4 == 3 && request[6] & 1 == 0 {
244                    Some(feature_error(request, 0x08))
245                } else {
246                    capture_keyboard(request, partial_arm)
247                }
248            });
249            let channel = scripted_channel(raw).await;
250            let retired = Arc::downgrade(&channel);
251            registry.replace_node(node.clone(), [route.clone()], channel);
252            let (stop, stopped) = tokio::sync::oneshot::channel();
253            stop.send(HostSwitchStopReason::Graceful).unwrap();
254            let (_signal, gate) = crate::device_io_channel();
255
256            let outcome = tokio::time::timeout(
257                std::time::Duration::from_secs(2),
258                run_host_switch_session(route.clone(), stopped, &registry, gate),
259            )
260            .await
261            .expect("failed restoration must return ownership instead of looping");
262            let pending = match outcome {
263                Ok(HostSwitchSessionOutcome::RestorePending {
264                    requested_host,
265                    restore,
266                }) => {
267                    assert!(!partial_arm);
268                    assert_eq!(requested_host, None);
269                    restore
270                }
271                Err(failure) => {
272                    assert!(partial_arm);
273                    failure
274                        .into_parts()
275                        .1
276                        .expect("partial arm rollback must retain firmware ownership")
277                }
278                Ok(HostSwitchSessionOutcome::Restored { .. }) => {
279                    panic!("failed writes cannot be clean")
280                }
281            };
282            let reporting: Vec<_> = writes
283                .written_reports()
284                .into_iter()
285                .filter(|request| request[2] == 0x22 && request[3] >> 4 == 3)
286                .collect();
287            assert_eq!(
288                reporting.len(),
289                3,
290                "one arm, then two failed bounded restores"
291            );
292            assert_eq!(&reporting[0][4..7], &[0, 0xd1, 0x23]);
293            assert_eq!(&reporting[1][4..7], &[0, 0xd1, 0x32]);
294
295            registry.remove_node(&node);
296            assert!(
297                retired.upgrade().is_none(),
298                "pending ownership must release the dead channel"
299            );
300            let pending = match pending.retry(&registry).await {
301                HostSwitchRestoreOutcome::RestorePending(pending) => pending,
302                HostSwitchRestoreOutcome::Restored => panic!("absent route cannot be restored"),
303            };
304            let (raw, fresh) =
305                ScriptedRawHidChannel::with_responder(|request| Some(request.to_vec()));
306            registry.replace_node(node, [route], scripted_channel(raw).await);
307            assert!(matches!(
308                pending.retry(&registry).await,
309                HostSwitchRestoreOutcome::Restored
310            ));
311            let reporting = fresh.written_reports();
312            assert_eq!(reporting.len(), 1);
313            assert_eq!(&reporting[0][4..10], &[0, 0xd1, 0x32, 0, 0, 0]);
314        }
315    }
316
317    #[tokio::test]
318    async fn retirement_before_listener_subscription_is_observed_without_another_event() {
319        let route = DeviceRoute::Direct {
320            vendor_id: 0x046d,
321            product_id: 0xb35b,
322        };
323        let registry = ChannelRegistry::default();
324        let (raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
325        let retired = SharedChannel::new(scripted_channel(raw).await, route);
326        let (_stop, stopped) = tokio::sync::oneshot::channel();
327        let (_presses, mut presses) = tokio::sync::mpsc::unbounded_channel();
328        let (_signal, gate) = crate::device_io_channel();
329        let result = tokio::time::timeout(
330            std::time::Duration::from_millis(100),
331            monitor_host_switch(stopped, &mut presses, &registry, &retired, gate),
332        )
333        .await
334        .expect("a retired publication needs no further notification");
335        assert_eq!(result, (None, false));
336    }
337
338    #[tokio::test]
339    async fn replaced_publication_during_successful_write_remains_pending() {
340        let route = DeviceRoute::Direct {
341            vendor_id: 0x046d,
342            product_id: 0xb35b,
343        };
344        let node = NodeId::from("keyboard-node".to_owned());
345        let registry = ChannelRegistry::default();
346        let (retired_raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
347        let retired = SharedChannel::new(scripted_channel(retired_raw).await, route.clone());
348        let control = ArmedControl {
349            cid: 0x00d3,
350            host: 0,
351            mode: ReportingMode::Diverted,
352            original: CidReporting {
353                cid: ControlId(0x00d3),
354                diverted: false,
355                persistently_diverted: true,
356                force_raw_xy: true,
357                raw_xy: false,
358                remap: Some(ControlId(0x1234)),
359                analytics_key_events: false,
360                raw_wheel: true,
361            },
362        };
363        let pending = PendingHostSwitchRestore::new(&retired, 0x22, vec![control])
364            .expect("one armed control must require restoration");
365        let (winner_raw, winner_handle) =
366            ScriptedRawHidChannel::with_responder(|request| Some(request.to_vec()));
367        let winner = scripted_channel(winner_raw).await;
368        let replacement_registry = registry.clone();
369        let replacement_node = node.clone();
370        let replacement_route = route.clone();
371        let (superseded_raw, superseded_handle) =
372            ScriptedRawHidChannel::with_dynamic_responder(move |request| {
373                replacement_registry.replace_node(
374                    replacement_node.clone(),
375                    [replacement_route.clone()],
376                    winner.clone(),
377                );
378                Some(request.to_vec())
379            });
380        registry.replace_node(node, [route], scripted_channel(superseded_raw).await);
381
382        let pending = match pending.retry(&registry).await {
383            HostSwitchRestoreOutcome::RestorePending(pending) => pending,
384            HostSwitchRestoreOutcome::Restored => panic!("superseded write counted as final"),
385        };
386        assert_eq!(superseded_handle.written_reports().len(), 1);
387        assert!(matches!(
388            pending.retry(&registry).await,
389            HostSwitchRestoreOutcome::Restored
390        ));
391        assert_eq!(winner_handle.written_reports().len(), 1);
392    }
393}