diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 16df49b9..3653099e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3988,6 +3988,7 @@ name = "switchify-pc" version = "1.0.0-rc.1" dependencies = [ "base64 0.22.1", + "block2", "core-graphics", "corebluetooth-rs", "directories", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 347c9f49..51e2e8e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 = [ @@ -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", diff --git a/src-tauri/src/ble_lifecycle.rs b/src-tauri/src/ble_lifecycle.rs new file mode 100644 index 00000000..43eada01 --- /dev/null +++ b/src-tauri/src/ble_lifecycle.rs @@ -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, +} + +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 { + 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 { + (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) + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1667832b..b5c89cb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod ble_lifecycle; mod diagnostics; mod display_navigation; mod dwell; @@ -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; @@ -202,6 +205,7 @@ fn finish_app_exit(app: &AppHandle) { app.state::().end_session(); app.state::() .end_session(); + platform_shutdown(app, &model.shared); app.exit(0); } @@ -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, @@ -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, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 4e21daa4..482b7888 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1,14 +1,22 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; +use std::ptr::NonNull; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use block2::RcBlock; use corebluetooth::prelude::*; use enigo::{Enigo, Settings}; -use objc2_app_kit::NSWorkspace; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObjectProtocol, ProtocolObject}; +use objc2_app_kit::{ + NSWorkspace, NSWorkspaceDidWakeNotification, NSWorkspaceWillSleepNotification, +}; #[allow(deprecated)] -use objc2_foundation::{NSHost, NSString, NSURL}; +use objc2_foundation::{NSHost, NSNotification, NSNotificationCenter, NSString, NSURL}; use tauri::{AppHandle, Manager}; +use crate::ble_lifecycle::{RecoveryCoordinator, RECOVERY_DELAYS}; use crate::display_navigation::{self, NavigationError}; use crate::dwell::DwellController; use crate::input::{ @@ -119,7 +127,64 @@ thread_local! { } pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { + let lifecycle = Arc::new(Mutex::new(RecoveryCoordinator::default())); + lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .begin_initial(); let display_name = system_display_name(); + let manager_generation = 1; + let manager = create_peripheral_manager(&app, lifecycle.clone(), manager_generation)?; + let state = manager.state(); + RUNTIME.with(|slot| { + *slot.borrow_mut() = Some(MacRuntime { + app, + shared, + display_name, + manager, + manager_generation, + service: None, + rx_characteristic: None, + tx_characteristic: None, + status_value: Vec::new(), + subscribers: HashMap::new(), + pairing_centrals: PairingCentralRegistry::default(), + outbound: OutboundQueue::default(), + input: None, + repeats: MouseRepeatController::default(), + pending_repeat_moves: HashMap::new(), + lifecycle, + service_generation: None, + notification_center: None, + power_observers: Vec::new(), + }); + }); + with_runtime(|runtime| { + runtime.install_power_observers(); + runtime.refresh_accessibility(false)?; + runtime.handle_manager_state(state)?; + let generation = runtime + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .current_generation(); + if runtime + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .should_retry(generation) + { + runtime.schedule_recovery(generation); + } + Ok(()) + }) +} + +fn create_peripheral_manager( + app: &AppHandle, + lifecycle: Arc>, + manager_generation: u64, +) -> Result { let state_app = app.clone(); let service_app = app.clone(); let advertising_app = app.clone(); @@ -128,75 +193,93 @@ pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { let ready_app = app.clone(); let read_app = app.clone(); let write_app = app.clone(); + let write_lifecycle = lifecycle; let callbacks = PeripheralManagerCallbacks::new() .on_state(move |state, _authorization| { dispatch_to_main(&state_app, move |runtime| { - runtime.handle_manager_state(state) + if runtime.manager_generation == manager_generation { + runtime.handle_manager_state(state) + } else { + Ok(()) + } }); }) - .on_add_service(move |_service, error| { + .on_add_service(move |service, error| { + let service = MainThreadBluetoothValue(service); dispatch_to_main(&service_app, move |runtime| { - runtime.service_was_added(error.is_none()) + let service = service.into_inner(); + if runtime.manager_generation == manager_generation { + runtime.service_was_added(&service, error.is_none()) + } else { + Ok(()) + } }); }) .on_start_advertising(move |error| { dispatch_to_main(&advertising_app, move |runtime| { - runtime.advertising_did_start(error.is_none()) + if runtime.manager_generation == manager_generation { + runtime.advertising_did_start(error.is_none()) + } else { + Ok(()) + } }); }) .on_subscribe(move |central, characteristic| { let values = MainThreadBluetoothValue((central, characteristic)); dispatch_to_main(&subscribe_app, move |runtime| { let (central, characteristic) = values.into_inner(); - runtime.central_subscribed(central, characteristic) + if runtime.manager_generation == manager_generation { + runtime.central_subscribed(central, characteristic) + } else { + Ok(()) + } }); }) .on_unsubscribe(move |central, characteristic| { let values = MainThreadBluetoothValue((central, characteristic)); dispatch_to_main(&unsubscribe_app, move |runtime| { let (central, characteristic) = values.into_inner(); - runtime.central_unsubscribed(central, characteristic) + if runtime.manager_generation == manager_generation { + runtime.central_unsubscribed(central, characteristic) + } else { + Ok(()) + } }); }) .on_ready_to_update(move || { - dispatch_to_main(&ready_app, MacRuntime::flush_outbound); + dispatch_to_main(&ready_app, move |runtime| { + if runtime.manager_generation == manager_generation { + runtime.flush_outbound() + } else { + Ok(()) + } + }); }) .on_read_request(move |request| { let request = MainThreadBluetoothValue(request); dispatch_to_main(&read_app, move |runtime| { - runtime.handle_read(request.into_inner()) + if runtime.manager_generation == manager_generation { + runtime.handle_read(request.into_inner()) + } else { + Ok(()) + } }); }) .on_write_requests(move |requests| { + let generation = write_lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .current_generation(); let requests = MainThreadBluetoothValue(requests); dispatch_to_main(&write_app, move |runtime| { - runtime.handle_writes(requests.into_inner()) + if runtime.manager_generation == manager_generation { + runtime.handle_writes(requests.into_inner(), generation) + } else { + Ok(()) + } }); }); - let manager = - PeripheralManager::with_callbacks(callbacks).map_err(|error| error.to_string())?; - let state = manager.state(); - RUNTIME.with(|slot| { - *slot.borrow_mut() = Some(MacRuntime { - app, - shared, - display_name, - manager, - service: None, - tx_characteristic: None, - status_value: Vec::new(), - subscribers: HashMap::new(), - pairing_centrals: PairingCentralRegistry::default(), - outbound: OutboundQueue::default(), - input: None, - repeats: MouseRepeatController::default(), - pending_repeat_moves: HashMap::new(), - }); - }); - with_runtime(|runtime| { - runtime.refresh_accessibility(false)?; - runtime.handle_manager_state(state) - }) + PeripheralManager::with_callbacks(callbacks).map_err(|error| error.to_string()) } fn dispatch_to_main( @@ -368,7 +451,9 @@ struct MacRuntime { shared: SharedModel, display_name: String, manager: PeripheralManager, + manager_generation: u64, service: Option, + rx_characteristic: Option, tx_characteristic: Option, status_value: Vec, subscribers: HashMap, @@ -377,6 +462,16 @@ struct MacRuntime { input: Option>, repeats: MouseRepeatController, pending_repeat_moves: HashMap, + lifecycle: Arc>, + service_generation: Option, + notification_center: Option>, + power_observers: Vec>>, +} + +pub fn shutdown(_app: &AppHandle, _shared: &SharedModel) { + RUNTIME.with(|slot| { + slot.borrow_mut().take(); + }); } #[derive(Debug, Clone, Copy, PartialEq)] @@ -421,19 +516,244 @@ impl PairingCentralRegistry { } impl MacRuntime { + fn install_power_observers(&mut self) { + let center = NSWorkspace::sharedWorkspace().notificationCenter(); + let sleep_app = self.app.clone(); + let sleep_block = RcBlock::new(move |_notification: NonNull| { + dispatch_to_main(&sleep_app, |runtime| runtime.handle_system_suspend()); + }); + let wake_app = self.app.clone(); + let wake_block = RcBlock::new(move |_notification: NonNull| { + dispatch_to_main(&wake_app, |runtime| runtime.handle_system_resume()); + }); + let sleep_observer = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(NSWorkspaceWillSleepNotification), + None, + None, + &sleep_block, + ) + }; + let wake_observer = unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(NSWorkspaceDidWakeNotification), + None, + None, + &wake_block, + ) + }; + self.notification_center = Some(center); + self.power_observers = vec![sleep_observer, wake_observer]; + } + + fn handle_system_suspend(&mut self) -> Result<(), String> { + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .suspend(); + self.reset_gatt(); + self.set_bluetooth(BluetoothState::Initializing); + set_activity( + &self.shared, + ActivityKind::Info, + "Bluetooth paused while the Mac sleeps.", + ); + emit_state(&self.app, &self.shared); + Ok(()) + } + + fn handle_system_resume(&mut self) -> Result<(), String> { + let generation = self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .resume(Instant::now()); + let Some(generation) = generation else { + return Ok(()); + }; + self.reset_gatt(); + self.set_bluetooth(BluetoothState::Initializing); + emit_state(&self.app, &self.shared); + self.schedule_recovery(generation); + Ok(()) + } + + fn schedule_recovery(&self, generation: u64) { + let app = self.app.clone(); + tauri::async_runtime::spawn(async move { + let started_at = tokio::time::Instant::now(); + for delay in RECOVERY_DELAYS { + if !delay.is_zero() { + tokio::time::sleep_until(started_at + delay).await; + } + let callback_app = app.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + let result = with_runtime(|runtime| runtime.recovery_attempt(generation)); + let _ = sender.send(result); + }) + .is_err() + { + return; + } + match receiver.await { + Ok(Ok(true)) => {} + Ok(Ok(false)) | Err(_) => return, + Ok(Err(error)) => { + let _ = callback_app.run_on_main_thread(move || { + let _ = + with_runtime(|runtime| runtime.recovery_failed(generation, error)); + }); + return; + } + } + } + // Give the final fresh CoreBluetooth request one event-loop turn to report + // success before declaring the bounded recovery sequence exhausted. + tokio::time::sleep(Duration::from_secs(1)).await; + let (sender, receiver) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + let result = with_runtime(|runtime| runtime.recovery_timed_out(generation)); + let _ = sender.send(result); + }) + .is_ok() + { + let _ = receiver.await; + } + }); + } + + fn recovery_attempt(&mut self, generation: u64) -> Result { + if !self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .should_retry(generation) + { + return Ok(false); + } + let state = self.rebuild_peripheral_manager()?; + match state { + PeripheralManagerState::PoweredOn => { + self.configure_service()?; + Ok(true) + } + PeripheralManagerState::PoweredOff => { + self.recovery_terminal(generation, BluetoothState::PoweredOff); + Ok(false) + } + PeripheralManagerState::Unauthorized => { + self.recovery_terminal(generation, BluetoothState::Unauthorized); + Ok(false) + } + PeripheralManagerState::Unsupported => { + self.recovery_terminal(generation, BluetoothState::Unsupported); + Ok(false) + } + PeripheralManagerState::Unknown | PeripheralManagerState::Resetting => Ok(true), + } + } + + fn rebuild_peripheral_manager(&mut self) -> Result { + self.reset_gatt(); + let generation = self.manager_generation.wrapping_add(1).max(1); + let manager = create_peripheral_manager(&self.app, self.lifecycle.clone(), generation)?; + let state = manager.state(); + self.manager = manager; + self.manager_generation = generation; + Ok(state) + } + + fn recovery_timed_out(&mut self, generation: u64) -> Result { + if !self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .should_retry(generation) + { + return Ok(false); + } + self.reset_gatt(); + self.recovery_failed( + generation, + "the Bluetooth stack did not become ready within 30 seconds".to_string(), + )?; + Ok(false) + } + + fn recovery_failed(&mut self, generation: u64, error: String) -> Result<(), String> { + if self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .mark_terminal(generation) + { + self.set_bluetooth(BluetoothState::Error); + set_activity( + &self.shared, + ActivityKind::Error, + format!("Bluetooth could not recover after resume: {error}"), + ); + emit_state(&self.app, &self.shared); + } + Ok(()) + } + + fn recovery_terminal(&self, generation: u64, state: BluetoothState) { + if self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .mark_terminal(generation) + { + self.set_bluetooth(state); + emit_state(&self.app, &self.shared); + } + } + fn handle_manager_state(&mut self, state: PeripheralManagerState) -> Result<(), String> { + if manager_state_invalidates_gatt(state) { + self.reset_gatt(); + } match state { - PeripheralManagerState::PoweredOn => self.configure_service()?, + PeripheralManagerState::PoweredOn => { + let (generation, recovering) = { + let mut lifecycle = self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let generation = lifecycle.recover_from_terminal(); + let current = lifecycle.current_generation(); + (generation, lifecycle.should_retry(current)) + }; + if let Some(generation) = generation { + self.reset_gatt(); + self.schedule_recovery(generation); + } else if recovering && self.service.is_none() { + self.configure_service()?; + } + } PeripheralManagerState::PoweredOff => { - self.reset_gatt(); + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .interrupt_terminal(); self.set_bluetooth(BluetoothState::PoweredOff); } PeripheralManagerState::Unauthorized => { - self.reset_gatt(); + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .interrupt_terminal(); self.set_bluetooth(BluetoothState::Unauthorized); } PeripheralManagerState::Unsupported => { - self.reset_gatt(); + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .interrupt_terminal(); self.set_bluetooth(BluetoothState::Unsupported); self.shared .lock() @@ -447,6 +767,10 @@ impl MacRuntime { ); } PeripheralManagerState::Unknown | PeripheralManagerState::Resetting => { + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .interrupt_terminal(); self.set_bluetooth(BluetoothState::Initializing); } } @@ -459,6 +783,12 @@ impl MacRuntime { return Ok(()); } self.set_bluetooth(BluetoothState::Initializing); + self.service_generation = Some( + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .current_generation(), + ); let service_uuid = BluetoothUuid::from_string(SERVICE_UUID).map_err(|error| error.to_string())?; let rx_uuid = BluetoothUuid::from_string(RX_UUID).map_err(|error| error.to_string())?; @@ -512,14 +842,23 @@ impl MacRuntime { self.manager .add_service(&service) .map_err(|error| error.to_string())?; + self.rx_characteristic = Some(rx); self.tx_characteristic = Some(tx); self.service = Some(service); emit_state(&self.app, &self.shared); Ok(()) } - fn service_was_added(&mut self, succeeded: bool) -> Result<(), String> { + fn service_was_added(&mut self, service: &Service, succeeded: bool) -> Result<(), String> { + if !self + .service + .as_ref() + .is_some_and(|current| current.is_same_service(service)) + { + return Ok(()); + } if !succeeded { + self.reset_gatt(); self.set_bluetooth(BluetoothState::Error); set_activity( &self.shared, @@ -536,11 +875,27 @@ impl MacRuntime { .with_service_uuid(service_uuid); self.manager .start_advertising(&advertisement) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + Ok(()) } fn advertising_did_start(&mut self, succeeded: bool) -> Result<(), String> { + let Some(generation) = self.service_generation else { + return Ok(()); + }; + if !self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_current(generation) + { + return Ok(()); + } if succeeded { + self.lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .mark_active(generation); self.set_bluetooth(BluetoothState::Advertising); set_activity( &self.shared, @@ -548,6 +903,7 @@ impl MacRuntime { "Advertising to nearby Switchify Android devices.", ); } else { + self.reset_gatt(); self.set_bluetooth(BluetoothState::Error); set_activity( &self.shared, @@ -645,11 +1001,23 @@ impl MacRuntime { .map_err(|error| error.to_string()) } - fn handle_writes(&mut self, requests: Vec) -> Result<(), String> { + fn handle_writes(&mut self, requests: Vec, generation: u64) -> Result<(), String> { for request in requests { - let uuid = request.characteristic().uuid(); + let characteristic = request.characteristic(); + let uuid = characteristic.uuid(); let central_id = request.central().identifier(); - let result = if !uuid.eq_ignore_ascii_case(RX_UUID) { + let is_current = self + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_current(generation) + && self + .rx_characteristic + .as_ref() + .is_some_and(|current| current.is_same_characteristic(&characteristic)); + let result = if !is_current { + AttError::UnlikelyError + } else if !uuid.eq_ignore_ascii_case(RX_UUID) { AttError::WriteNotPermitted } else if request.offset() != 0 { AttError::InvalidOffset @@ -1537,7 +1905,10 @@ impl MacRuntime { self.app.state::().cancel(&self.app); self.stop_all_repeats(); self.manager.stop_advertising(); + self.manager.remove_all_services(); self.service = None; + self.service_generation = None; + self.rx_characteristic = None; self.tx_characteristic = None; self.subscribers.clear(); self.pairing_centrals.clear(); @@ -1547,7 +1918,7 @@ impl MacRuntime { .shared .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - model.engine.cancel_all_pairings(); + model.engine.reset_transport_session(); model.state.pending_pairings.clear(); } if let Some(input) = self.input.as_mut() { @@ -1559,6 +1930,17 @@ impl MacRuntime { } } +fn manager_state_invalidates_gatt(state: PeripheralManagerState) -> bool { + matches!( + state, + PeripheralManagerState::PoweredOff + | PeripheralManagerState::Unauthorized + | PeripheralManagerState::Unsupported + | PeripheralManagerState::Unknown + | PeripheralManagerState::Resetting + ) +} + fn pointer_profile_for_display( name: &str, scale_factor: f64, @@ -1654,6 +2036,13 @@ fn expire_pairing(app: &AppHandle, shared: &SharedModel, request_id: &str) -> Re impl Drop for MacRuntime { fn drop(&mut self) { + if let Some(center) = self.notification_center.as_ref() { + for observer in self.power_observers.drain(..) { + let observer: &ProtocolObject = &observer; + let observer: &AnyObject = AsRef::::as_ref(observer); + unsafe { center.removeObserver(observer) }; + } + } self.app.state::().cancel(&self.app); if let Some(input) = self.input.as_mut() { let _ = input.release_all(); @@ -1669,6 +2058,19 @@ mod tests { use super::*; use std::sync::Mutex; + #[test] + fn resetting_and_unknown_manager_states_invalidate_cached_gatt_objects() { + assert!(manager_state_invalidates_gatt( + PeripheralManagerState::Resetting + )); + assert!(manager_state_invalidates_gatt( + PeripheralManagerState::Unknown + )); + assert!(!manager_state_invalidates_gatt( + PeripheralManagerState::PoweredOn + )); + } + #[test] fn mac_typing_route_orders_cleanup_before_successful_overlay_hiding() { let events = Mutex::new(Vec::new()); diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 94555560..a633813b 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -460,6 +460,11 @@ impl ProtocolEngine { .count() } + pub fn reset_transport_session(&mut self) -> usize { + self.reassembler = FrameReassembler::default(); + self.cancel_all_pairings() + } + pub fn set_paired_token(&mut self, device_id: String, token: String) { self.tokens.insert(device_id, token); } @@ -1991,6 +1996,39 @@ mod tests { assert!(engine.pending_pairings().is_empty()); } + #[test] + fn transport_reset_clears_partial_frames_and_pairings_but_preserves_security_state() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine.set_paired_token("android-1".into(), "token-1".into()); + let partial = BluetoothFrame { + version: PROTOCOL_VERSION, + message_id: "partial".into(), + sequence: 0, + is_final: false, + total_bytes: 2, + payload_base64: general_purpose::STANDARD.encode(b"a"), + }; + assert_eq!( + engine.receive_frame(&serde_json::to_vec(&partial).unwrap(), NOW), + Ok(None) + ); + engine + .process_message( + &pairing_request("pair-1", "android-2", "Phone", "nonce-1").to_string(), + NOW, + ) + .unwrap(); + engine + .replay_cache + .insert("android-1:request-1".into(), NOW); + + assert_eq!(engine.reset_transport_session(), 1); + assert!(engine.pending_pairings().is_empty()); + assert_eq!(engine.token_for("android-1"), Some("token-1")); + assert_eq!(engine.replay_cache.get("android-1:request-1"), Some(&NOW)); + assert!(engine.reassembler.partials.is_empty()); + } + #[test] fn newer_request_from_same_device_replaces_only_that_device() { let mut engine = ProtocolEngine::new("desktop-1".into()); diff --git a/src-tauri/src/windows_power.rs b/src-tauri/src/windows_power.rs new file mode 100644 index 00000000..6271f761 --- /dev/null +++ b/src-tauri/src/windows_power.rs @@ -0,0 +1,208 @@ +use std::ffi::c_void; +use std::sync::mpsc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use tokio::sync::mpsc::UnboundedSender; +use windows::core::w; +use windows::Win32::Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Power::{ + RegisterSuspendResumeNotification, UnregisterSuspendResumeNotification, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, + GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW, + TranslateMessage, CREATESTRUCTW, DEVICE_NOTIFY_WINDOW_HANDLE, GWLP_USERDATA, HWND_MESSAGE, MSG, + PBT_APMRESUMEAUTOMATIC, PBT_APMRESUMECRITICAL, PBT_APMRESUMESTANDBY, PBT_APMRESUMESUSPEND, + PBT_APMSUSPEND, WINDOW_EX_STYLE, WINDOW_STYLE, WM_CLOSE, WM_CREATE, WM_DESTROY, WM_NCCREATE, + WM_NCDESTROY, WM_POWERBROADCAST, WNDCLASSW, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PowerSignal { + Suspend, + Resume, +} + +pub struct PowerMonitor { + window: isize, + thread: Option>, +} + +impl PowerMonitor { + pub fn spawn(sender: UnboundedSender) -> Result { + let (ready_sender, ready_receiver) = mpsc::sync_channel(1); + let thread = thread::Builder::new() + .name("switchify-power-events".into()) + .spawn(move || run_message_window(sender, ready_sender)) + .map_err(|error| error.to_string())?; + let window = ready_receiver + .recv_timeout(Duration::from_secs(2)) + .map_err(|_| "The Windows power monitor did not start.".to_string())??; + Ok(Self { + window, + thread: Some(thread), + }) + } +} + +impl Drop for PowerMonitor { + fn drop(&mut self) { + if self.window != 0 { + let window = HWND(self.window as *mut c_void); + unsafe { + let _ = PostMessageW(Some(window), WM_CLOSE, WPARAM(0), LPARAM(0)); + } + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn run_message_window( + sender: UnboundedSender, + ready: mpsc::SyncSender>, +) { + let result = unsafe { + let instance = match GetModuleHandleW(None) { + Ok(instance) => instance, + Err(error) => { + let _ = ready.send(Err(error.to_string())); + return; + } + }; + let class = w!("SwitchifyPowerMonitorWindow"); + let registration = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: instance.into(), + lpszClassName: class, + ..Default::default() + }; + let _ = RegisterClassW(®istration); + let context = Box::into_raw(Box::new(sender)); + match CreateWindowExW( + WINDOW_EX_STYLE::default(), + class, + w!(""), + WINDOW_STYLE::default(), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + Some(instance.into()), + Some(context.cast()), + ) { + Ok(window) => Ok((window, context)), + Err(error) => Err(error.to_string()), + } + }; + let (window, _) = match result { + Ok(value) => value, + Err(error) => { + let _ = ready.send(Err(error)); + return; + } + }; + let notification = + unsafe { RegisterSuspendResumeNotification(HANDLE(window.0), DEVICE_NOTIFY_WINDOW_HANDLE) }; + let notification = match notification { + Ok(notification) => notification, + Err(error) => { + unsafe { + let _ = DestroyWindow(window); + } + let _ = ready.send(Err(error.to_string())); + return; + } + }; + let _ = ready.send(Ok(window.0 as isize)); + unsafe { + let mut message = MSG::default(); + while GetMessageW(&mut message, None, 0, 0).as_bool() { + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + let _ = UnregisterSuspendResumeNotification(notification); + } +} + +unsafe extern "system" fn window_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + unsafe { + if message == WM_NCCREATE { + let create = &*(lparam.0 as *const CREATESTRUCTW); + SetWindowLongPtrW(window, GWLP_USERDATA, create.lpCreateParams as isize); + } + if message == WM_POWERBROADCAST { + if let Some(signal) = map_power_message(wparam.0 as u32) { + let context = + GetWindowLongPtrW(window, GWLP_USERDATA) as *const UnboundedSender; + if let Some(sender) = context.as_ref() { + let _ = sender.send(signal); + } + } + return LRESULT(1); + } + if message == WM_CLOSE { + let _ = DestroyWindow(window); + return LRESULT(0); + } + if message == WM_DESTROY { + PostQuitMessage(0); + return LRESULT(0); + } + if message == WM_NCDESTROY { + let context = + GetWindowLongPtrW(window, GWLP_USERDATA) as *mut UnboundedSender; + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + if !context.is_null() { + drop(Box::from_raw(context)); + } + } + if message == WM_CREATE { + return LRESULT(0); + } + DefWindowProcW(window, message, wparam, lparam) + } +} + +fn map_power_message(value: u32) -> Option { + match value { + PBT_APMSUSPEND => Some(PowerSignal::Suspend), + PBT_APMRESUMEAUTOMATIC + | PBT_APMRESUMESUSPEND + | PBT_APMRESUMECRITICAL + | PBT_APMRESUMESTANDBY => Some(PowerSignal::Resume), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_suspend_and_all_resume_power_messages() { + assert_eq!( + map_power_message(PBT_APMSUSPEND), + Some(PowerSignal::Suspend) + ); + for message in [ + PBT_APMRESUMEAUTOMATIC, + PBT_APMRESUMESUSPEND, + PBT_APMRESUMECRITICAL, + PBT_APMRESUMESTANDBY, + ] { + assert_eq!(map_power_message(message), Some(PowerSignal::Resume)); + } + assert_eq!(map_power_message(0), None); + } +} diff --git a/src-tauri/src/windows_runtime.rs b/src-tauri/src/windows_runtime.rs index 91010404..770a6632 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -15,10 +15,12 @@ use windows::Devices::Bluetooth::GenericAttributeProfile::{ GattServiceProviderAdvertisementStatusChangedEventArgs, GattServiceProviderAdvertisingParameters, GattWriteOption, GattWriteRequestedEventArgs, }; +use windows::Devices::Radios::{Radio, RadioKind, RadioState}; use windows::Foundation::{Deferral, TypedEventHandler}; use windows::Security::Cryptography::CryptographicBuffer; use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED}; +use crate::ble_lifecycle::{RecoveryCoordinator, RECOVERY_DELAYS}; use crate::display_navigation::{self, NavigationError}; use crate::dwell::DwellController; use crate::input::{ @@ -37,6 +39,7 @@ use crate::state::{ emit_state, set_activity, AccessibilityState, ActivityKind, AppModel, BluetoothState, SharedModel, }; +use crate::windows_power::{PowerMonitor, PowerSignal}; const SERVICE_UUID: GUID = GUID::from_u128(0x7a78f7e8_1d6d_4d92_9ef0_1f89d3db21f4); const RX_UUID: GUID = GUID::from_u128(0x7a78f7e9_1d6d_4d92_9ef0_1f89d3db21f4); @@ -60,6 +63,7 @@ struct NotificationQueueState { maximum_encoded_bytes: usize, queued_frames: usize, messages: VecDeque, + shutdown: bool, } #[derive(Debug, Default)] @@ -121,6 +125,18 @@ impl NotificationDispatcher { Self::invalidate_locked(&mut state); } + fn shutdown(&self) { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Self::invalidate_locked(&mut state); + state.shutdown = true; + drop(state); + self.inner.ready.notify_waiters(); + } + fn invalidate_locked(state: &mut NotificationQueueState) { state.generation = state.generation.wrapping_add(1).max(1); state.subscribed = false; @@ -186,10 +202,10 @@ impl NotificationDispatcher { Ok(()) } - async fn next_message(&self) -> QueuedNotificationMessage { + async fn next_message(&self) -> Option { loop { let notified = self.inner.ready.notified(); - if let Some(message) = { + let (message, shutdown) = { let mut state = self .inner .state @@ -199,9 +215,13 @@ impl NotificationDispatcher { if let Some(message) = message.as_ref() { state.queued_frames -= message.frames.len(); } - message - } { - return message; + (message, state.shutdown) + }; + if let Some(message) = message { + return Some(message); + } + if shutdown { + return None; } notified.await; } @@ -271,8 +291,7 @@ async fn run_notification_worker( SendFuture: Future>, ReportError: FnMut(&str), { - loop { - let message = dispatcher.next_message().await; + while let Some(message) = dispatcher.next_message().await { if !dispatcher.is_current(message.generation) { continue; } @@ -331,7 +350,8 @@ async fn send_notification_frame( } struct WindowsRuntime { - _provider: GattServiceProvider, + generation: u64, + provider: GattServiceProvider, _rx: GattLocalCharacteristic, _tx: GattLocalCharacteristic, notifications: NotificationDispatcher, @@ -340,11 +360,86 @@ struct WindowsRuntime { repeats: MouseRepeatController, } +struct NotificationWorkerGuard(Option); + +impl NotificationWorkerGuard { + fn new(dispatcher: NotificationDispatcher) -> Self { + Self(Some(dispatcher)) + } + + fn disarm(&mut self) { + self.0 = None; + } +} + +impl Drop for NotificationWorkerGuard { + fn drop(&mut self) { + if let Some(dispatcher) = self.0.take() { + dispatcher.shutdown(); + } + } +} + +impl Drop for WindowsRuntime { + fn drop(&mut self) { + self.notifications.shutdown(); + let _ = self.provider.StopAdvertising(); + let _ = self.input.release_all(); + self.input.end_control_session(); + } +} + static RUNTIME: OnceLock>> = OnceLock::new(); fn runtime() -> &'static Mutex> { RUNTIME.get_or_init(|| Mutex::new(None)) } +struct RadioSubscription { + radio: Radio, + token: i64, +} + +impl Drop for RadioSubscription { + fn drop(&mut self) { + let _ = self.radio.RemoveStateChanged(self.token); + } +} + +struct WindowsPlatform { + lifecycle: Arc>, + _power_monitor: PowerMonitor, + radio: Option, +} + +static PLATFORM: OnceLock>> = OnceLock::new(); + +fn platform() -> &'static Mutex> { + PLATFORM.get_or_init(|| Mutex::new(None)) +} + +fn generation_is_current(lifecycle: &Arc>, generation: u64) -> bool { + lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_current(generation) +} + +fn runtime_generation_is_current(generation: u64) -> bool { + runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .is_some_and(|runtime| runtime.generation == generation) +} + +fn take_runtime() { + let old = runtime() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + drop(old); +} + fn tasklist_has_other_switchify_process(output: &str, current_pid: u32) -> bool { output.lines().any(|line| { let mut fields = line.split(','); @@ -386,22 +481,252 @@ pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { emit_state(&app, &shared); return Ok(()); } + let lifecycle = Arc::new(Mutex::new(RecoveryCoordinator::default())); + let generation = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .begin_initial(); + let (power_sender, mut power_receiver) = tokio::sync::mpsc::unbounded_channel(); + let power_monitor = PowerMonitor::spawn(power_sender)?; + *platform() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(WindowsPlatform { + lifecycle: lifecycle.clone(), + _power_monitor: power_monitor, + radio: None, + }); + + let power_app = app.clone(); + let power_shared = shared.clone(); + let power_lifecycle = lifecycle.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = start_gatt(app.clone(), shared.clone()).await { - shared + while let Some(signal) = power_receiver.recv().await { + handle_power_signal( + power_app.clone(), + power_shared.clone(), + power_lifecycle.clone(), + signal, + ); + } + }); + tauri::async_runtime::spawn(register_radio_monitor( + app.clone(), + shared.clone(), + lifecycle.clone(), + )); + spawn_recovery(app, shared, lifecycle, generation); + Ok(()) +} + +fn handle_power_signal( + app: AppHandle, + shared: SharedModel, + lifecycle: Arc>, + signal: PowerSignal, +) { + match signal { + PowerSignal::Suspend => { + lifecycle .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .state - .bluetooth = BluetoothState::Error; + .suspend(); + reset_transport(&app, &shared, BluetoothState::Initializing); set_activity( &shared, - ActivityKind::Error, - format!("Bluetooth could not start: {error}"), + ActivityKind::Info, + "Bluetooth paused while the PC sleeps.", ); emit_state(&app, &shared); } + PowerSignal::Resume => { + let generation = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .resume(Instant::now()); + if let Some(generation) = generation { + reset_transport(&app, &shared, BluetoothState::Initializing); + spawn_recovery(app, shared, lifecycle, generation); + } + } + } +} + +fn spawn_recovery( + app: AppHandle, + shared: SharedModel, + lifecycle: Arc>, + generation: u64, +) { + tauri::async_runtime::spawn(async move { + let recovery = async { + let mut last_error = "Bluetooth did not become ready.".to_string(); + let started_at = tokio::time::Instant::now(); + for delay in RECOVERY_DELAYS { + if !generation_is_current(&lifecycle, generation) { + return None; + } + if !delay.is_zero() { + tokio::time::sleep_until(started_at + delay).await; + } + if !generation_is_current(&lifecycle, generation) { + return None; + } + if let Some(RadioState::Off) = current_bluetooth_radio_state() { + lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .mark_terminal(generation); + set_bluetooth_state( + &app, + &shared, + BluetoothState::PoweredOff, + ActivityKind::Info, + "Bluetooth is off. Switch it on to resume advertising.", + ); + return None; + } + match start_gatt(app.clone(), shared.clone(), lifecycle.clone(), generation).await { + Ok(()) => return None, + Err(error) => { + last_error = error; + take_runtime(); + } + } + } + Some(last_error) + }; + let last_error = match tokio::time::timeout(Duration::from_secs(30), recovery).await { + Ok(Some(error)) => error, + Ok(None) => return, + Err(_) => { + if generation_is_current(&lifecycle, generation) { + take_runtime(); + } + "Bluetooth recovery exceeded the 30-second deadline.".to_string() + } + }; + if lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .mark_terminal(generation) + { + set_bluetooth_state( + &app, + &shared, + BluetoothState::Error, + ActivityKind::Error, + format!("Bluetooth could not recover after resume: {last_error}"), + ); + } }); - Ok(()) +} + +async fn register_radio_monitor( + app: AppHandle, + shared: SharedModel, + lifecycle: Arc>, +) { + let Ok(operation) = Radio::GetRadiosAsync() else { + return; + }; + let Ok(radios) = operation.await else { + return; + }; + let Ok(count) = radios.Size() else { + return; + }; + for index in 0..count { + let Ok(radio) = radios.GetAt(index) else { + continue; + }; + if radio.Kind().ok() != Some(RadioKind::Bluetooth) { + continue; + } + let callback_app = app.clone(); + let callback_shared = shared.clone(); + let callback_lifecycle = lifecycle.clone(); + let token = match radio.StateChanged(&TypedEventHandler::::new( + move |radio, _| { + if let Some(radio) = radio.as_ref() { + handle_radio_state( + callback_app.clone(), + callback_shared.clone(), + callback_lifecycle.clone(), + radio.State()?, + ); + } + Ok(()) + }, + )) { + Ok(token) => token, + Err(_) => return, + }; + let state = radio.State().unwrap_or(RadioState::Unknown); + if let Some(platform) = platform() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_mut() + { + platform.radio = Some(RadioSubscription { radio, token }); + } + handle_radio_state(app, shared, lifecycle, state); + return; + } +} + +fn handle_radio_state( + app: AppHandle, + shared: SharedModel, + lifecycle: Arc>, + state: RadioState, +) { + if state == RadioState::Off { + lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .interrupt_terminal(); + reset_transport(&app, &shared, BluetoothState::PoweredOff); + set_activity( + &shared, + ActivityKind::Info, + "Bluetooth is off. Switch it on to resume advertising.", + ); + emit_state(&app, &shared); + } else if state == RadioState::On { + let generation = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .recover_from_terminal(); + if let Some(generation) = generation { + reset_transport(&app, &shared, BluetoothState::Initializing); + spawn_recovery(app, shared, lifecycle, generation); + } + } +} + +fn current_bluetooth_radio_state() -> Option { + platform() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .and_then(|platform| platform.radio.as_ref()) + .and_then(|subscription| subscription.radio.State().ok()) +} + +fn set_bluetooth_state( + app: &AppHandle, + shared: &SharedModel, + bluetooth: BluetoothState, + kind: ActivityKind, + message: impl Into, +) { + shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .state + .bluetooth = bluetooth; + set_activity(shared, kind, message); + emit_state(app, shared); } async fn create_characteristic( @@ -438,7 +763,12 @@ async fn create_characteristic( result.Characteristic().map_err(|error| error.to_string()) } -async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { +async fn start_gatt( + app: AppHandle, + shared: SharedModel, + lifecycle: Arc>, + generation: u64, +) -> Result<(), String> { let result = GattServiceProvider::CreateAsync(SERVICE_UUID) .map_err(|error| error.to_string())? .await @@ -478,10 +808,12 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { .await?; let notifications = NotificationDispatcher::default(); + let mut worker_guard = NotificationWorkerGuard::new(notifications.clone()); let worker_notifications = notifications.clone(); let worker_tx = tx.clone(); let worker_app = app.clone(); let worker_shared = shared.clone(); + let worker_lifecycle = lifecycle.clone(); std::thread::Builder::new() .name("switchify-ble-notifications".into()) .spawn(move || { @@ -497,8 +829,12 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { async move { send_notification_frame(&tx, frame).await } }, move |message| { - set_activity(&worker_shared, ActivityKind::Error, message); - emit_state(&worker_app, &worker_shared); + if generation_is_current(&worker_lifecycle, generation) + && runtime_generation_is_current(generation) + { + set_activity(&worker_shared, ActivityKind::Error, message); + emit_state(&worker_app, &worker_shared); + } }, )), _ => { @@ -515,16 +851,25 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { let write_app = app.clone(); let write_shared = shared.clone(); + let write_lifecycle = lifecycle.clone(); rx.WriteRequested(&TypedEventHandler::new( move |_: Ref<'_, GattLocalCharacteristic>, args: Ref<'_, GattWriteRequestedEventArgs>| { + if !generation_is_current(&write_lifecycle, generation) + || !runtime_generation_is_current(generation) + { + return Ok(()); + } if let Some(args) = args.cloned() { let deferral = args.GetDeferral()?; let callback_app = write_app.clone(); let callback_shared = write_shared.clone(); + let callback_lifecycle = write_lifecycle.clone(); tauri::async_runtime::spawn(async move { if let Err(error) = handle_write( callback_app.clone(), callback_shared.clone(), + callback_lifecycle, + generation, args, deferral, ) @@ -547,8 +892,14 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { let subscribe_app = app.clone(); let subscribe_shared = shared.clone(); let subscribe_notifications = notifications.clone(); + let subscribe_lifecycle = lifecycle.clone(); tx.SubscribedClientsChanged( &TypedEventHandler::::new(move |sender, _| { + if !generation_is_current(&subscribe_lifecycle, generation) + || !runtime_generation_is_current(generation) + { + return Ok(()); + } let subscriber_count = sender.as_ref().and_then(|value| { refresh_notification_subscription(value, &subscribe_notifications, true).ok() }); @@ -606,10 +957,16 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { .map_err(|error| error.to_string())?; let read_shared = shared.clone(); + let read_lifecycle = lifecycle.clone(); status .ReadRequested(&TypedEventHandler::new( move |_: Ref<'_, GattLocalCharacteristic>, args: Ref<'_, GattReadRequestedEventArgs>| { + if !generation_is_current(&read_lifecycle, generation) + || !runtime_generation_is_current(generation) + { + return Ok(()); + } if let Some(args) = args.cloned() { let deferral = args.GetDeferral()?; let status_shared = read_shared.clone(); @@ -627,15 +984,67 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { let status_app = app.clone(); let status_shared = shared.clone(); + let status_lifecycle = lifecycle.clone(); + let (advertising_sender, advertising_receiver) = tokio::sync::oneshot::channel(); + let advertising_sender = Arc::new(Mutex::new(Some(advertising_sender))); + let callback_advertising_sender = advertising_sender.clone(); provider .AdvertisementStatusChanged(&TypedEventHandler::< GattServiceProvider, GattServiceProviderAdvertisementStatusChangedEventArgs, >::new(move |_, args| { + if !generation_is_current(&status_lifecycle, generation) + || !runtime_generation_is_current(generation) + { + return Ok(()); + } if let Some(args) = args.cloned() { let status = args.Status()?; let error = args.Error()?; update_advertisement_status(&status_app, &status_shared, status, error); + let (recovering, active) = { + let lifecycle = status_lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + lifecycle.should_retry(generation), + lifecycle.is_active(generation), + ) + }; + if recovering { + let startup_result = + if status == GattServiceProviderAdvertisementStatus::Started { + Some(Ok(())) + } else if advertisement_status_is_terminal_failure(status) { + Some(Err(format!( + "Bluetooth advertising failed to start ({status:?}, {error:?})." + ))) + } else { + None + }; + if let Some(startup_result) = startup_result { + if let Some(sender) = callback_advertising_sender + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + let _ = sender.send(startup_result); + } + } + } else if advertisement_status_needs_recovery(status) && active { + let recovery_app = status_app.clone(); + let recovery_shared = status_shared.clone(); + let recovery_lifecycle = status_lifecycle.clone(); + tauri::async_runtime::spawn(async move { + tokio::task::yield_now().await; + recover_after_advertising_stopped( + recovery_app, + recovery_shared, + recovery_lifecycle, + generation, + ); + }); + } } Ok(()) })) @@ -643,10 +1052,15 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { let input = Enigo::new(&Settings::default()) .map_err(|_| "Windows input injection could not initialize.".to_string())?; + if !generation_is_current(&lifecycle, generation) { + notifications.shutdown(); + return Err("Bluetooth recovery was superseded.".into()); + } *runtime() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(WindowsRuntime { - _provider: provider.clone(), + generation, + provider: provider.clone(), _rx: rx, _tx: tx, notifications, @@ -657,6 +1071,7 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { ), repeats: MouseRepeatController::default(), }); + worker_guard.disarm(); let advertising = GattServiceProviderAdvertisingParameters::new().map_err(|error| error.to_string())?; advertising @@ -672,9 +1087,78 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { .AdvertisementStatus() .map_err(|error| error.to_string())?; update_advertisement_status(&app, &shared, status, BluetoothError::Success); + if status == GattServiceProviderAdvertisementStatus::Started { + advertising_sender + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + } else if advertisement_status_is_terminal_failure(status) { + advertising_sender + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + return Err(format!( + "Bluetooth advertising did not start completely ({status:?})." + )); + } else { + tokio::time::timeout(Duration::from_secs(2), advertising_receiver) + .await + .map_err(|_| "Bluetooth advertising did not report readiness in time.".to_string())? + .map_err(|_| "Bluetooth advertising readiness was cancelled.".to_string())??; + } + let mut lifecycle = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let current_status = provider + .AdvertisementStatus() + .map_err(|error| error.to_string())?; + if current_status != GattServiceProviderAdvertisementStatus::Started { + return Err(format!( + "Bluetooth advertising did not remain active ({current_status:?})." + )); + } + if !lifecycle.mark_active(generation) { + return Err("Bluetooth recovery was superseded before advertising became active.".into()); + } Ok(()) } +fn advertisement_status_needs_recovery(status: GattServiceProviderAdvertisementStatus) -> bool { + status != GattServiceProviderAdvertisementStatus::Started +} + +fn advertisement_status_is_terminal_failure( + status: GattServiceProviderAdvertisementStatus, +) -> bool { + matches!( + status, + GattServiceProviderAdvertisementStatus::Aborted + | GattServiceProviderAdvertisementStatus::StartedWithoutAllAdvertisementData + ) +} + +fn recover_after_advertising_stopped( + app: AppHandle, + shared: SharedModel, + lifecycle: Arc>, + generation: u64, +) { + let next_generation = { + let mut lifecycle = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !lifecycle.is_active(generation) { + return; + } + lifecycle.interrupt_terminal(); + lifecycle.recover_from_terminal() + }; + if let Some(next_generation) = next_generation { + reset_transport(&app, &shared, BluetoothState::Initializing); + spawn_recovery(app, shared, lifecycle, next_generation); + } +} + fn should_cancel_pending_pairings(subscriber_count: Option) -> bool { subscriber_count == Some(0) } @@ -724,6 +1208,8 @@ fn update_advertisement_status( async fn handle_write( app: AppHandle, shared: SharedModel, + lifecycle: Arc>, + generation: u64, args: GattWriteRequestedEventArgs, deferral: Deferral, ) -> Result<(), String> { @@ -733,6 +1219,19 @@ async fn handle_write( .map_err(|error| error.to_string())? .await .map_err(|error| error.to_string())?; + let lifecycle_guard = lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !lifecycle_guard.is_current(generation) || !runtime_generation_is_current(generation) { + if request.Option().map_err(|error| error.to_string())? + == GattWriteOption::WriteWithResponse + { + request + .RespondWithProtocolError(0x0e) + .map_err(|error| error.to_string())?; + } + return Ok(()); + } let mut bytes = windows::core::Array::::new(); CryptographicBuffer::CopyToByteArray( &request.Value().map_err(|error| error.to_string())?, @@ -747,6 +1246,7 @@ async fn handle_write( if let Some(response) = process_frame(&app, &shared, &bytes)? { notify(response)?; } + drop(lifecycle_guard); Ok(()) } .await; @@ -1528,6 +2028,37 @@ pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), Strin Ok(()) } +fn reset_transport(app: &AppHandle, shared: &SharedModel, bluetooth: BluetoothState) { + app.state::().cancel(app); + stop_all_repeats(app); + take_runtime(); + app.state::().end_session(); + app.state::().end_session(); + let mut model = shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + model.engine.reset_transport_session(); + model.state.pending_pairings.clear(); + model.state.connected_device_name = None; + model.state.bluetooth = bluetooth; +} + +pub fn shutdown(app: &AppHandle, shared: &SharedModel) { + if let Some(mut platform) = platform() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + platform + .lifecycle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .suspend(); + platform.radio.take(); + } + reset_transport(app, shared, BluetoothState::Initializing); +} + fn release_input_session() { if let Some(runtime) = runtime() .lock() @@ -1542,10 +2073,11 @@ fn release_input_session() { #[cfg(test)] mod tests { use super::{ - pointer_profile_for_display, run_notification_worker, select_notification_bytes, - should_cancel_pending_pairings, tasklist_has_other_switchify_process, - validate_notification_statuses, NotificationDispatcher, MAX_QUEUED_NOTIFICATION_FRAMES, - NOTIFICATION_BYTES, NOTIFICATION_DELIVERY_ERROR, + advertisement_status_needs_recovery, pointer_profile_for_display, run_notification_worker, + select_notification_bytes, should_cancel_pending_pairings, + tasklist_has_other_switchify_process, validate_notification_statuses, + NotificationDispatcher, MAX_QUEUED_NOTIFICATION_FRAMES, NOTIFICATION_BYTES, + NOTIFICATION_DELIVERY_ERROR, }; use crate::display_navigation::Display; use crate::input::AndroidTypingRoute; @@ -1559,7 +2091,9 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::oneshot; - use windows::Devices::Bluetooth::GenericAttributeProfile::GattCommunicationStatus; + use windows::Devices::Bluetooth::GenericAttributeProfile::{ + GattCommunicationStatus, GattServiceProviderAdvertisementStatus, + }; async fn wait_for_frame_count(frames: &Arc>>>, expected: usize) { tokio::time::timeout(Duration::from_secs(2), async { @@ -1640,6 +2174,22 @@ mod tests { assert!(!should_cancel_pending_pairings(None)); } + #[test] + fn only_fully_started_advertising_is_terminally_successful() { + assert!(!advertisement_status_needs_recovery( + GattServiceProviderAdvertisementStatus::Started + )); + assert!(advertisement_status_needs_recovery( + GattServiceProviderAdvertisementStatus::Aborted + )); + assert!(advertisement_status_needs_recovery( + GattServiceProviderAdvertisementStatus::StartedWithoutAllAdvertisementData + )); + assert!(advertisement_status_needs_recovery( + GattServiceProviderAdvertisementStatus::Stopped + )); + } + #[test] fn notification_size_uses_the_smallest_current_subscriber_limit() { assert_eq!(select_notification_bytes([Some(514)]), Some(514)); @@ -1668,12 +2218,12 @@ mod tests { dispatcher.subscribers_changed(1, 514); dispatcher.enqueue(&message).unwrap(); - let wide = dispatcher.next_message().await.frames; + let wide = dispatcher.next_message().await.unwrap().frames; assert!(wide.iter().all(|frame| frame.len() <= 514)); dispatcher.subscribers_changed(1, 247); dispatcher.enqueue(&message).unwrap(); - let narrow = dispatcher.next_message().await.frames; + let narrow = dispatcher.next_message().await.unwrap().frames; assert!(narrow.iter().all(|frame| frame.len() <= 247)); assert!(narrow.len() > wide.len()); } @@ -2253,4 +2803,20 @@ mod tests { (24, 65, 140) ); } + + #[tokio::test] + async fn notification_worker_exits_when_dispatcher_shuts_down() { + let dispatcher = NotificationDispatcher::default(); + let worker_dispatcher = dispatcher.clone(); + let worker = tokio::spawn(run_notification_worker( + worker_dispatcher, + |_| async { Ok(()) }, + |_| {}, + )); + dispatcher.shutdown(); + tokio::time::timeout(Duration::from_secs(1), worker) + .await + .expect("worker should stop") + .expect("worker should not panic"); + } } diff --git a/vendor/corebluetooth-rs/src/mutable_characteristic.rs b/vendor/corebluetooth-rs/src/mutable_characteristic.rs index e8c8d3c0..3da085bd 100644 --- a/vendor/corebluetooth-rs/src/mutable_characteristic.rs +++ b/vendor/corebluetooth-rs/src/mutable_characteristic.rs @@ -60,6 +60,11 @@ impl MutableCharacteristic { self.as_characteristic().uuid_object() } + /// Returns whether both handles refer to the same CoreBluetooth characteristic object. + pub fn is_same_characteristic(&self, other: &Characteristic) -> bool { + self.raw == other.raw + } + /// Returns the `CBCharacteristicProperties` exposed by `CBMutableCharacteristic`. pub fn properties(&self) -> CharacteristicProperties { self.as_characteristic().properties() diff --git a/vendor/corebluetooth-rs/src/mutable_service.rs b/vendor/corebluetooth-rs/src/mutable_service.rs index fa41bafc..6c2b0d3e 100644 --- a/vendor/corebluetooth-rs/src/mutable_service.rs +++ b/vendor/corebluetooth-rs/src/mutable_service.rs @@ -50,6 +50,11 @@ impl MutableService { self.as_service().uuid_object() } + /// Returns whether both handles refer to the same CoreBluetooth service object. + pub fn is_same_service(&self, other: &Service) -> bool { + self.raw == other.raw + } + /// Returns whether `CBMutableService.isPrimary` is set. pub fn is_primary(&self) -> bool { self.as_service().is_primary()