Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ tokio = { version = "1", features = ["macros", "rt", "sync", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }

[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.6.2"
corebluetooth-rs = "=0.3.6"
core-graphics = { version = "0.25", features = ["highsierra"] }
libc = "0.2"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSBitmapImageRep", "NSColor", "NSControl", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSImageView", "NSPanel", "NSResponder", "NSScreen", "NSView", "NSWindow", "NSWorkspace", "objc2-core-foundation"] }
objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["CGEventSource", "CGEventTypes"] }
objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSObject", "NSString", "NSThread", "NSURL"] }
objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] }

[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.62.2", features = [
Expand All @@ -55,6 +56,7 @@ windows = { version = "0.62.2", features = [
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_LibraryLoader",
"Win32_System_Power",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_System_WinRT",
Expand Down
176 changes: 176 additions & 0 deletions src-tauri/src/ble_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
use std::time::{Duration, Instant};

pub const RECOVERY_DELAYS: [Duration; 6] = [
Duration::ZERO,
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(15),
];

const DUPLICATE_RESUME_WINDOW: Duration = Duration::from_secs(2);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Active,
Suspended,
Recovering,
Terminal,
}

#[derive(Debug)]
pub struct RecoveryCoordinator {
generation: u64,
phase: Phase,
last_resume: Option<Instant>,
}

impl Default for RecoveryCoordinator {
fn default() -> Self {
Self {
generation: 0,
phase: Phase::Terminal,
last_resume: None,
}
}
}

impl RecoveryCoordinator {
pub fn begin_initial(&mut self) -> u64 {
self.begin_recovery()
}

pub fn suspend(&mut self) -> u64 {
self.generation = self.generation.wrapping_add(1).max(1);
self.phase = Phase::Suspended;
self.generation
}

pub fn resume(&mut self, now: Instant) -> Option<u64> {
if self.phase == Phase::Recovering {
return None;
}
if self.phase != Phase::Suspended
&& self
.last_resume
.is_some_and(|last| now.saturating_duration_since(last) < DUPLICATE_RESUME_WINDOW)
{
return None;
}
self.last_resume = Some(now);
Some(self.begin_recovery())
}

pub fn recover_from_terminal(&mut self) -> Option<u64> {
(self.phase == Phase::Terminal).then(|| self.begin_recovery())
}

pub fn interrupt_terminal(&mut self) -> u64 {
self.generation = self.generation.wrapping_add(1).max(1);
self.phase = Phase::Terminal;
self.generation
}

pub fn mark_active(&mut self, generation: u64) -> bool {
if !self.is_current(generation) {
return false;
}
self.phase = Phase::Active;
true
}

pub fn mark_terminal(&mut self, generation: u64) -> bool {
if !self.is_current(generation) {
return false;
}
self.phase = Phase::Terminal;
true
}

pub fn is_current(&self, generation: u64) -> bool {
self.generation == generation && matches!(self.phase, Phase::Recovering | Phase::Active)
}

#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub fn is_active(&self, generation: u64) -> bool {
self.generation == generation && self.phase == Phase::Active
}

#[cfg_attr(target_os = "windows", allow(dead_code))]
pub fn should_retry(&self, generation: u64) -> bool {
self.generation == generation && self.phase == Phase::Recovering
}

#[cfg_attr(target_os = "windows", allow(dead_code))]
pub fn current_generation(&self) -> u64 {
self.generation
}

fn begin_recovery(&mut self) -> u64 {
self.generation = self.generation.wrapping_add(1).max(1);
self.phase = Phase::Recovering;
self.generation
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn duplicate_resume_events_are_coalesced() {
let now = Instant::now();
let mut coordinator = RecoveryCoordinator::default();
coordinator.suspend();
let generation = coordinator.resume(now).unwrap();
assert_eq!(coordinator.resume(now + Duration::from_millis(100)), None);
assert!(coordinator.mark_active(generation));
assert_eq!(coordinator.resume(now + Duration::from_secs(1)), None);

coordinator.suspend();
assert!(coordinator
.resume(now + Duration::from_millis(1_100))
.is_some());
}

#[test]
fn suspend_cancels_recovery_and_stale_generations() {
let mut coordinator = RecoveryCoordinator::default();
let generation = coordinator.begin_initial();
coordinator.suspend();
assert!(!coordinator.is_current(generation));
assert!(!coordinator.mark_active(generation));
}

#[test]
fn terminal_state_can_recover_after_a_radio_change() {
let mut coordinator = RecoveryCoordinator::default();
let first = coordinator.begin_initial();
assert!(coordinator.mark_terminal(first));
assert!(!coordinator.is_current(first));
let second = coordinator.recover_from_terminal().unwrap();
assert_ne!(first, second);
assert!(coordinator.is_current(second));
}

#[test]
fn only_the_current_active_generation_is_active() {
let mut coordinator = RecoveryCoordinator::default();
let first = coordinator.begin_initial();
assert!(!coordinator.is_active(first));
assert!(coordinator.mark_active(first));
assert!(coordinator.is_active(first));
let second = coordinator.interrupt_terminal();
assert!(!coordinator.is_active(first));
assert!(!coordinator.is_active(second));
}

#[test]
fn retry_schedule_is_bounded_to_thirty_seconds() {
assert_eq!(
RECOVERY_DELAYS,
[0, 1, 2, 4, 8, 15].map(Duration::from_secs)
);
}
}
12 changes: 12 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod ble_lifecycle;
mod diagnostics;
mod display_navigation;
mod dwell;
Expand All @@ -19,6 +20,8 @@ mod storage;
mod telemetry;
mod updater;
#[cfg(target_os = "windows")]
mod windows_power;
#[cfg(target_os = "windows")]
mod windows_runtime;
#[cfg(target_os = "windows")]
mod windows_security;
Expand Down Expand Up @@ -202,6 +205,7 @@ fn finish_app_exit(app: &AppHandle) {
app.state::<overlay::CursorOverlay>().end_session();
app.state::<modifier_overlay::ModifierOverlay>()
.end_session();
platform_shutdown(app, &model.shared);
app.exit(0);
}

Expand Down Expand Up @@ -1356,6 +1360,10 @@ fn platform_install(app: AppHandle, shared: state::SharedModel) -> Result<(), St
macos::install(app, shared)
}
#[cfg(target_os = "macos")]
fn platform_shutdown(app: &AppHandle, shared: &state::SharedModel) {
macos::shutdown(app, shared);
}
#[cfg(target_os = "macos")]
fn platform_check_accessibility(
app: &AppHandle,
shared: &state::SharedModel,
Expand Down Expand Up @@ -1389,6 +1397,10 @@ fn platform_install(app: AppHandle, shared: state::SharedModel) -> Result<(), St
windows_runtime::install(app, shared)
}
#[cfg(target_os = "windows")]
fn platform_shutdown(app: &AppHandle, shared: &state::SharedModel) {
windows_runtime::shutdown(app, shared);
}
#[cfg(target_os = "windows")]
fn platform_check_accessibility(
app: &AppHandle,
shared: &state::SharedModel,
Expand Down
Loading