From db48185852c4adeb2ee5bdab68a2fd3392a65693 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:54:29 +0100 Subject: [PATCH 1/5] fix: harden Linux pairing credential storage --- docs/linux-credentials.md | 21 +++++ src-tauri/src/storage.rs | 171 +++++++++++++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 docs/linux-credentials.md diff --git a/docs/linux-credentials.md b/docs/linux-credentials.md new file mode 100644 index 00000000..98cd28d4 --- /dev/null +++ b/docs/linux-credentials.md @@ -0,0 +1,21 @@ +# Linux credential storage foundation + +Issue #720. This is storage hardening on `linux-support`, not an enabled secure Linux runtime. + +The pinned keyring 4.1.6 default `v1` adapter selects Secret Service on Linux, Windows Credential Manager on Windows, and Keychain Services on macOS. Linux continues to use the existing service `com.enaboapps.switchify.pc.pairing` and device-ID lookup keys. No plaintext, kernel-keyring or memory fallback is added. Dependencies, state schema and other platforms' storage selection are unchanged. + +The Linux wrapper serializes operations on each storage instance. Save succeeds only when a nonempty token is written and read back unchanged. Missing credentials remain distinct from an inaccessible store; empty stored values fail closed. Backend errors are replaced with a fixed remediation message, never raw D-Bus text, credential attributes or tokens. Unlock the desktop Secret Service keyring; ensure a Secret Service provider is installed/running, then restart Switchify PC. The pinned keyring adapter caches initial store initialization, so merely retrying after an initial service failure may not recover in the same process. + +A failed verification does not delete the credential: a write may have succeeded before a read failed, and destructive rollback could erase existing access. A replacement write can therefore change the backend even when verification reports failure; it is not a transaction or a durability guarantee. Future pairing integration must not approve/publish a token on any storage error and must account for replacement failure recovery. + +The existing model restores pairing tokens transactionally into the protocol engine. If any load fails, no tokens are activated, and saved pairing metadata is preserved when settings are persisted. Restarting after storage recovery can restore access. Ordinary missing entries retain existing behavior; this wrapper does not infer that every missing entry means the whole store failed. + +## Validation and remaining gates + +Fake-store tests cover successful write verification, recreated wrapper reads, empty credentials, mismatched read-back, save/load/delete failures, non-destructive verification failure and model metadata preservation/recovery. They never access a real keyring or Bluetooth device. Recreating a fake adapter is not physical persistence evidence. + +Before enabling Linux pairing, manually qualify an unlocked store, locked/missing provider, permission denial, application restart, logout/login, failed replacement and explicit forgetting on each supported desktop. No new readiness probe writes test credentials or prompts at startup. Bounded/off-main-thread credential operations and recovery UX belong to production runtime integration; this wrapper retains the synchronous storage API. + +Subscriber isolation remains a separate unresolved gate. This change cannot approve Linux pairing or inject input. + +Reference: [keyring 4.1.6 v1 adapter](https://docs.rs/keyring/4.1.6/keyring/v1/index.html). diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index d409ac53..9544c4d8 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -225,7 +225,75 @@ fn platform_pairing_token_store(state_path: &Path) -> Box )) } -#[cfg(not(target_os = "macos"))] +#[cfg(any(target_os = "linux", test))] +const LINUX_CREDENTIAL_ERROR: &str = "Linux credential storage is unavailable. Unlock the desktop Secret Service keyring and restart Switchify PC. Saved pairing records have been preserved."; + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug)] +struct LinuxPairingTokenStore { + secure: std::sync::Mutex>, +} + +#[cfg(any(target_os = "linux", test))] +impl LinuxPairingTokenStore { + fn new(secure: Box) -> Self { + Self { + secure: std::sync::Mutex::new(secure), + } + } +} + +#[cfg(any(target_os = "linux", test))] +impl PairingTokenStore for LinuxPairingTokenStore { + fn save(&self, device_id: &str, token: &str) -> Result<(), String> { + if token.is_empty() { + return Err(LINUX_CREDENTIAL_ERROR.into()); + } + let secure = self + .secure + .lock() + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())?; + secure + .save(device_id, token) + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())?; + // A successful backend write alone must not acknowledge durable access. + // Do not delete on read-back failure: the write may have succeeded and an + // existing credential must not be erased by an attempted rollback. + match secure.load(device_id) { + Ok(Some(stored)) if stored == token => Ok(()), + _ => Err(LINUX_CREDENTIAL_ERROR.into()), + } + } + + fn load(&self, device_id: &str) -> Result, String> { + let secure = self + .secure + .lock() + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())?; + match secure.load(device_id) { + Ok(Some(token)) if token.is_empty() => Err(LINUX_CREDENTIAL_ERROR.into()), + Ok(token) => Ok(token), + Err(_) => Err(LINUX_CREDENTIAL_ERROR.into()), + } + } + + fn delete(&self, device_id: &str) -> Result<(), String> { + self.secure + .lock() + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())? + .delete(device_id) + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string()) + } +} + +#[cfg(target_os = "linux")] +fn platform_pairing_token_store(_state_path: &Path) -> Box { + Box::new(LinuxPairingTokenStore::new( + Box::::default(), + )) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn platform_pairing_token_store(_state_path: &Path) -> Box { Box::::default() } @@ -492,6 +560,107 @@ mod tests { } } + #[test] + fn linux_credentials_verify_writes_and_allow_recreated_adapter_reads() { + let secure = SharedPairingTokenStore::default(); + let store = LinuxPairingTokenStore::new(Box::new(secure.clone())); + store.save("device", "test-token").unwrap(); + drop(store); + let restored = LinuxPairingTokenStore::new(Box::new(secure)); + assert_eq!( + restored.load("device").unwrap().as_deref(), + Some("test-token") + ); + restored.delete("device").unwrap(); + restored.delete("device").unwrap(); + assert_eq!(restored.load("device").unwrap(), None); + } + + #[test] + fn linux_credentials_fail_closed_without_raw_errors_or_destructive_rollback() { + let secure = SharedPairingTokenStore::default(); + let store = LinuxPairingTokenStore::new(Box::new(secure.clone())); + store.save("device", "test-token").unwrap(); + secure.set_failure("save", true); + assert_eq!( + store.save("device", "replacement").unwrap_err(), + LINUX_CREDENTIAL_ERROR + ); + assert_eq!(secure.token("device").as_deref(), Some("test-token")); + secure.set_failure("save", false); + secure.set_failure("load", true); + assert_eq!(store.load("device").unwrap_err(), LINUX_CREDENTIAL_ERROR); + assert_eq!( + store.save("device", "replacement").unwrap_err(), + LINUX_CREDENTIAL_ERROR + ); + assert_eq!(secure.token("device").as_deref(), Some("replacement")); + secure.set_failure("load", false); + assert_eq!( + store.load("device").unwrap().as_deref(), + Some("replacement") + ); + secure.set_failure("delete", true); + assert_eq!(store.delete("device").unwrap_err(), LINUX_CREDENTIAL_ERROR); + assert_eq!(secure.token("device").as_deref(), Some("replacement")); + } + + #[test] + fn linux_credentials_reject_corrupt_readback_and_empty_tokens() { + let secure = SharedPairingTokenStore::default(); + let store = LinuxPairingTokenStore::new(Box::new(secure.clone())); + assert!(store.save("device", "").is_err()); + assert_eq!(secure.token("device"), None); + secure.set_corrupt_save(true); + assert_eq!( + store.save("device", "test-token").unwrap_err(), + LINUX_CREDENTIAL_ERROR + ); + secure.set_corrupt_save(false); + secure.save("device", "").unwrap(); + assert_eq!(store.load("device").unwrap_err(), LINUX_CREDENTIAL_ERROR); + } + + #[test] + fn linux_storage_failure_preserves_metadata_and_restores_after_recovery() { + let secure = SharedPairingTokenStore::default(); + secure.save("device", "test-token").unwrap(); + let root = std::env::temp_dir().join(format!( + "switchify-linux-credentials-{}", + uuid::Uuid::new_v4() + )); + let storage = AppStorage { + path: root.join(STATE_FILE), + pairing_tokens: Box::new(LinuxPairingTokenStore::new(Box::new(secure.clone()))), + }; + storage + .save(&PersistedState { + paired_devices: vec![PairedDeviceView { + device_id: "device".into(), + device_name: "Test phone".into(), + paired_at: 1, + last_seen_at: None, + }], + ..PersistedState::default() + }) + .unwrap(); + secure.set_failure("load", true); + let model = crate::state::AppModel::with_storage_for_test(storage); + assert!(model.snapshot().paired_devices.is_empty()); + model + .persist_settings(&crate::state::AppSettings::default()) + .unwrap(); + assert_eq!(model.storage.load().unwrap().paired_devices.len(), 1); + secure.set_failure("load", false); + let restored = crate::state::AppModel::with_storage_for_test(model.storage); + assert_eq!(restored.snapshot().paired_devices.len(), 1); + assert_eq!( + restored.shared.lock().unwrap().engine.token_for("device"), + Some("test-token") + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn application_storage_uses_the_promoted_identity() { assert_eq!(APP_NAME, "Switchify PC"); From 0614c5b1395d918e28f8115598ff333a1376a3b9 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:18:06 +0100 Subject: [PATCH 2/5] Add bounded Linux credential worker foundation --- docs/linux-credential-worker.md | 11 + src-tauri/Cargo.toml | 3 + src-tauri/src/lib.rs | 4 + src-tauri/src/linux_credential_worker.rs | 376 +++++++++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 docs/linux-credential-worker.md create mode 100644 src-tauri/src/linux_credential_worker.rs diff --git a/docs/linux-credential-worker.md b/docs/linux-credential-worker.md new file mode 100644 index 00000000..41ed9193 --- /dev/null +++ b/docs/linux-credential-worker.md @@ -0,0 +1,11 @@ +# Linux credential worker foundation + +Issue #722, dependent on #721. This component is not started by the unavailable Linux runtime. It does not activate Bluetooth, approve pairing, inject input or change existing synchronous model startup. Production ownership and startup migration remain separate work. + +One worker owns one dedicated blocking thread and admits at most one outstanding load, save or delete. Overlapping callers receive `Busy`; there is no retry queue or per-request thread spawning. A caller-supplied timeout must be greater than zero and at most 30 seconds. Device identifiers are limited to 128 bytes and tokens to 4096 bytes; empty identifiers/tokens are rejected. Backend errors become the fixed `StorageUnavailable` result, with no raw native error or credential logging. + +Timeout and dropping a future invalidate its result. Generation invalidation discards stale results; queued work checks cancellation before calling the backend. Cancellation can race with starting a native call and cannot interrupt it or roll back a save/delete already underway. A timed-out write may still commit. Callers must never publish/approve a credential from a cancelled or failed operation and must reconcile persisted state before retrying a replacement. + +Admission remains busy until the native call returns, even after caller timeout. Shutdown stops admission, invalidates results and closes the channel without joining the thread. A stuck native call may therefore leave a detached thread until process exit. The future runtime must own exactly one worker and must not recreate workers on timeout; otherwise repeated recreation would defeat the thread bound. Restart is the recovery path for a permanently stuck worker. + +Fake-store tests cover save/load/delete, request and response bounds, sanitized errors, timeout while a call remains blocked, busy admission, discarded stale results, recovery, dropped futures and non-blocking shutdown. They perform no real keyring operations. Physical Secret Service qualification and subscriber isolation are still required before enabling secure Linux pairing; see [credential storage gates](linux-credentials.md). diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02f1f449..b3c64016 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,5 +66,8 @@ windows = { version = "0.62.2", features = [ ] } winreg = "0.55" +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } + [patch.crates-io] corebluetooth-rs = { path = "../vendor/corebluetooth-rs" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6f286be..92170dcc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,10 @@ mod grid3; #[cfg_attr(target_os = "linux", allow(dead_code))] mod input; #[cfg(target_os = "linux")] +// Prepared for runtime integration; not started by the unavailable Linux runtime. +#[allow(dead_code)] +mod linux_credential_worker; +#[cfg(target_os = "linux")] mod linux_runtime; #[cfg(target_os = "macos")] mod macos; diff --git a/src-tauri/src/linux_credential_worker.rs b/src-tauri/src/linux_credential_worker.rs new file mode 100644 index 00000000..460b3c90 --- /dev/null +++ b/src-tauri/src/linux_credential_worker.rs @@ -0,0 +1,376 @@ +//! One blocking credential worker; no native operation is started until requested. +//! Caller cancellation invalidates results, not side effects of an in-flight write. +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{sync_channel, SyncSender}, + Arc, +}; +use std::time::Duration; + +use tokio::sync::oneshot; + +use crate::storage::AppStorage; + +const MAX_DEVICE_ID_BYTES: usize = 128; +const MAX_TOKEN_BYTES: usize = 4096; + +pub(crate) trait CredentialBackend: Send + Sync + 'static { + fn load(&self, id: &str) -> Result, String>; + fn save(&self, id: &str, token: &str) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +impl CredentialBackend for AppStorage { + fn load(&self, id: &str) -> Result, String> { + self.load_pairing_token(id) + } + fn save(&self, id: &str, token: &str) -> Result<(), String> { + self.save_pairing_token(id, token) + } + fn delete(&self, id: &str) -> Result<(), String> { + self.delete_pairing_token(id) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkerError { + Busy, + TimedOut, + Cancelled, + Stopped, + InvalidRequest, + StorageUnavailable, +} + +enum Operation { + Load(String), + Save(String, String), + Delete(String), +} +// Intentionally no Debug: replies and operations may hold credential material. +enum Reply { + Loaded(Option), + Done, +} + +struct State { + busy: AtomicBool, + stopped: AtomicBool, + generation: AtomicU64, +} + +struct Request { + operation: Operation, + generation: u64, + cancelled: Arc, + reply: oneshot::Sender>, +} + +struct BusyGuard(Arc); +impl Drop for BusyGuard { + fn drop(&mut self) { + self.0.busy.store(false, Ordering::SeqCst); + } +} + +struct CancellationGuard(Arc); +impl Drop for CancellationGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +pub(crate) struct CredentialWorker { + sender: Option>, + state: Arc, + wait: Duration, +} + +impl CredentialWorker { + pub(crate) fn start( + backend: Arc, + wait: Duration, + ) -> Result { + if wait.is_zero() || wait > Duration::from_secs(30) { + return Err(WorkerError::InvalidRequest); + } + let (sender, receiver) = sync_channel::(1); + let state = Arc::new(State { + busy: AtomicBool::new(false), + stopped: AtomicBool::new(false), + generation: AtomicU64::new(0), + }); + let worker_state = state.clone(); + std::thread::Builder::new() + .name("linux-credentials".into()) + .spawn(move || { + while let Ok(request) = receiver.recv() { + let _busy = BusyGuard(worker_state.clone()); + let current = || { + !worker_state.stopped.load(Ordering::SeqCst) + && worker_state.generation.load(Ordering::SeqCst) == request.generation + && !request.cancelled.load(Ordering::SeqCst) + }; + if !current() { + drop(_busy); + let _ = request.reply.send(Err(WorkerError::Cancelled)); + continue; + } + let result = match &request.operation { + Operation::Load(id) => backend.load(id).map(Reply::Loaded), + Operation::Save(id, token) => backend.save(id, token).map(|()| Reply::Done), + Operation::Delete(id) => backend.delete(id).map(|()| Reply::Done), + } + .map_err(|_| WorkerError::StorageUnavailable); + let result = match result { + Ok(Reply::Loaded(Some(ref token))) + if token.is_empty() || token.len() > MAX_TOKEN_BYTES => + { + Err(WorkerError::StorageUnavailable) + } + other => other, + }; + let result = if current() { + result + } else { + Err(WorkerError::Cancelled) + }; + // Release admission before notifying the next caller. + drop(_busy); + let _ = request.reply.send(result); + } + worker_state.stopped.store(true, Ordering::SeqCst); + }) + .map_err(|_| WorkerError::Stopped)?; + Ok(Self { + sender: Some(sender), + state, + wait, + }) + } + + pub(crate) fn invalidate(&self) { + self.state.generation.fetch_add(1, Ordering::SeqCst); + } + + pub(crate) fn shutdown(&mut self) { + self.state.stopped.store(true, Ordering::SeqCst); + self.invalidate(); + self.sender.take(); + // Never join here: a synchronous Secret Service call may not be cancellable. + } + + async fn request(&self, operation: Operation) -> Result { + if self.state.stopped.load(Ordering::SeqCst) { + return Err(WorkerError::Stopped); + } + if self + .state + .busy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Err(WorkerError::Busy); + } + let generation = self.state.generation.load(Ordering::SeqCst); + let cancelled = Arc::new(AtomicBool::new(false)); + let _cancel = CancellationGuard(cancelled.clone()); + let (reply, receive) = oneshot::channel(); + let request = Request { + operation, + generation, + cancelled, + reply, + }; + if self + .sender + .as_ref() + .is_none_or(|sender| sender.try_send(request).is_err()) + { + self.state.busy.store(false, Ordering::SeqCst); + return Err(WorkerError::Stopped); + } + let result = tokio::time::timeout(self.wait, receive) + .await + .map_err(|_| WorkerError::TimedOut)? + .map_err(|_| WorkerError::Stopped)?; + if self.state.stopped.load(Ordering::SeqCst) + || self.state.generation.load(Ordering::SeqCst) != generation + { + return Err(WorkerError::Cancelled); + } + result + } + + fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_DEVICE_ID_BYTES + } + + pub(crate) async fn load(&self, id: &str) -> Result, WorkerError> { + if !Self::valid_id(id) { + return Err(WorkerError::InvalidRequest); + } + match self.request(Operation::Load(id.into())).await? { + Reply::Loaded(token) => Ok(token), + Reply::Done => Err(WorkerError::Stopped), + } + } + + pub(crate) async fn save(&self, id: &str, token: &str) -> Result<(), WorkerError> { + if !Self::valid_id(id) || token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return Err(WorkerError::InvalidRequest); + } + self.request(Operation::Save(id.into(), token.into())) + .await + .map(|_| ()) + } + + pub(crate) async fn delete(&self, id: &str) -> Result<(), WorkerError> { + if !Self::valid_id(id) { + return Err(WorkerError::InvalidRequest); + } + self.request(Operation::Delete(id.into())).await.map(|_| ()) + } +} + +impl Drop for CredentialWorker { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Condvar, Mutex}; + + #[derive(Default)] + struct Fake { + token: Mutex>, + gate: (Mutex, Condvar), + entered: AtomicBool, + fail: AtomicBool, + } + impl Fake { + fn release(&self) { + *self.gate.0.lock().unwrap() = false; + self.gate.1.notify_all(); + } + } + impl CredentialBackend for Fake { + fn load(&self, _: &str) -> Result, String> { + self.entered.store(true, Ordering::SeqCst); + let mut blocked = self.gate.0.lock().unwrap(); + while *blocked { + blocked = self.gate.1.wait(blocked).unwrap(); + } + if self.fail.load(Ordering::SeqCst) { + return Err("private native detail".into()); + } + Ok(self.token.lock().unwrap().clone()) + } + fn save(&self, _: &str, token: &str) -> Result<(), String> { + *self.token.lock().unwrap() = Some(token.into()); + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), String> { + *self.token.lock().unwrap() = None; + Ok(()) + } + } + async fn wait_until(mut predicate: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(2), async { + while !predicate() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + #[tokio::test] + async fn success_validation_and_sanitized_errors() { + let fake = Arc::new(Fake::default()); + let worker = CredentialWorker::start(fake.clone(), Duration::from_secs(1)).unwrap(); + worker.save("id", "test-token").await.unwrap(); + assert_eq!( + worker.load("id").await.unwrap().as_deref(), + Some("test-token") + ); + worker.delete("id").await.unwrap(); + assert_eq!(worker.load("id").await.unwrap(), None); + assert_eq!(worker.load("").await, Err(WorkerError::InvalidRequest)); + assert_eq!( + worker.delete(&"x".repeat(MAX_DEVICE_ID_BYTES + 1)).await, + Err(WorkerError::InvalidRequest) + ); + assert_eq!( + worker.save("id", &"x".repeat(MAX_TOKEN_BYTES + 1)).await, + Err(WorkerError::InvalidRequest) + ); + for token in [String::new(), "x".repeat(MAX_TOKEN_BYTES + 1)] { + *fake.token.lock().unwrap() = Some(token); + assert_eq!( + worker.load("id").await, + Err(WorkerError::StorageUnavailable) + ); + } + fake.fail.store(true, Ordering::SeqCst); + assert_eq!( + worker.load("id").await, + Err(WorkerError::StorageUnavailable) + ); + } + #[tokio::test(start_paused = true)] + async fn timeout_keeps_single_worker_busy_until_backend_returns() { + let fake = Arc::new(Fake::default()); + *fake.gate.0.lock().unwrap() = true; + let worker = CredentialWorker::start(fake.clone(), Duration::from_millis(50)).unwrap(); + let request = worker.load("id"); + tokio::pin!(request); + tokio::select! { + _ = &mut request => panic!("request must remain blocked"), + _ = wait_until(|| fake.entered.load(Ordering::SeqCst)) => (), + } + tokio::time::advance(Duration::from_millis(51)).await; + assert_eq!(request.await, Err(WorkerError::TimedOut)); + assert_eq!(worker.load("id").await, Err(WorkerError::Busy)); + tokio::time::resume(); + fake.release(); + wait_until(|| !worker.state.busy.load(Ordering::SeqCst)).await; + assert_eq!(worker.load("id").await.unwrap(), None); + } + #[tokio::test] + async fn invalidation_discards_inflight_result_and_allows_recovery() { + let fake = Arc::new(Fake::default()); + *fake.gate.0.lock().unwrap() = true; + let worker = CredentialWorker::start(fake.clone(), Duration::from_secs(1)).unwrap(); + let request = worker.load("id"); + tokio::pin!(request); + tokio::select! { + _ = &mut request => panic!("request must remain blocked"), + _ = wait_until(|| fake.entered.load(Ordering::SeqCst)) => (), + } + worker.invalidate(); + fake.release(); + assert_eq!(request.await, Err(WorkerError::Cancelled)); + assert_eq!(worker.load("id").await.unwrap(), None); + } + #[tokio::test] + async fn dropped_future_and_shutdown_do_not_wait_for_blocked_backend() { + let fake = Arc::new(Fake::default()); + *fake.gate.0.lock().unwrap() = true; + let mut worker = CredentialWorker::start(fake.clone(), Duration::from_secs(1)).unwrap(); + { + let request = worker.load("id"); + tokio::pin!(request); + tokio::select! { + _ = &mut request => panic!("request must remain blocked"), + _ = wait_until(|| fake.entered.load(Ordering::SeqCst)) => (), + } + } + assert_eq!(worker.load("id").await, Err(WorkerError::Busy)); + worker.shutdown(); + assert_eq!(worker.load("id").await, Err(WorkerError::Stopped)); + fake.release(); + } +} From ba18e8e33ff1fac745f01539ab813a936dd32aec Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:25:47 +0100 Subject: [PATCH 3/5] Add peer-scoped Linux read response mailbox --- docs/linux-read-responses.md | 19 +++ src-tauri/src/lib.rs | 3 + src-tauri/src/linux_read_responses.rs | 162 ++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 docs/linux-read-responses.md create mode 100644 src-tauri/src/linux_read_responses.rs diff --git a/docs/linux-read-responses.md b/docs/linux-read-responses.md new file mode 100644 index 00000000..d1bbb145 --- /dev/null +++ b/docs/linux-read-responses.md @@ -0,0 +1,19 @@ +# Linux peer-scoped reply transport + +Issue #724. Optional BLE transport extension, retaining protocol v1 messages and authentication. This mailbox is not yet wired to a live runtime. + +## Why notifications cannot carry Linux replies + +In BlueZ 5.72, `sock_io_read` passes AcquireNotify data to `send_notification_to_devices`, which iterates subscribed device states. Device identity on a BlueR writer is not a delivery boundary. Refusing a competing writer after BlueZ accepts its CCC subscription does not close that race. This is source evidence of broadcast behavior, not merely missing hardware evidence: [pinned BlueZ implementation](https://github.com/bluez/bluez/blob/5.72/src/gatt-database.c#L2445). + +## Negotiation and wire contract + +A Linux server using this transport adds `responseTransport: "read-v1"` to discovery status and exposes read-only characteristic `7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4` under the existing Switchify service. Existing service/RX/TX/status identifiers are unchanged. Updated clients choose polling only after reading this marker; absent marker retains existing notification behavior. Unsupported marker values must fail closed. Linux read-v1 servers never place protocol responses on TX, even for older clients. Old clients cannot complete pairing and must update; there is no sensitive notification fallback. + +Each offset-zero read consumes one existing v1 JSON/base64 frame (at most 180 bytes); an empty value means no reply. Nonzero offset reads return the same snapshot for ATT long-read assembly. Clients run only one read at a time. A read failure terminates the session rather than retrying a possibly consumed frame. No ACK/retransmission mechanism is added. Clients retain normal bounded protocol reassembly and request deadlines. Reads are directed ATT responses associated by BlueZ with the requesting connection, not notifications. + +The runtime must associate RX and mailbox ownership with the BlueZ request peer, reject competing peers, serialize access and clear the mailbox/reassembler/input on disconnect before allowing reuse of an address. Idle mailbox reads never claim ownership. Every async response carries the mailbox generation; results from previous sessions are rejected. Queue admission is atomic and capped at 256 KiB encoded data plus one 180-byte read snapshot. On overflow the runtime must tear down the session, not silently lose a reply. + +## Qualification + +Automated fake-peer tests cover competing reads without consumption, long-read offsets at MTU 23, normal protocol framing, bounded queues and generation cleanup. No credential is broadcast by this design. Real Android read interoperability, disconnect races and input cleanup still need a supervised test before calling the build usable. Multi-adapter coverage remains a broader release-quality gate, but notification broadcast isolation is no longer the intended trust boundary. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92170dcc..a69d2174 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,9 @@ mod input; #[allow(dead_code)] mod linux_credential_worker; #[cfg(target_os = "linux")] +#[allow(dead_code)] +mod linux_read_responses; +#[cfg(target_os = "linux")] mod linux_runtime; #[cfg(target_os = "macos")] mod macos; diff --git a/src-tauri/src/linux_read_responses.rs b/src-tauri/src/linux_read_responses.rs new file mode 100644 index 00000000..ac52d551 --- /dev/null +++ b/src-tauri/src/linux_read_responses.rs @@ -0,0 +1,162 @@ +//! Linux replies use ATT reads, never characteristic notifications. The runtime +//! must close this mailbox on disconnect before admitting another connection. +use std::collections::VecDeque; + +use crate::protocol::create_notification_frames; + +pub const RESPONSE_UUID: &str = "7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4"; +pub const RESPONSE_TRANSPORT: &str = "read-v1"; +const FRAME_BYTES: usize = 180; +const MAX_QUEUED_BYTES: usize = 256 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReadError { + Unauthorized, + InvalidOffset, + InvalidMtu, + StaleSession, + Full, + InvalidMessage, +} + +// No Debug: queued responses can contain pairing credentials. +#[derive(Default)] +pub struct ReadResponses { + owner: Option<[u8; 6]>, + generation: u64, + frames: VecDeque>, + bytes: usize, + snapshot: Vec, +} + +impl ReadResponses { + pub fn open(&mut self, peer: [u8; 6]) -> Result { + match self.owner { + Some(owner) if owner != peer => Err(ReadError::Unauthorized), + Some(_) => Ok(self.generation), + None => { + self.close(); + self.owner = Some(peer); + Ok(self.generation) + } + } + } + + pub fn close(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.owner = None; + self.frames.clear(); + self.snapshot.clear(); + self.bytes = 0; + } + + pub fn enqueue(&mut self, generation: u64, message: &str) -> Result<(), ReadError> { + if self.owner.is_none() || self.generation != generation { + return Err(ReadError::StaleSession); + } + let frames = create_notification_frames(message, FRAME_BYTES) + .map_err(|_| ReadError::InvalidMessage)?; + let bytes: usize = frames.iter().map(Vec::len).sum(); + if self.bytes + bytes > MAX_QUEUED_BYTES { + return Err(ReadError::Full); + } + self.bytes += bytes; + self.frames.extend(frames); + Ok(()) + } + + pub fn read(&mut self, peer: [u8; 6], offset: u16, mtu: u16) -> Result, ReadError> { + if mtu < 23 { + return Err(ReadError::InvalidMtu); + } + if self.owner.is_some_and(|owner| owner != peer) { + return Err(ReadError::Unauthorized); + } + if offset == 0 { + self.snapshot = self.frames.pop_front().unwrap_or_default(); + self.bytes -= self.snapshot.len(); + } + let tail = self + .snapshot + .get(usize::from(offset)..) + .ok_or(ReadError::InvalidOffset)?; + Ok(tail[..tail.len().min(usize::from(mtu) - 1)].to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::FrameReassembler; + + const A: [u8; 6] = [1; 6]; + const B: [u8; 6] = [2; 6]; + + #[test] + fn competitor_cannot_read_or_consume_owner_replies() { + let mut queue = ReadResponses::default(); + let generation = queue.open(A).unwrap(); + queue.enqueue(generation, "public test response").unwrap(); + assert_eq!(queue.open(B), Err(ReadError::Unauthorized)); + assert_eq!(queue.read(B, 0, 517), Err(ReadError::Unauthorized)); + assert_eq!(queue.read(B, 1, 517), Err(ReadError::Unauthorized)); + assert!(!queue.read(A, 0, 517).unwrap().is_empty()); + assert!(queue.read(A, 0, 517).unwrap().is_empty()); + } + + #[test] + fn long_reads_preserve_snapshot_and_shared_framing() { + let mut queue = ReadResponses::default(); + let generation = queue.open(A).unwrap(); + let message = "public fixture ".repeat(300); + queue.enqueue(generation, &message).unwrap(); + let mut assembler = FrameReassembler::default(); + let mut completed = None; + loop { + let mut frame = queue.read(A, 0, 23).unwrap(); + if frame.is_empty() { + break; + } + loop { + let part = queue.read(A, frame.len() as u16, 23).unwrap(); + if part.is_empty() { + break; + } + frame.extend(part); + } + assert!(frame.len() <= FRAME_BYTES); + completed = assembler + .accept(serde_json::from_slice(&frame).unwrap(), 0) + .unwrap() + .or(completed); + } + assert_eq!(completed.as_deref(), Some(message.as_str())); + } + + #[test] + fn disconnect_discards_snapshots_queue_and_late_results() { + let mut queue = ReadResponses::default(); + let old = queue.open(A).unwrap(); + queue.enqueue(old, "public old response").unwrap(); + queue.read(A, 0, 23).unwrap(); + queue.close(); + let new = queue.open(B).unwrap(); + assert_ne!(old, new); + assert_eq!(queue.enqueue(old, "late"), Err(ReadError::StaleSession)); + assert!(queue.read(B, 0, 517).unwrap().is_empty()); + assert_eq!(queue.read(B, 1, 517), Err(ReadError::InvalidOffset)); + assert_eq!(queue.read(B, 0, 22), Err(ReadError::InvalidMtu)); + } + + #[test] + fn queue_is_bounded_and_overflow_does_not_partially_enqueue() { + let mut queue = ReadResponses::default(); + let generation = queue.open(A).unwrap(); + let message = "x".repeat(16 * 1024); + while queue.enqueue(generation, &message).is_ok() {} + let before = queue.bytes; + assert!(before <= MAX_QUEUED_BYTES); + assert_eq!(queue.enqueue(generation, &message), Err(ReadError::Full)); + assert_eq!(queue.bytes, before); + } +} From 89511a0cd4ee1f68db3c8d9ece4b9f426cab146d Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:43:56 +0100 Subject: [PATCH 4/5] Add opt-in Linux X11 read-response runtime --- docs/linux-x11-development.md | 35 ++ src-tauri/Cargo.lock | 225 ++++++- src-tauri/Cargo.toml | 4 + src-tauri/src/lib.rs | 108 ++-- src-tauri/src/linux_live.rs | 1047 +++++++++++++++++++++++++++++++ src-tauri/src/linux_runtime.rs | 24 +- src-tauri/src/protocol.rs | 47 +- src/settings/PointerSection.tsx | 8 +- src/settings/SettingsView.tsx | 2 +- src/settings/settings.test.tsx | 13 + 10 files changed, 1448 insertions(+), 65 deletions(-) create mode 100644 docs/linux-x11-development.md create mode 100644 src-tauri/src/linux_live.rs diff --git a/docs/linux-x11-development.md b/docs/linux-x11-development.md new file mode 100644 index 00000000..97cc2668 --- /dev/null +++ b/docs/linux-x11-development.md @@ -0,0 +1,35 @@ +# Experimental X11 control build + +Issue #726; depends on the credential and read-response stack (#721, #723, #725) and Switchify Remote read-v1 support (#157 in switchify-remote). This is a supervised development build, not a Linux production release or a claim of completed hardware qualification. + +## Build and opt in + +Build as an ordinary user on Ubuntu 24.04/Mint 22 X11, with the development prerequisites from the Linux CI job installed (WebKitGTK 4.1, GTK3, D-Bus, X11/XKB, OpenSSL and AppIndicator headers), Node 24 and Rust 1.97.1: + +```sh +npm ci +npm run tauri build -- --debug --no-bundle +SWITCHIFY_LINUX_EXPERIMENTAL=1 SWITCHIFY_LINUX_ADAPTER=hci0 ./src-tauri/target/debug/switchify-pc +``` + +Without the explicit environment opt-in the existing unavailable Linux runtime remains in use. Requires `XDG_SESSION_TYPE=x11`, a display and no Wayland display environment. The selected adapter must already be powered and advertising-capable. No power, pairing database, D-Bus policy or device permissions are changed. No root UI or input helper is used. Do not use this development mode at the lock screen, unattended, or on a shared desktop; active-seat/lock-screen qualification is not complete. + +Use an updated Remote that supports `responseTransport: read-v1`. Compare the pairing verification code in both apps before approving. Approval writes and verifies the credential through Secret Service, persists metadata, then activates and exposes the reply only through the peer-scoped read mailbox. Failures do not approve the device; uncertain replacement writes require restarting and pairing again. Missing/locked Secret Service providers are not bypassed. Startup restoration still uses the existing synchronous model path, so a provider that hangs during startup remains a known limitation. + +## Usable slice and limits + +Basic text, keyboard shortcuts/modifiers, streamed typing, pointer movement, clicks, drag, scroll and media commands use the existing input adapter. A reduced pointer profile disables repeat, dwell, window management, display navigation and switch forwarding. The Controls UI exposes pointer speed without unsupported repeat/dwell controls. Tray/overlay behavior stays at the existing Linux foundation (visible main window and no overlays). Exact layout, Unicode, scaling and media behavior need manual X11 validation. + +One runtime thread owns input and protocol processing. RX admission is bounded to 64 requests of at most 512 bytes. Read replies are bounded by the mailbox. Peer changes, disconnect observations, a two-second missing-poll lease, authentication failure, queue failures, forgetting and exit invalidate stale work and release tracked input. A detected wall-clock pause above three seconds also invalidates the session. Generation-scoped UI approval prevents an old approval from authorizing a replacement connection. Native credential waits are bounded; input is released before waiting. Expired pairing requests are removed. + +Radio/daemon failure is fail-closed; automatic service re-registration is not implemented. Restart after such a failure. The app never removes unrelated registrations or pairings. Shutdown requests cleanup and waits up to one second; an irrecoverably blocked native input call is not claimed cancellable. Physical disconnect/address-reuse ordering, cleanup under suspend/lock, Secret Service persistence, and competing ATT clients still require qualification. The read mailbox removes notification broadcasting as the reply mechanism, not all Bluetooth trust concerns. + +## Supervised acceptance test + +1. Start the opt-in desktop build; confirm the ordinary-user UI reports readiness without granting unavailable capabilities. +2. On the updated phone, discover the PC, compare the verification code and approve on the desktop. Verify pairing completes (not merely that services were discovered). +3. Focus a disposable text editor yourself. Send short test text and a shortcut; test pointer movement, click, scroll, then drag/release. +4. Disconnect while a modifier/drag is held and confirm it is released. Repeat with phone Bluetooth off, app exit and reconnect. +5. Restart the desktop and verify saved pairing reconnects. Repeat with a locked keyring and confirm an actionable failure, never silent approval. + +Automated tests use fake input only. No actual typing, clicking, scrolling or pointer movement is part of the test suite. Record exact desktop, BlueZ, adapter, Android and Remote versions with the manual results; never include tokens or typed private text. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aa2936b3..42410abe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -381,6 +381,35 @@ dependencies = [ "piper", ] +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "brotli" version = "8.0.4" @@ -792,14 +821,60 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -815,13 +890,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -832,11 +918,33 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ + "futures-channel", + "futures-util", "libc", "libdbus-sys", "windows-sys 0.61.2", ] +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1281,6 +1389,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -1288,6 +1411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1355,6 +1479,7 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2214,6 +2339,12 @@ dependencies = [ "log", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2305,6 +2436,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2434,6 +2571,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -2482,6 +2631,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2928,6 +3088,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3165,7 +3345,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3762,7 +3942,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3966,6 +4146,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3989,10 +4191,12 @@ version = "1.0.0-rc.6" dependencies = [ "base64 0.22.1", "block2", + "bluer", "core-graphics", "corebluetooth-rs", "directories", "enigo", + "futures", "hmac", "keyring", "libc", @@ -4622,6 +4826,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b3c64016..c2f271c8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,10 @@ tiny-skia = "0.11.4" tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } uuid = { version = "1", features = ["v4", "serde"] } +[target.'cfg(target_os = "linux")'.dependencies] +bluer = { version = "=0.17.4", features = ["bluetoothd"] } +futures = "0.3" + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" corebluetooth-rs = "=0.3.6" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a69d2174..8b4530ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ mod input; #[allow(dead_code)] mod linux_credential_worker; #[cfg(target_os = "linux")] +mod linux_live; +#[cfg(target_os = "linux")] #[allow(dead_code)] mod linux_read_responses; #[cfg(target_os = "linux")] @@ -50,14 +52,13 @@ mod windows_startup; #[cfg(target_os = "linux")] use linux_runtime::{ - approve_pairing as platform_approve_pairing, check_accessibility as platform_check_accessibility, disconnect_all as platform_disconnect_all, install as platform_install, reject_pairing as platform_reject_pairing, shutdown as platform_shutdown, }; -use state::{ - snapshot, ActivityKind, AppModel, AppSettings, AppState, PairedDeviceView, SwitchProfile, -}; +#[cfg(not(target_os = "linux"))] +use state::PairedDeviceView; +use state::{snapshot, ActivityKind, AppModel, AppSettings, AppState, SwitchProfile}; use std::sync::Mutex; use tauri::menu::MenuItem; #[cfg(not(target_os = "linux"))] @@ -305,48 +306,56 @@ async fn approve_pairing( model: State<'_, AppModel>, request_id: String, ) -> Result { - let pending = model - .snapshot() - .pending_pairings - .into_iter() - .find(|pending| pending.request_id == request_id) - .ok_or_else(|| "Pairing request is no longer pending.".to_string())?; - let shared = model.shared.clone(); - let operation_app = app.clone(); - on_main_thread(app, move || { - platform_approve_pairing(&operation_app, &shared, &request_id) - }) - .await?; - let token = { - let data = model - .shared - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - data.engine - .token_for(&pending.device_id) - .ok_or_else(|| "Pairing token was not created.".to_string())? - .to_owned() - }; - model - .storage - .save_pairing_token(&pending.device_id, &token)?; + #[cfg(target_os = "linux")] { - let mut data = model - .shared - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - data.state - .paired_devices - .retain(|device| device.device_id != pending.device_id); - data.state.paired_devices.push(PairedDeviceView { - device_id: pending.device_id, - device_name: pending.device_name, - paired_at: state::now_ms(), - last_seen_at: None, - }); + let _ = model; + linux_live::approve(&app, request_id).await + } + #[cfg(not(target_os = "linux"))] + { + let pending = model + .snapshot() + .pending_pairings + .into_iter() + .find(|pending| pending.request_id == request_id) + .ok_or_else(|| "Pairing request is no longer pending.".to_string())?; + let shared = model.shared.clone(); + let operation_app = app.clone(); + on_main_thread(app, move || { + platform_approve_pairing(&operation_app, &shared, &request_id) + }) + .await?; + let token = { + let data = model + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.engine + .token_for(&pending.device_id) + .ok_or_else(|| "Pairing token was not created.".to_string())? + .to_owned() + }; + model + .storage + .save_pairing_token(&pending.device_id, &token)?; + { + let mut data = model + .shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.state + .paired_devices + .retain(|device| device.device_id != pending.device_id); + data.state.paired_devices.push(PairedDeviceView { + device_id: pending.device_id, + device_name: pending.device_name, + paired_at: state::now_ms(), + last_seen_at: None, + }); + } + model.persist()?; + Ok(model.snapshot()) } - model.persist()?; - Ok(model.snapshot()) } #[tauri::command] @@ -437,7 +446,16 @@ fn modifier_overlay_present( } #[tauri::command] -fn forget_device(model: State<'_, AppModel>, device_id: String) -> Result { +async fn forget_device( + app: AppHandle, + model: State<'_, AppModel>, + device_id: String, +) -> Result { + #[cfg(target_os = "linux")] + if linux_live::requested() { + return linux_live::forget(&app, device_id).await; + } + let _ = app; { let mut data = model .shared diff --git a/src-tauri/src/linux_live.rs b/src-tauri/src/linux_live.rs new file mode 100644 index 00000000..3688e61d --- /dev/null +++ b/src-tauri/src/linux_live.rs @@ -0,0 +1,1047 @@ +//! Explicitly opted-in X11 development runtime. No notification carries replies. +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, +}; +use std::time::{Duration, Instant}; + +use bluer::{ + adv::Advertisement, + gatt::{ + local::{ + Application, Characteristic, CharacteristicRead, CharacteristicWrite, + CharacteristicWriteMethod, ReqError, Service, + }, + WriteOp, + }, + Address, DeviceEvent, DeviceProperty, +}; +use enigo::{Enigo, Mouse, Settings}; +use futures::StreamExt; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::input::{DesktopInput, InputInjector}; +use crate::linux_credential_worker::CredentialWorker; +use crate::linux_read_responses::{ReadError, ReadResponses, RESPONSE_TRANSPORT, RESPONSE_UUID}; +use crate::protocol::{ + bluetooth_status_payload, pointer_profile_response, EngineEvent, PointerProfile, +}; +use crate::state::{ + emit_state, now_ms, set_activity, AccessibilityState, ActivityKind, AppModel, AppState, + BluetoothState, PairedDeviceView, SharedModel, +}; +use crate::storage::AppStorage; + +const SERVICE: Uuid = Uuid::from_u128(0x7a78f7e8_1d6d_4d92_9ef0_1f89d3db21f4); +const RX: Uuid = Uuid::from_u128(0x7a78f7e9_1d6d_4d92_9ef0_1f89d3db21f4); +const STATUS: Uuid = Uuid::from_u128(0x7a78f7eb_1d6d_4d92_9ef0_1f89d3db21f4); +const ERROR: &str = "Linux Bluetooth session stopped. Check Bluetooth and the X11 session, then restart Switchify PC."; +const ALLOWED: &[&str] = &[ + "mouse.move", + "mouse.click", + "mouse.doubleClick", + "mouse.rightClick", + "mouse.scroll", + "mouse.dragStart", + "mouse.dragEnd", + "keyboard.key", + "keyboard.modifierDown", + "keyboard.modifierUp", + "keyboard.shortcut", + "keyboard.typeText", + "keyboard.textStream.open", + "keyboard.textStream.char", + "keyboard.textStream.chunk", + "keyboard.textStream.key", + "keyboard.textStream.close", + "media.control", + "connection.disconnecting", + "connection.ping", + "pointer.profile", +]; + +#[derive(Default)] +struct Gate { + peer: Option
, + generation: u64, + responses: ReadResponses, + touched: Option, +} +impl Gate { + fn close(&mut self) { + self.responses.close(); + self.peer = None; + self.generation = self.generation.wrapping_add(1); + self.touched = None; + } + fn claim(&mut self, peer: Address) -> Result { + if self.peer.is_some_and(|owner| owner != peer) { + return Err(ReqError::NotAuthorized); + } + self.responses + .open(peer.0) + .map_err(|_| ReqError::NotAuthorized)?; + self.peer = Some(peer); + self.touched = Some(Instant::now()); + Ok(self.generation) + } + fn enqueue(&mut self, generation: u64, message: &str) -> Result<(), ()> { + if generation != self.generation { + return Err(()); + } + let owner = self.peer.ok_or(())?; + let mailbox_generation = self.responses.open(owner.0).map_err(|_| ())?; + self.responses + .enqueue(mailbox_generation, message) + .map_err(|_| ()) + } +} + +enum Command { + Frame { + peer: Address, + generation: u64, + bytes: Vec, + done: oneshot::Sender>, + }, + Approve { + id: String, + generation: u64, + done: oneshot::Sender>, + }, + Reject { + id: String, + generation: u64, + }, + Forget { + id: String, + done: oneshot::Sender>, + }, +} +struct Handle { + sender: mpsc::Sender, + gate: Arc>, + stop: Arc, + finished: std::sync::mpsc::Receiver<()>, +} +static RUNTIME: OnceLock>> = OnceLock::new(); +fn runtime() -> &'static Mutex> { + RUNTIME.get_or_init(|| Mutex::new(None)) +} + +pub fn requested() -> bool { + std::env::var("SWITCHIFY_LINUX_EXPERIMENTAL").as_deref() == Ok("1") +} + +pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { + if std::env::var("XDG_SESSION_TYPE").as_deref() != Ok("x11") + || std::env::var_os("DISPLAY").is_none() + || std::env::var_os("WAYLAND_DISPLAY").is_some() + { + return Err( + "This experimental build requires a local X11 desktop session, not Wayland.".into(), + ); + } + let adapter = std::env::var("SWITCHIFY_LINUX_ADAPTER").unwrap_or_else(|_| "hci0".into()); + if !adapter.strip_prefix("hci").is_some_and(|suffix| { + !suffix.is_empty() && suffix.len() <= 4 && suffix.bytes().all(|b| b.is_ascii_digit()) + }) { + return Err("Select a valid hciN Bluetooth adapter.".into()); + } + let mut owner = runtime().lock().map_err(|_| ERROR)?; + if owner.is_some() { + return Err("Linux runtime has already started. Restart the application.".into()); + } + let (sender, receiver) = mpsc::channel(64); + let gate = Arc::new(Mutex::new(Gate::default())); + let stop = Arc::new(AtomicBool::new(false)); + let (finished_tx, finished) = std::sync::mpsc::channel(); + let thread_gate = gate.clone(); + let thread_stop = stop.clone(); + let thread_sender = sender.clone(); + std::thread::Builder::new() + .name("linux-runtime".into()) + .spawn(move || { + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| ()) + .and_then(|rt| { + rt.block_on(run( + &app, + &shared, + &adapter, + receiver, + thread_sender, + thread_gate.clone(), + thread_stop, + )) + }); + thread_gate.lock().unwrap().close(); + { + let mut data = shared.lock().unwrap(); + data.engine.reset_transport_session(); + data.state.pending_pairings.clear(); + data.state.connected_device_name = None; + data.state.bluetooth = BluetoothState::Error; + data.state.accessibility = AccessibilityState::Unavailable; + } + if result.is_err() { + set_activity(&shared, ActivityKind::Error, ERROR); + } + emit_state(&app, &shared); + let _ = finished_tx.send(()); + }) + .map_err(|_| ERROR)?; + *owner = Some(Handle { + sender, + gate, + stop, + finished, + }); + Ok(()) +} + +pub fn disconnect() { + if let Ok(owner) = runtime().lock() { + if let Some(handle) = owner.as_ref() { + handle.gate.lock().unwrap().close(); + } + } +} +pub fn shutdown() { + if let Ok(owner) = runtime().lock() { + if let Some(handle) = owner.as_ref() { + handle.gate.lock().unwrap().close(); + handle.stop.store(true, Ordering::SeqCst); + let _ = handle.finished.recv_timeout(Duration::from_secs(1)); + } + } +} +pub async fn approve(app: &AppHandle, id: String) -> Result { + let (done, result) = oneshot::channel(); + { + let owner = runtime().lock().map_err(|_| ERROR)?; + let handle = owner.as_ref().ok_or(ERROR)?; + let generation = handle.gate.lock().unwrap().generation; + handle + .sender + .try_send(Command::Approve { + id, + generation, + done, + }) + .map_err(|_| ERROR)?; + } + timeout(Duration::from_secs(5), result) + .await + .map_err(|_| ERROR)? + .map_err(|_| ERROR)??; + Ok(app.state::().snapshot()) +} +pub fn reject(id: &str) -> Result<(), String> { + let owner = runtime().lock().map_err(|_| ERROR)?; + let handle = owner.as_ref().ok_or(ERROR)?; + let generation = handle.gate.lock().unwrap().generation; + handle + .sender + .try_send(Command::Reject { + id: id.into(), + generation, + }) + .map_err(|_| ERROR.into()) +} + +pub async fn forget(app: &AppHandle, id: String) -> Result { + if id.is_empty() || id.len() > 128 { + return Err("Invalid device identifier.".into()); + } + let (done, result) = oneshot::channel(); + { + let owner = runtime().lock().map_err(|_| ERROR)?; + let handle = owner.as_ref().ok_or(ERROR)?; + handle.gate.lock().unwrap().close(); + handle + .sender + .try_send(Command::Forget { id, done }) + .map_err(|_| ERROR)?; + } + timeout(Duration::from_secs(5), result) + .await + .map_err(|_| ERROR)? + .map_err(|_| ERROR)??; + Ok(app.state::().snapshot()) +} + +fn cleanup(input: &mut DesktopInput, shared: &SharedModel) -> Result<(), ()> { + let released = input.release_all().map_err(|_| ()); + let mut data = shared.lock().unwrap(); + data.engine.reset_transport_session(); + data.state.pending_pairings.clear(); + data.state.connected_device_name = None; + data.state.bluetooth = BluetoothState::Advertising; + released +} + +async fn run( + app: &AppHandle, + shared: &SharedModel, + adapter_name: &str, + mut receiver: mpsc::Receiver, + sender: mpsc::Sender, + gate: Arc>, + stop: Arc, +) -> Result<(), ()> { + let session = timeout(Duration::from_secs(5), bluer::Session::new()) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + let adapter = session.adapter(adapter_name).map_err(|_| ())?; + let mut adapter_events = timeout(Duration::from_secs(3), adapter.events()) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + if !timeout(Duration::from_secs(3), adapter.is_powered()) + .await + .map_err(|_| ())? + .map_err(|_| ())? + { + return Err(()); + } + let enigo = Enigo::new(&Settings::default()).map_err(|_| ())?; + let (width, height) = enigo.main_display().map_err(|_| ())?; + let mut input = DesktopInput::new(enigo); + let mut credentials = + CredentialWorker::start(Arc::new(AppStorage::new()), Duration::from_secs(3)) + .map_err(|_| ())?; + let mut status: Value = serde_json::from_slice( + &bluetooth_status_payload( + "Switchify PC", + &shared.lock().unwrap().state.desktop_id, + "linux", + ) + .map_err(|_| ())?, + ) + .map_err(|_| ())?; + status["responseTransport"] = json!(RESPONSE_TRANSPORT); + let status = serde_json::to_vec(&status).map_err(|_| ())?; + let read_gate = gate.clone(); + let rx_gate = gate.clone(); + let application = Application { + services: vec![Service { + uuid: SERVICE, + primary: true, + characteristics: vec![ + Characteristic { + uuid: RX, + write: Some(CharacteristicWrite { + write: true, + write_without_response: true, + method: CharacteristicWriteMethod::Fun(Box::new(move |bytes, request| { + let sender = sender.clone(); + let gate = rx_gate.clone(); + Box::pin(async move { + if request.offset != 0 { + return Err(ReqError::InvalidOffset); + } + if request.prepare_authorize || request.op_type == WriteOp::Reliable + { + return Err(ReqError::NotSupported); + } + if bytes.is_empty() || bytes.len() > 512 { + return Err(ReqError::InvalidValueLength); + } + let generation = gate + .lock() + .map_err(|_| ReqError::Failed)? + .claim(request.device_address)?; + let (done, result) = oneshot::channel(); + if sender + .try_send(Command::Frame { + peer: request.device_address, + generation, + bytes, + done, + }) + .is_err() + { + gate.lock().map_err(|_| ReqError::Failed)?.close(); + return Err(ReqError::Failed); + } + match timeout(Duration::from_secs(4), result).await { + Ok(Ok(result)) => result, + _ => { + gate.lock().map_err(|_| ReqError::Failed)?.close(); + Err(ReqError::Failed) + } + } + }) + })), + ..Default::default() + }), + ..Default::default() + }, + Characteristic { + uuid: Uuid::parse_str(RESPONSE_UUID).map_err(|_| ())?, + read: Some(CharacteristicRead { + read: true, + fun: Box::new(move |request| { + let result = read_gate.lock().map_err(|_| ReqError::Failed).and_then( + |mut gate| { + let value = gate + .responses + .read(request.device_address.0, request.offset, request.mtu) + .map_err(|error| match error { + ReadError::Unauthorized => ReqError::NotAuthorized, + ReadError::InvalidOffset => ReqError::InvalidOffset, + _ => ReqError::Failed, + })?; + if gate.peer == Some(request.device_address) { + gate.touched = Some(Instant::now()); + } + Ok(value) + }, + ); + Box::pin(async move { result }) + }), + ..Default::default() + }), + ..Default::default() + }, + Characteristic { + uuid: STATUS, + read: Some(CharacteristicRead { + read: true, + fun: Box::new(move |request| { + let result = if request.mtu < 23 { + Err(ReqError::NotSupported) + } else { + status + .get(usize::from(request.offset)..) + .map(|tail| { + tail[..tail.len().min(usize::from(request.mtu) - 1)] + .to_vec() + }) + .ok_or(ReqError::InvalidOffset) + }; + Box::pin(async move { result }) + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }], + ..Default::default() + }; + let service = timeout( + Duration::from_secs(5), + adapter.serve_gatt_application(application), + ) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + let advertisement = timeout( + Duration::from_secs(5), + adapter.advertise(Advertisement { + service_uuids: [SERVICE].into_iter().collect(), + local_name: Some("Switchify PC Linux".into()), + discoverable: Some(true), + ..Default::default() + }), + ) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + { + let mut data = shared.lock().unwrap(); + data.state.bluetooth = BluetoothState::Advertising; + data.state.accessibility = AccessibilityState::Granted; + } + set_activity( + shared, + ActivityKind::Info, + "Experimental X11 control is ready. Use a Remote with Linux read-response support.", + ); + emit_state(app, shared); + let profile = PointerProfile { + display_id: "x11-primary".into(), + scale_factor: 1.0, + x: 0, + y: 0, + width: width as u32, + height: height as u32, + small_delta: 5, + medium_delta: 20, + large_delta: 60, + display_navigation_supported: false, + display_count: 1, + }; + let mut active: Option<(Address, u64)> = None; + let mut watch: Option> = None; + let mut tick = tokio::time::interval(Duration::from_millis(25)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut last_tick = now_ms(); + let result = async { + loop { + if stop.load(Ordering::SeqCst) { break; } + tokio::select! { + event = adapter_events.next() => { + match event { + None | Some(bluer::AdapterEvent::PropertyChanged(bluer::AdapterProperty::Powered(false))) => return Err(()), + Some(bluer::AdapterEvent::DeviceRemoved(peer)) => { + let mut gate = gate.lock().unwrap(); + if gate.peer == Some(peer) { gate.close(); } + }, + _ => (), + } + }, + _ = tick.tick() => { + let now = now_ms(); + let mut gate = gate.lock().unwrap(); + if now.saturating_sub(last_tick) > 3000 || gate.touched.is_some_and(|last| last.elapsed() > Duration::from_secs(2)) { gate.close(); } + last_tick = now; + let generation = gate.generation; + let mut data = shared.lock().unwrap(); + let expired: Vec<_> = data.engine.pending_pairings().into_iter().filter(|pending| now >= pending.expires_at).map(|pending| pending.request_id).collect(); + let changed = !expired.is_empty(); + for id in expired { + if let Some(response) = data.engine.expire_pairing(&id, now) { + if gate.enqueue(generation, &response).is_err() { gate.close(); } + } + } + data.state.pending_pairings = data.engine.pending_pairings(); + drop(data); drop(gate); + if changed { emit_state(app, shared); } + }, + command = receiver.recv() => { + let Some(command) = command else { break; }; + match command { + Command::Frame { peer, generation, bytes, done } => { + if gate.lock().unwrap().generation != generation { let _ = done.send(Err(ReqError::NotAuthorized)); continue; } + if active != Some((peer, generation)) { + cleanup(&mut input, shared)?; + if let Some(watch) = watch.take() { watch.abort(); } + let device = adapter.device(peer).map_err(|_| ())?; + let mut events = timeout(Duration::from_secs(2), device.events()).await.map_err(|_| ())?.map_err(|_| ())?; + if !timeout(Duration::from_secs(2), device.is_connected()).await.map_err(|_| ())?.map_err(|_| ())? { return Err(()); } + let watch_gate = gate.clone(); + watch = Some(tokio::spawn(async move { + while let Some(event) = events.next().await { + if matches!(event, DeviceEvent::PropertyChanged(DeviceProperty::Connected(false))) { break; } + } + let mut gate = watch_gate.lock().unwrap(); + if gate.generation == generation { gate.close(); } + })); + active = Some((peer, generation)); + } + // The watcher can invalidate during asynchronous setup. + let mut locked_gate = gate.lock().unwrap(); + if locked_gate.generation != generation { let _ = done.send(Err(ReqError::NotAuthorized)); continue; } + if now_ms().saturating_sub(last_tick) > 3000 { locked_gate.close(); let _ = done.send(Err(ReqError::Failed)); continue; } + let response = process_frame(&mut input, shared, &profile, &bytes, |connection| { + app.state::().record_authenticated_connection(&connection.device_id, connection.device_name.as_deref(), connection.connected_at, connection.received_order).unwrap_or(false) + }); + let outcome = match response { + Ok(Some(response)) => locked_gate.enqueue(generation, &response), + Ok(None) => Ok(()), + Err(()) => Err(()), + }; + if outcome.is_err() { locked_gate.close(); } + let _ = done.send(outcome.map_err(|_| ReqError::Failed)); + }, + Command::Approve { id, generation, done } => { + // No held input may remain while a native credential call is pending. + input.release_all().map_err(|_| ())?; + let outcome = if gate.lock().unwrap().generation != generation || done.is_closed() { Err(ERROR.into()) } else { + approve_inner(app, shared, &gate, &credentials, &id, || done.is_closed()).await + }; + if outcome.is_err() { gate.lock().unwrap().close(); } + let _ = done.send(outcome); + }, + Command::Reject { id, generation } => { + if gate.lock().unwrap().generation != generation { continue; } + let response = shared.lock().unwrap().engine.reject_pairing(&id); + if let Ok(response) = response { + let generation = gate.lock().unwrap().generation; + if gate.lock().unwrap().enqueue(generation, &response).is_err() { gate.lock().unwrap().close(); } + } + }, + Command::Forget { id, done } => { + cleanup(&mut input, shared)?; + shared.lock().unwrap().engine.forget_device(&id); + let outcome = match credentials.delete(&id).await { + Ok(()) => { + shared.lock().unwrap().state.paired_devices.retain(|device| device.device_id != id); + app.state::().persist() + }, + Err(_) => Err("Credential deletion failed. Unlock the desktop keyring and restart.".into()), + }; + let _ = done.send(outcome); + }, + } + let mut data = shared.lock().unwrap(); + data.state.pending_pairings = data.engine.pending_pairings(); + drop(data); + emit_state(app, shared); + } + } + if active.is_some_and(|(_, generation)| generation != gate.lock().unwrap().generation) { + credentials.invalidate(); + cleanup(&mut input, shared)?; + if let Some(watch) = watch.take() { watch.abort(); } + if let Some((peer, _)) = active.take() { let _ = timeout(Duration::from_secs(1), adapter.device(peer).map_err(|_| ())?.disconnect()).await; } + emit_state(app, shared); + } + } + Ok(()) + }.await; + gate.lock().unwrap().close(); + if let Some(watch) = watch { + watch.abort(); + } + credentials.shutdown(); + let cleanup_result = cleanup(&mut input, shared); + drop(advertisement); + drop(service); + result.and(cleanup_result) +} + +async fn approve_inner( + app: &AppHandle, + shared: &SharedModel, + gate: &Arc>, + credentials: &CredentialWorker, + id: &str, + cancelled: impl Fn() -> bool, +) -> Result<(), String> { + let generation = { + let gate = gate.lock().unwrap(); + gate.peer.ok_or(ERROR)?; + gate.generation + }; + let (pending, approval) = { + let mut data = shared.lock().unwrap(); + let pending = data + .engine + .pending_pairings() + .into_iter() + .find(|pending| pending.request_id == id) + .ok_or(ERROR)?; + let approval = data.engine.prepare_pairing(id, now_ms())?; + // Replacement writes can fail after committing. Never keep an old token + // active while persistence has an uncertain outcome. + data.engine.forget_device(&pending.device_id); + (pending, approval) + }; + credentials + .save(&approval.device_id, &approval.token) + .await + .map_err(|_| { + "Credential storage failed. Unlock the desktop keyring and restart Switchify PC." + })?; + if cancelled() || gate.lock().unwrap().generation != generation { + return Err(ERROR.into()); + } + let previous = { + let mut data = shared.lock().unwrap(); + let previous = data.state.paired_devices.clone(); + data.state + .paired_devices + .retain(|device| device.device_id != pending.device_id); + data.state.paired_devices.push(PairedDeviceView { + device_id: pending.device_id, + device_name: pending.device_name, + paired_at: now_ms(), + last_seen_at: None, + }); + previous + }; + if app.state::().persist().is_err() { + shared.lock().unwrap().state.paired_devices = previous; + return Err("Pairing metadata could not be saved. Restart and pair again.".into()); + } + let mut gate = gate.lock().unwrap(); + if cancelled() || gate.generation != generation { + return Err(ERROR.into()); + } + gate.enqueue(generation, &approval.response) + .map_err(|_| ERROR)?; + shared + .lock() + .unwrap() + .engine + .set_paired_token(approval.device_id, approval.token); + drop(gate); + emit_state(app, shared); + Ok(()) +} + +fn process_frame( + input: &mut DesktopInput, + shared: &SharedModel, + profile: &PointerProfile, + bytes: &[u8], + record_connection: impl FnOnce(&crate::protocol::AuthenticatedConnection) -> bool, +) -> Result, ()> { + let event = shared + .lock() + .unwrap() + .engine + .receive_frame(bytes, now_ms()) + .map_err(|_| ())?; + let settings = shared.lock().unwrap().state.settings.clone(); + input.set_pointer_scale_percent(settings.pointer_scale_percent); + let response = match event { + None => None, + Some(EngineEvent::PendingPairing { + replaced_response, .. + }) => { + let mut data = shared.lock().unwrap(); + data.state.pending_pairings = data.engine.pending_pairings(); + replaced_response + } + Some(EngineEvent::Response(response)) => { + if serde_json::from_str::(&response) + .ok() + .is_some_and(|value| { + matches!( + value["error"]["code"].as_str(), + Some( + "invalid_auth" + | "unknown_device" + | "expired_timestamp" + | "duplicate_request" + ) + ) + }) + { + return Err(()); + } + Some(response) + } + Some(EngineEvent::AuthenticatedConnection(connection)) => { + let saved = record_connection(&connection); + shared.lock().unwrap().state.bluetooth = BluetoothState::Connected; + Some( + shared + .lock() + .unwrap() + .engine + .complete_authenticated_connection(&connection, saved), + ) + } + Some(EngineEvent::PointerProfile(id)) => Some(linux_profile(&id, profile, &settings)), + Some(EngineEvent::Text(command)) => { + let result = input + .type_text(&command.text) + .map_err(|_| "X11 text input failed."); + let response = shared + .lock() + .unwrap() + .engine + .complete_text_command(&command, result); + if result.is_err() { + input.release_all().map_err(|_| ())?; + } + response + } + Some(EngineEvent::MouseMove(command)) => { + let result = input + .move_pointer(command.dx.round() as i32, command.dy.round() as i32) + .map_err(|_| "X11 pointer input failed."); + let response = shared + .lock() + .unwrap() + .engine + .complete_mouse_move_command(&command, result); + if result.is_err() { + input.release_all().map_err(|_| ())?; + } + response + } + Some(EngineEvent::MouseClick(command)) => { + let result = input + .click_pointer(command.button, command.click_count) + .map_err(|_| "X11 click failed."); + let response = shared + .lock() + .unwrap() + .engine + .complete_mouse_click_command(&command, result); + if result.is_err() { + input.release_all().map_err(|_| ())?; + } + response + } + Some(EngineEvent::Desktop(command)) => { + if !ALLOWED.contains(&command.command_type.as_str()) { + Some( + shared + .lock() + .unwrap() + .engine + .complete_desktop_command_with_error( + &command, + Err(( + "unsupported_command", + "This command is not enabled in the X11 development build.", + )), + ) + .ok_or(())?, + ) + } else { + let result = input + .execute( + &command.device_id, + &command.command_type, + &command.payload, + &[], + ) + .map(|_| ()) + .map_err(|_| "X11 command failed."); + let response = shared + .lock() + .unwrap() + .engine + .complete_desktop_command(&command, result); + if result.is_err() { + input.release_all().map_err(|_| ())?; + } + if command.command_type == "connection.disconnecting" { + return Err(()); + } + response + } + } + }; + Ok(response) +} + +fn linux_profile( + id: &str, + profile: &PointerProfile, + settings: &crate::state::AppSettings, +) -> String { + let mut value: Value = serde_json::from_str(&pointer_profile_response(id, profile, settings)) + .expect("internal profile JSON"); + let capabilities = &mut value["payload"]["capabilities"]; + capabilities["supportedCommands"] = json!(ALLOWED); + capabilities["noAckCommands"] = json!(ALLOWED + .iter() + .filter(|command| !matches!( + **command, + "connection.disconnecting" + | "connection.ping" + | "pointer.profile" + | "keyboard.textStream.open" + | "keyboard.textStream.close" + )) + .collect::>()); + for name in [ + "mouseRepeat", + "keyRepeat", + "pointerSpeed", + "displayNavigation", + ] { + capabilities[name]["supported"] = json!(false); + capabilities[name]["enabled"] = json!(false); + capabilities[name]["setSupported"] = json!(false); + } + value.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{create_frames, MouseButton}; + use base64::Engine; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + #[derive(Clone, Default)] + struct Fake(Arc>>); + impl InputInjector for Fake { + fn inject_text(&mut self, _: &str) -> Result<(), String> { + self.0.lock().unwrap().push("text"); + Ok(()) + } + fn move_pointer(&mut self, _: i32, _: i32) -> Result<(), String> { + self.0.lock().unwrap().push("move"); + Ok(()) + } + fn move_pointer_absolute(&mut self, _: i32, _: i32) -> Result<(), String> { + Ok(()) + } + fn click_pointer(&mut self, _: MouseButton, _: u8) -> Result<(), String> { + self.0.lock().unwrap().push("click"); + Ok(()) + } + fn set_pointer_button(&mut self, _: MouseButton, down: bool) -> Result<(), String> { + self.0 + .lock() + .unwrap() + .push(if down { "down" } else { "up" }); + Ok(()) + } + fn scroll(&mut self, _: i32, _: i32) -> Result<(), String> { + self.0.lock().unwrap().push("scroll"); + Ok(()) + } + fn set_key(&mut self, _: &str, down: bool) -> Result<(), String> { + self.0 + .lock() + .unwrap() + .push(if down { "key-down" } else { "key-up" }); + Ok(()) + } + fn press_shortcut(&mut self, _: &[String]) -> Result<(), String> { + self.0.lock().unwrap().push("shortcut"); + Ok(()) + } + fn media(&mut self, _: &str) -> Result<(), String> { + Ok(()) + } + fn window(&mut self, _: &str) -> Result<(), String> { + panic!("unsupported window action must not execute") + } + } + fn profile() -> PointerProfile { + PointerProfile { + display_id: "test".into(), + scale_factor: 1.0, + x: 0, + y: 0, + width: 1920, + height: 1080, + small_delta: 5, + medium_delta: 20, + large_delta: 60, + display_navigation_supported: false, + display_count: 1, + } + } + fn model() -> AppModel { + let model = AppModel::with_storage_for_test(AppStorage::at( + std::env::temp_dir().join(format!("switchify-live-test-{}.json", Uuid::new_v4())), + )); + model + .shared + .lock() + .unwrap() + .engine + .set_paired_token("fixture-device".into(), "fixture-token".into()); + model + } + fn signed(kind: &str, payload: Value) -> Value { + let id = Uuid::new_v4().to_string(); + let now = now_ms(); + let canonical = format!("1\n{id}\nfixture-device\n{now}\n{kind}\n{payload}\nack"); + let mut mac = Hmac::::new_from_slice(b"fixture-token").unwrap(); + mac.update(canonical.as_bytes()); + let auth = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + json!({ "version": 1, "id": id, "deviceId": "fixture-device", "timestamp": now, "type": kind, "payload": payload, "auth": auth }) + } + fn deliver( + input: &mut DesktopInput, + model: &AppModel, + message: Value, + ) -> Result, ()> { + let mut response = None; + for bytes in create_frames(&message.to_string()).unwrap() { + response = + process_frame(input, &model.shared, &profile(), &bytes, |_| true)?.or(response); + } + Ok(response) + } + + #[test] + fn authenticated_frames_drive_fake_input_and_cleanup_releases_drag() { + let model = model(); + let fake = Fake::default(); + let mut input = DesktopInput::new(fake.clone()); + for (kind, payload) in [ + ("keyboard.typeText", json!({"text":"public fixture"})), + ("mouse.move", json!({"dx":10,"dy":-5})), + ("mouse.dragStart", json!({"button":"left"})), + ("mouse.scroll", json!({"dx":0,"dy":1})), + ] { + let response: Value = serde_json::from_str( + &deliver(&mut input, &model, signed(kind, payload)) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(response["ok"], true, "{kind}"); + } + cleanup(&mut input, &model.shared).unwrap(); + assert_eq!( + *fake.0.lock().unwrap(), + ["text", "move", "down", "scroll", "up"] + ); + } + + #[test] + fn invalid_authentication_never_injects_and_unsupported_commands_are_honest() { + let model = model(); + let fake = Fake::default(); + let mut input = DesktopInput::new(fake.clone()); + let mut command = signed("keyboard.typeText", json!({"text":"public fixture"})); + command["auth"] = json!("invalid"); + assert!(deliver(&mut input, &model, command).is_err()); + let response: Value = serde_json::from_str( + &deliver( + &mut input, + &model, + signed("window.control", json!({"action":"minimize"})), + ) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(response["error"]["code"], "unsupported_command"); + assert!(fake.0.lock().unwrap().is_empty()); + } + + #[test] + fn profile_does_not_advertise_unimplemented_controls() { + let response: Value = serde_json::from_str(&linux_profile( + "fixture", + &profile(), + &crate::state::AppSettings::default(), + )) + .unwrap(); + let caps = &response["payload"]["capabilities"]; + assert_eq!(caps["supportedCommands"], json!(ALLOWED)); + for name in [ + "mouseRepeat", + "keyRepeat", + "pointerSpeed", + "displayNavigation", + ] { + assert_eq!(caps[name]["supported"], false); + } + assert!(!ALLOWED.contains(&"window.control")); + assert!(!ALLOWED.contains(&"switch.session.start")); + } + + #[test] + fn gate_prevents_peer_takeover_and_stale_publication() { + let mut gate = Gate::default(); + let a = Address([1; 6]); + let b = Address([2; 6]); + let old = gate.claim(a).unwrap(); + assert_eq!(gate.claim(b), Err(ReqError::NotAuthorized)); + gate.close(); + let new = gate.claim(b).unwrap(); + assert_ne!(old, new); + assert!(gate.enqueue(old, "public stale response").is_err()); + assert!(gate.responses.read(b.0, 0, 517).unwrap().is_empty()); + } +} diff --git a/src-tauri/src/linux_runtime.rs b/src-tauri/src/linux_runtime.rs index 3f48354a..061dae11 100644 --- a/src-tauri/src/linux_runtime.rs +++ b/src-tauri/src/linux_runtime.rs @@ -19,11 +19,15 @@ fn reset_session(shared: &SharedModel) { pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { reset_session(&shared); + if crate::linux_live::requested() { + return crate::linux_live::install(app, shared); + } emit_state(&app, &shared); Ok(()) } pub fn shutdown(_app: &AppHandle, shared: &SharedModel) { + crate::linux_live::shutdown(); reset_session(shared); } @@ -32,28 +36,32 @@ pub fn check_accessibility( shared: &SharedModel, _prompt: bool, ) -> Result<(), String> { + if crate::linux_live::requested() { + emit_state(app, shared); + return Ok(()); + } reset_session(shared); emit_state(app, shared); Ok(()) } -pub fn approve_pairing( - _app: &AppHandle, - _shared: &SharedModel, - _request_id: &str, -) -> Result<(), String> { - Err(UNAVAILABLE.into()) -} - pub fn reject_pairing( _app: &AppHandle, _shared: &SharedModel, _request_id: &str, ) -> Result<(), String> { + if crate::linux_live::requested() { + return crate::linux_live::reject(_request_id); + } Err(UNAVAILABLE.into()) } pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { + crate::linux_live::disconnect(); + if crate::linux_live::requested() { + emit_state(app, shared); + return Ok(()); + } reset_session(shared); emit_state(app, shared); Ok(()) diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index da7288db..71879609 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -205,6 +205,14 @@ pub struct ProtocolEngine { connection_order: u64, } +// Intentionally not Debug: approval contains credential material. Linux saves +// this before activating it or publishing its response. +pub struct PreparedPairing { + pub device_id: String, + pub token: String, + pub response: String, +} + impl ProtocolEngine { pub fn new(desktop_id: String) -> Self { Self { @@ -283,6 +291,16 @@ impl ProtocolEngine { } pub fn approve_pairing(&mut self, request_id: &str, now_ms: i64) -> Result { + let approval = self.prepare_pairing(request_id, now_ms)?; + self.tokens.insert(approval.device_id, approval.token); + Ok(approval.response) + } + + pub fn prepare_pairing( + &mut self, + request_id: &str, + now_ms: i64, + ) -> Result { let pending = self .pending_pairings .remove(request_id) @@ -294,8 +312,7 @@ impl ProtocolEngine { let mut token_bytes = [0_u8; 32]; OsRng.fill_bytes(&mut token_bytes); let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes); - self.tokens.insert(pending.device_id.clone(), token.clone()); - Ok(json!({ + let response = json!({ "version": PROTOCOL_VERSION, "id": pending.request_id, "type": "pairing.complete", @@ -307,7 +324,12 @@ impl ProtocolEngine { }, "error": Value::Null }) - .to_string()) + .to_string(); + Ok(PreparedPairing { + device_id: pending.device_id, + token, + response, + }) } pub fn reject_pairing(&mut self, request_id: &str) -> Result { @@ -1681,6 +1703,25 @@ mod tests { assert!(expired.pending_pairings().is_empty()); } + #[test] + fn prepared_pairing_does_not_activate_credentials_before_persistence() { + let mut engine = ProtocolEngine::new("desktop-1".into()); + engine + .process_message( + &pairing_request("pair-1", "android-1", "Phone", "nonce-1").to_string(), + NOW, + ) + .unwrap(); + let approval = engine.prepare_pairing("pair-1", NOW + 1).unwrap(); + assert!(engine.token_for("android-1").is_none()); + assert!(engine.pending_pairings().is_empty()); + let response: Value = serde_json::from_str(&approval.response).unwrap(); + assert_eq!(response["payload"]["token"], approval.token); + assert_eq!(approval.token.len(), 43); + engine.set_paired_token(approval.device_id, approval.token); + assert!(engine.token_for("android-1").is_some()); + } + #[test] fn pairing_requests_are_newest_first_and_actions_are_targeted() { let mut engine = ProtocolEngine::new("desktop-1".into()); diff --git a/src/settings/PointerSection.tsx b/src/settings/PointerSection.tsx index 4d42e271..55daf48d 100644 --- a/src/settings/PointerSection.tsx +++ b/src/settings/PointerSection.tsx @@ -6,7 +6,7 @@ import { type SettingsUpdate, } from "./controls"; -export function PointerSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { +export function PointerSection({ settings, update, basicOnly = false }: { settings: AppSettings; update: SettingsUpdate; basicOnly?: boolean }) { // The presets stop at 100%, so any value above that or off the 5/25/50/75 // steps is only reachable through the exact-speed select. Reveal it whenever // such a value is active, so the control that produced it is never hidden. @@ -14,7 +14,7 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up const [showExact, setShowExact] = useState(!isPreset); useEffect(() => { if (!isPreset) setShowExact(true); }, [isPreset]); const exactSpeedId = useId(); - return + return
Pointer speed {settings.pointerScalePercent}%
{pointerSpeedOptions.map((value) => )}
setShowExact(!showExact)} controls={exactSpeedId}> @@ -27,7 +27,8 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up
-
+ {basicOnly &&

Experimental X11 control supports basic input. Repeat, dwell and cursor overlays are not enabled.

} + {!basicOnly && <>
update("mouseRepeatEnabled", value)} />
legend="Movement interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.moveRepeatIntervalMs} onChange={(next) => update("moveRepeatIntervalMs", next)} /> @@ -50,5 +51,6 @@ export function PointerSection({ settings, update }: { settings: AppSettings; up note={{ summary: "After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it." }} />
+ } ; } diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index ed22df61..5fc11c5f 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -58,7 +58,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat {active === "general" && } {active === "pointer" && (linuxInputUnavailable(state) ?

Settings will become available when Linux input support is enabled.

- : )} + : )} {active === "cursor" && } {active === "privacy" && } {active === "updates" && } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index 09eae9c1..93efde87 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -49,6 +49,19 @@ describe("Switchify PC settings", () => { expect(save).not.toHaveBeenCalled(); }); + it("shows only implemented controls when experimental X11 input is ready", async () => { + browserState.capabilities = { ...defaultCapabilities, platform: "linux", cursorOverlay: false }; + browserState.bluetooth = "advertising"; + browserState.accessibility = "granted"; + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Controls"); + expect(screen.getByRole("button", { name: "50% pointer speed" })).toBeInTheDocument(); + for (const name of ["Repeat mouse movement", "Repeat held keys", "Dwell to click"]) expect(screen.queryByRole("checkbox", { name })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Cursor appearance" })).not.toBeInTheDocument(); + }); + it("shows update progress and exposes cancellation in Settings", async () => { browserState.updater = { status: "downloading", version: "1.0.0-beta.2", downloadedBytes: 50, totalBytes: 200, error: null, retryAction: null }; const cancel = vi.spyOn(api, "cancelUpdateDownload").mockResolvedValue(structuredClone(browserState)); From c90e42c8f789b9abac3181cb942025a043a2925f Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:46:48 +0100 Subject: [PATCH 5/5] Bind pairing approval to its originating active session --- src-tauri/src/linux_live.rs | 39 +++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/linux_live.rs b/src-tauri/src/linux_live.rs index 3688e61d..c23035d5 100644 --- a/src-tauri/src/linux_live.rs +++ b/src-tauri/src/linux_live.rs @@ -72,6 +72,11 @@ struct Gate { touched: Option, } impl Gate { + fn owns_active(&self, active: Option<(Address, u64)>, generation: u64) -> bool { + active.is_some_and(|(peer, origin)| { + origin == generation && self.generation == origin && self.peer == Some(peer) + }) + } fn close(&mut self) { self.responses.close(); self.peer = None; @@ -558,14 +563,14 @@ async fn run( Command::Approve { id, generation, done } => { // No held input may remain while a native credential call is pending. input.release_all().map_err(|_| ())?; - let outcome = if gate.lock().unwrap().generation != generation || done.is_closed() { Err(ERROR.into()) } else { - approve_inner(app, shared, &gate, &credentials, &id, || done.is_closed()).await + let outcome = if !gate.lock().unwrap().owns_active(active, generation) || done.is_closed() { Err(ERROR.into()) } else { + approve_inner(app, shared, &gate, &credentials, &id, generation, || done.is_closed()).await }; if outcome.is_err() { gate.lock().unwrap().close(); } let _ = done.send(outcome); }, Command::Reject { id, generation } => { - if gate.lock().unwrap().generation != generation { continue; } + if !gate.lock().unwrap().owns_active(active, generation) { continue; } let response = shared.lock().unwrap().engine.reject_pairing(&id); if let Ok(response) = response { let generation = gate.lock().unwrap().generation; @@ -618,13 +623,16 @@ async fn approve_inner( gate: &Arc>, credentials: &CredentialWorker, id: &str, + generation: u64, cancelled: impl Fn() -> bool, ) -> Result<(), String> { - let generation = { + { let gate = gate.lock().unwrap(); gate.peer.ok_or(ERROR)?; - gate.generation - }; + if gate.generation != generation { + return Err(ERROR.into()); + } + } let (pending, approval) = { let mut data = shared.lock().unwrap(); let pending = data @@ -1044,4 +1052,23 @@ mod tests { assert!(gate.enqueue(old, "public stale response").is_err()); assert!(gate.responses.read(b.0, 0, 517).unwrap().is_empty()); } + + #[test] + fn approval_after_disconnect_cannot_relabel_old_pending_request_for_replacement_peer() { + let mut gate = Gate::default(); + let a = Address([1; 6]); + let b = Address([2; 6]); + let original = gate.claim(a).unwrap(); + let active = Some((a, original)); // Engine pending request belongs to A. + assert!(gate.owns_active(active, original)); + gate.close(); // Engine cleanup has not run yet. + let clicked_generation = gate.generation; // UI queues approval of A's old request. + gate.claim(b).unwrap(); // B claims before the actor consumes that approval. + assert!(!gate.owns_active(active, clicked_generation)); + assert!(!gate.owns_active(active, original)); + assert!(!gate.owns_active(None, clicked_generation)); + assert!(gate.responses.read(b.0, 0, 517).unwrap().is_empty()); + // Only after actor cleanup and processing B's own first frame may B approve. + assert!(gate.owns_active(Some((b, clicked_generation)), clicked_generation)); + } }