diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index a6e3c32..a314d7d 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use corebluetooth::prelude::*; @@ -163,6 +163,7 @@ pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { tx_characteristic: None, status_value: Vec::new(), subscribers: HashMap::new(), + pairing_centrals: PairingCentralRegistry::default(), outbound: OutboundQueue::default(), input: None, repeats: MouseRepeatController::default(), @@ -248,6 +249,7 @@ pub fn approve_pairing( model.state.pending_pairings = model.engine.pending_pairings(); response }?; + runtime.pairing_centrals.remove(request_id); runtime.enqueue_message(&response)?; set_activity( shared, @@ -273,6 +275,7 @@ pub fn reject_pairing( model.state.pending_pairings = model.engine.pending_pairings(); response }?; + runtime.pairing_centrals.remove(request_id); runtime.enqueue_message(&response)?; set_activity(shared, ActivityKind::Info, "Pairing request rejected."); emit_state(app, shared); @@ -283,22 +286,27 @@ pub fn reject_pairing( pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { with_runtime(|runtime| { runtime.stop_all_repeats(); - if let Some(input) = runtime.input.as_mut() { + let release = if let Some(input) = runtime.input.as_mut() { let release = input.release_all(); input.end_control_session(); - release?; - } + release + } else { + Ok(()) + }; runtime.subscribers.clear(); + runtime.pairing_centrals.clear(); runtime.outbound.clear(); { let mut model = shared .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + model.engine.cancel_all_pairings(); + model.state.pending_pairings.clear(); model.state.bluetooth = BluetoothState::Advertising; model.state.connected_device_name = None; } emit_state(app, shared); - Ok(()) + release }) } @@ -329,11 +337,48 @@ struct MacRuntime { tx_characteristic: Option, status_value: Vec, subscribers: HashMap, + pairing_centrals: PairingCentralRegistry, outbound: OutboundQueue, input: Option>, repeats: MouseRepeatController, } +#[derive(Debug, Default)] +struct PairingCentralRegistry { + by_request: HashMap, +} + +impl PairingCentralRegistry { + fn associate(&mut self, request_id: String, central_id: String) { + self.by_request.insert(request_id, central_id); + } + + fn retain_pending(&mut self, pending_request_ids: &HashSet) { + self.by_request + .retain(|request_id, _| pending_request_ids.contains(request_id)); + } + + fn remove(&mut self, request_id: &str) { + self.by_request.remove(request_id); + } + + fn take_for_central(&mut self, central_id: &str) -> Vec { + let request_ids = self + .by_request + .iter() + .filter(|(_, associated_central)| associated_central.as_str() == central_id) + .map(|(request_id, _)| request_id.clone()) + .collect::>(); + self.by_request + .retain(|_, associated_central| associated_central != central_id); + request_ids + } + + fn clear(&mut self) { + self.by_request.clear(); + } +} + impl MacRuntime { fn handle_manager_state(&mut self, state: PeripheralManagerState) -> Result<(), String> { match state { @@ -502,7 +547,23 @@ impl MacRuntime { characteristic: Characteristic, ) -> Result<(), String> { if characteristic.uuid().eq_ignore_ascii_case(TX_UUID) { - self.subscribers.remove(¢ral.identifier()); + let central_id = central.identifier(); + self.subscribers.remove(¢ral_id); + let request_ids = self.pairing_centrals.take_for_central(¢ral_id); + let cancelled = if request_ids.is_empty() { + 0 + } else { + let mut model = self + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cancelled = request_ids + .iter() + .filter(|request_id| model.engine.cancel_pairing(request_id)) + .count(); + model.state.pending_pairings = model.engine.pending_pairings(); + cancelled + }; if self.subscribers.is_empty() { self.stop_all_repeats(); self.outbound.clear(); @@ -514,6 +575,13 @@ impl MacRuntime { self.app.state::().end_session(); self.set_bluetooth(BluetoothState::Advertising); } + if cancelled > 0 { + set_activity( + &self.shared, + ActivityKind::Info, + "Pairing request cancelled.", + ); + } emit_state(&self.app, &self.shared); } Ok(()) @@ -538,6 +606,7 @@ impl MacRuntime { fn handle_writes(&mut self, requests: Vec) -> Result<(), String> { for request in requests { let uuid = request.characteristic().uuid(); + let central_id = request.central().identifier(); let result = if !uuid.eq_ignore_ascii_case(RX_UUID) { AttError::WriteNotPermitted } else if request.offset() != 0 { @@ -545,7 +614,7 @@ impl MacRuntime { } else { match request.value() { Ok(Some(value)) => { - self.handle_frame(&value); + self.handle_frame(¢ral_id, &value); AttError::Success } _ => AttError::InvalidPdu, @@ -558,7 +627,7 @@ impl MacRuntime { Ok(()) } - fn handle_frame(&mut self, bytes: &[u8]) { + fn handle_frame(&mut self, central_id: &str, bytes: &[u8]) { let event = { self.shared .lock() @@ -574,13 +643,22 @@ impl MacRuntime { })) => { let request_id = request.request_id.clone(); let delay_ms = request.expires_at.saturating_sub(now_ms()) as u64; - { + let pending_request_ids = { let mut model = self .shared .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); model.state.pending_pairings = model.engine.pending_pairings(); - } + model + .state + .pending_pairings + .iter() + .map(|pending| pending.request_id.clone()) + .collect::>() + }; + self.pairing_centrals.retain_pending(&pending_request_ids); + self.pairing_centrals + .associate(request_id.clone(), central_id.to_string()); if let Some(response) = replaced_response { if let Err(error) = self.enqueue_message(&response) { self.report_error(error); @@ -1175,7 +1253,16 @@ impl MacRuntime { self.service = None; self.tx_characteristic = None; self.subscribers.clear(); + self.pairing_centrals.clear(); self.outbound.clear(); + { + let mut model = self + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + model.engine.cancel_all_pairings(); + model.state.pending_pairings.clear(); + } if let Some(input) = self.input.as_mut() { let _ = input.release_all(); input.end_control_session(); @@ -1239,8 +1326,10 @@ fn expire_pairing(app: &AppHandle, shared: &SharedModel, request_id: &str) -> Re response }; let Some(response) = response else { + runtime.pairing_centrals.remove(request_id); return Ok(()); }; + runtime.pairing_centrals.remove(request_id); runtime.enqueue_message(&response)?; set_activity(shared, ActivityKind::Info, "Pairing request expired."); emit_state(app, shared); @@ -1367,4 +1456,20 @@ mod tests { let profile = pointer_profile_for_display("Large", 1.0, 0, 0, 10_000, 10_000); assert_eq!(profile.large_delta, MAX_POINTER_DELTA as u32); } + + #[test] + fn pairing_central_registry_cancels_only_the_disconnected_central() { + let mut registry = PairingCentralRegistry::default(); + registry.associate("pair-a-old".into(), "central-a".into()); + registry.associate("pair-a".into(), "central-a".into()); + registry.associate("pair-b".into(), "central-b".into()); + + registry.retain_pending(&HashSet::from(["pair-a".to_string(), "pair-b".to_string()])); + let mut cancelled = registry.take_for_central("central-a"); + cancelled.sort(); + + assert_eq!(cancelled, ["pair-a"]); + assert!(registry.take_for_central("unknown").is_empty()); + assert_eq!(registry.take_for_central("central-b"), ["pair-b"]); + } } diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 60e11ba..bef5ba9 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -407,6 +407,18 @@ impl ProtocolEngine { pending } + pub fn cancel_pairing(&mut self, request_id: &str) -> bool { + self.pending_pairings.remove(request_id).is_some() + } + + pub fn cancel_all_pairings(&mut self) -> usize { + let request_ids = self.pending_pairings.keys().cloned().collect::>(); + request_ids + .iter() + .filter(|request_id| self.cancel_pairing(request_id)) + .count() + } + pub fn set_paired_token(&mut self, device_id: String, token: String) { self.tokens.insert(device_id, token); } @@ -1589,6 +1601,34 @@ mod tests { assert!(engine.reject_pairing("pair-1").is_err()); } + #[test] + fn pairing_cancellation_is_targeted_and_makes_expiry_harmless() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine + .process_message( + &pairing_request("pair-1", "android-1", "Pixel", "nonce-1").to_string(), + NOW, + ) + .unwrap(); + engine + .process_message( + &pairing_request("pair-2", "android-2", "Galaxy", "nonce-2").to_string(), + NOW + 1, + ) + .unwrap(); + + assert!(engine.cancel_pairing("pair-1")); + assert!(!engine.cancel_pairing("pair-1")); + assert_eq!(engine.pending_pairings()[0].request_id, "pair-2"); + assert_eq!( + engine.expire_pairing("pair-1", NOW + PAIRING_TIMEOUT_MS), + None + ); + assert_eq!(engine.cancel_all_pairings(), 1); + assert_eq!(engine.cancel_all_pairings(), 0); + assert!(engine.pending_pairings().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_runtime.rs b/src-tauri/src/windows_runtime.rs index b8bfe59..9121b5f 100644 --- a/src-tauri/src/windows_runtime.rs +++ b/src-tauri/src/windows_runtime.rs @@ -216,26 +216,43 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { let subscribe_shared = shared.clone(); tx.SubscribedClientsChanged( &TypedEventHandler::::new(move |sender, _| { - let connected = sender + let subscriber_count = sender .as_ref() .and_then(|value| value.SubscribedClients().ok()) - .and_then(|clients| clients.Size().ok()) - .is_some_and(|size| size > 0); - { + .and_then(|clients| clients.Size().ok()); + let Some(subscriber_count) = subscriber_count else { + eprintln!( + "Switchify BLE subscriber count was unavailable; preserving connection state." + ); + return Ok(()); + }; + let connected = subscriber_count > 0; + let cancelled = { let mut model = subscribe_shared .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cancelled = if should_cancel_pending_pairings(Some(subscriber_count)) { + model.engine.cancel_all_pairings() + } else { + 0 + }; + if cancelled > 0 { + model.state.pending_pairings = model.engine.pending_pairings(); + } model.state.bluetooth = if connected { BluetoothState::Connected } else { BluetoothState::Advertising }; model.state.connected_device_name = connected.then(|| "Bluetooth device".into()); - } + cancelled + }; set_activity( &subscribe_shared, ActivityKind::Info, - if connected { + if cancelled > 0 { + "Pairing request cancelled." + } else if connected { "Android device connected." } else { "Android device disconnected." @@ -322,6 +339,10 @@ async fn start_gatt(app: AppHandle, shared: SharedModel) -> Result<(), String> { Ok(()) } +fn should_cancel_pending_pairings(subscriber_count: Option) -> bool { + subscriber_count == Some(0) +} + fn update_advertisement_status( app: &AppHandle, shared: &SharedModel, @@ -964,6 +985,8 @@ pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), Strin let mut model = shared .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + model.engine.cancel_all_pairings(); + model.state.pending_pairings.clear(); model.state.bluetooth = BluetoothState::Advertising; model.state.connected_device_name = None; drop(model); @@ -984,7 +1007,15 @@ fn release_input_session() { #[cfg(test)] mod tests { - use super::tasklist_has_other_switchify_process; + use super::{should_cancel_pending_pairings, tasklist_has_other_switchify_process}; + + #[test] + fn pending_pairings_clear_only_after_the_final_subscriber_leaves() { + assert!(should_cancel_pending_pairings(Some(0))); + assert!(!should_cancel_pending_pairings(Some(1))); + assert!(!should_cancel_pending_pairings(Some(2))); + assert!(!should_cancel_pending_pairings(None)); + } #[test] fn conflict_check_ignores_the_current_process() { diff --git a/src/App.test.tsx b/src/App.test.tsx index 3abc1ac..d375de7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -216,6 +216,33 @@ describe("Switchify PC shell", () => { expect(screen.getByRole("button", { name: "Reject pairing request from Pixel, code 111111" })).toHaveFocus(); }); + it("clears a cancelled pairing request from the setup guide runtime event", async () => { + let stateHandler: ((state: typeof browserState) => void) | undefined; + browserState.setup = { shown: false, completed: false, autoOpenEligible: true }; + browserState.pendingPairings = [ + { requestId: "pair-cancelled", deviceId: "android-1", deviceName: "Galaxy", verificationCode: "063781", expiresAt: 1 }, + ]; + vi.spyOn(api, "onState").mockImplementation(async (handler) => { + stateHandler = handler; + return () => undefined; + }); + + render(); + const setup = await screen.findByRole("dialog", { name: "Bluetooth and input access" }); + fireEvent.click(within(setup).getByRole("button", { name: "Next" })); + fireEvent.click(within(setup).getByRole("button", { name: "Next" })); + expect(screen.getByLabelText("Verification code for Galaxy")).toHaveTextContent("063781"); + + act(() => stateHandler?.({ + ...structuredClone(browserState), + pendingPairings: [], + lastActivity: { kind: "info", message: "Pairing request cancelled." }, + })); + + await waitFor(() => expect(screen.queryByLabelText("Verification code for Galaxy")).not.toBeInTheDocument()); + expect(screen.getByRole("heading", { name: "Waiting for an Android device" })).toBeInTheDocument(); + }); + it("opens settings with accessible native controls", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" }));