From ee8074b4370f113455503d525509bb20a22d92f6 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:45:18 +0100 Subject: [PATCH 01/10] docs: plan Linux support implementation (#710) --- docs/linux-support-plan.md | 138 +++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/linux-support-plan.md diff --git a/docs/linux-support-plan.md b/docs/linux-support-plan.md new file mode 100644 index 00000000..734d54a3 --- /dev/null +++ b/docs/linux-support-plan.md @@ -0,0 +1,138 @@ +# Linux implementation plan + +Tracking issue: [#710](https://github.com/switchifyapp/switchify-pc/issues/710) (planning only). +Source baseline: `d222639`, version `1.0.0-rc.6`. +Status: proposed implementation sequence; no Linux runtime support is delivered by this document. + +## Goal and support boundaries + +Enable the existing Switchify Android client to securely pair with and control a Linux desktop. Preserve the product identity, protocol v1, pairing approval, persisted settings, and existing macOS and Windows behavior. + +The proposed first supported configuration is Ubuntu 24.04 x86_64 with an X11 desktop session, BlueZ, a Bluetooth adapter that successfully supports connectable LE advertising, and an available persistent desktop credential store. Treat Debian as a subsequent validation target until an exact release and desktop have passed the matrix below. A successful Linux build alone does not qualify a platform as supported. + +The first public milestone includes pairing, reconnection, text and keyboard input, pointer movement, clicks, dragging, scrolling, switch profiles, repeat controls, diagnostics, startup, and recoverable background operation. Cursor feedback and dwell must either pass their acceptance tests or be explicitly disabled in UI and wire capabilities. Grid 3 and Windows UIAccess stay unavailable. Window management commands and display navigation are exposed only after their desktop-specific behavior is validated. + +Native Wayland support is a separate milestone. A working XWayland window does not establish control of native Wayland applications. Do not label the first milestone as general Linux support without stating the X11 requirement. ARM64, Flatpak, Snap, RPM, and broad distro coverage follow independently. + +## Existing foundation and gaps + +| Area | Current source | Implementation implication | +| --- | --- | --- | +| Runtime dispatch | [lib.rs](../src-tauri/src/lib.rs), [macos.rs](../src-tauri/src/macos.rs), [windows_runtime.rs](../src-tauri/src/windows_runtime.rs) | Add Linux implementations for installation, shutdown, access checks, pairing approval/rejection, disconnect, repeats, and dwell. Runtime hooks currently exist only for macOS and Windows. | +| BLE and protocol | [protocol.rs](../src-tauri/src/protocol.rs), [ble_lifecycle.rs](../src-tauri/src/ble_lifecycle.rs) | Reuse authentication, framing and recovery machinery. Add a BlueZ server transport; selectively expose reusable items currently gated to macOS/tests. | +| Input | [input.rs](../src-tauri/src/input.rs), [Cargo.toml](../src-tauri/Cargo.toml) | `InputInjector` and `DesktopInput` provide a seam for Linux. Enigo 0.6.1 is pinned; generic injection exists, but Linux window actions return an error. | +| Visual feedback | [overlay.rs](../src-tauri/src/overlay.rs), [modifier_overlay.rs](../src-tauri/src/modifier_overlay.rs), [display_navigation.rs](../src-tauri/src/display_navigation.rs) | Cursor overlay lacks a Linux module. Modifier overlay has a generic webview path requiring runtime verification. Display calculations can be reused after coordinate validation. | +| Capabilities and UI | [state.rs](../src-tauri/src/state.rs), [App.tsx](../src/App.tsx), [api.ts](../src/api.ts) | Linux exists as a capability value with overlays/navigation disabled. Replace binary platform assumptions, setup text, sample state and unconditional feature claims. | +| Persistence/startup | [storage.rs](../src-tauri/src/storage.rs), [lib.rs](../src-tauri/src/lib.rs) | Non-macOS token storage uses keyring; verify the actual pinned Linux backend and persistence. Autostart has a generic non-Windows path. | +| CI and release | [ci.yml](../.github/workflows/ci.yml), [release-tauri.yml](../.github/workflows/release-tauri.yml), [create-update-feed.mjs](../scripts/create-update-feed.mjs) | Native CI covers Windows/macOS only. Release artifact selection and updater keys assume those two platforms. | + +Static inspection identifies missing runtime and overlay symbols on Linux. The initial assessment did not execute a native build because Cargo was unavailable in that environment; milestone 1 must establish the complete compiler/dependency baseline. + +## Ordered implementation milestones + +Each milestone becomes a separate implementation issue and focused branch from current `main`, with a draft PR, validation evidence, and independent review under [AGENTS.md](../AGENTS.md). Closing #710 accepts the plan only. Do not treat it as completion of Linux support. Keep Linux disabled for public releases until the release gates pass. + +### 1. Establish a Linux build and test baseline + +- Install Node.js 24 and Rust 1.97.1 and resolve the Linux development libraries required by the pinned Tauri, Enigo and credential dependencies, including WebKitGTK 4.1 and AppIndicator integration. +- Add `linux_runtime.rs` and a Linux cursor-overlay module or explicit unavailable implementation behind `cfg(target_os = "linux")`. Make unsupported features safe at startup and correctly represented in capabilities; do not grant input access through a stub. +- Audit all non-Windows/non-macOS branches, including shutdown, modifier overlay initialization, tray availability, and unsupported window actions. Optional overlay or tray failure must not abort the usable main window. +- Add Ubuntu native build, Clippy and tests to CI. Use injected fake adapters; a headless test job must not control the runner's input or need physical Bluetooth hardware. +- Extract shared runtime command/session logic only where needed for the Linux backend; test existing platform behavior before moving it. + +Exit: Linux compiles and launches its UI with honest unavailable states; existing native jobs remain green. Record exact build/runtime packages and the tested desktop session. + +### 2. Prove BlueZ peripheral interoperability + +- Evaluate a maintained Rust BlueZ server binding versus direct D-Bus integration against the required APIs before selecting a dependency. Central-only scanning libraries are insufficient. +- Implement local GATT service registration and connectable advertising using the existing service, RX, TX and status UUIDs and characteristic properties. +- Prototype reads, writes, offsets, write-without-response, notification subscription, fragmentation and negotiated MTU behavior using the existing Android app and at least two BLE adapters. +- Establish how incoming requests identify a peer and how outbound replies are delivered. BlueZ subscription notifications may not provide the same per-central targeting as other backends: prove that a pairing token or response cannot be delivered to an unintended subscriber. If targeted delivery cannot be guaranteed, enforce a tested single-peer policy before returning sensitive data. +- Record ordinary-user D-Bus permissions, missing adapter/daemon behavior, advertising limits, rfkill and powered-off behavior. Do not run the UI as root or assume every Bluetooth adapter can advertise. + +Exit: Android discovers the desktop and exchanges the existing framed protocol through BlueZ. Document peer/subscription isolation, adapter requirements and the chosen transport library. This is the first technical go/no-go checkpoint. + +### 3. Integrate secure sessions, storage and recovery + +- Route the BlueZ transport through the existing protocol engine. Preserve canonical JSON authentication, frame/queue limits, timestamps, replay protection, constant-time comparison, pairing approval, and sanitized errors. +- Serialize transport events and input operations with an explicit runtime owner. Invalidate stale callbacks and queued notifications using session/recovery generations. Avoid holding the shared model lock over D-Bus operations. +- Implement bounded notification backpressure and error handling. Reuse recovery scheduling for radio changes, adapter removal, daemon restart and suspend/resume; prevent duplicate service registrations or advertisements. +- Make disconnect, unsubscribe, authentication failure, timeout, device forgetting, suspend and application exit cancel repeats/dwell and release held keys/buttons. Test cleanup after partial injection failures. +- Verify a persistent Linux credential backend in the pinned keyring version. Handle absent/locked stores with actionable state, preserve saved pairing records on storage failure, and avoid silently accepting a pairing that cannot persist. Test restart and logout/login; do not silently fall back to plaintext or volatile storage. +- Retain single-instance behavior and diagnose conflicts without removing another process's advertisements or unrelated Bluetooth pairings. + +Exit: secure pairing survives restart; all teardown paths clean up deterministically; repeated power/daemon disruptions recover without stale commands. Authentication and storage regression tests pass across platforms. + +### 4. Deliver and validate X11 input + +- Instantiate Enigo through the existing input abstraction and report access according to usable session/backend state. Detect a Wayland session explicitly and show the X11 requirement until milestone 7 passes. +- Validate text, shortcuts, modifier latching, switch press/release, pointer scale, dragging, both scroll axes, media controls, mouse/key repeat and disconnect cleanup. Cover Unicode, non-US layouts, physical modifier interaction and repeated identical keys. +- Implement Linux window commands only for documented desktop mappings, or expose unsupported results consistently. Audit Android supported-command lists as well as desktop controls. Add backward-compatible capability tests for any protocol additions; inspect Android consumption before promising that no client change is necessary. +- Wire repeat cancellation and dwell through real Linux runtime hooks. Keep unsupported controls disabled rather than accepting operations with no effect. + +Exit: existing Android control works in real X11 applications under a normal user, with no stuck input after forced disconnect. Every advertised command has automated adapter coverage and a recorded manual result. + +### 5. Add feedback and desktop integration + +- Reuse shared cursor rendering for a Linux overlay. Verify transparency, click-through, non-focusability, stacking, fullscreen/workspace behavior and deterministic hiding on session end. +- Test the generic modifier overlay and disable it gracefully if unsupported. Never allow a feedback window to steal keyboard focus. +- Verify pointer position and monitor coordinates, including negative origins, scaling, hotplug and display navigation. Enable capabilities only after validation. Dwell must have reliable pointer tracking and visible progress/cancellation feedback before being enabled. +- Validate tray menu access, close-to-background and start-hidden behavior on the selected desktop. Provide an accessible recovery path when no tray host is present so closing the window does not strand the user. +- Test startup registration with installed paths, moved AppImages, duplicate instances and disabled startup. Update setup/support text with Bluetooth, credential-store and session-specific remediation. + +Exit: supported desktop integration works without focus theft; missing optional components leave a usable app; capabilities match measured support. + +### 6. Package and release the X11 milestone + +- Produce an x86_64 `.deb` and AppImage with the existing identity and Linux-specific dependency metadata. Build on the oldest selected supported base with compatible WebKitGTK/glibc; test installation on clean machines. +- Use signed Tauri updater artifacts for the AppImage path after validating the pinned updater plugin's Linux format and restart behavior. `.deb` installations should use package-managed/manual package upgrades and must not attempt to replace themselves with an AppImage. +- Extend artifact discovery, signature/format verification, feed tests and release workflow with `linux-x86_64` only when the Linux release gate is enabled. Preserve existing Windows/macOS keys and legacy release metadata. +- Publish the combined feed only after every platform required for that release passes verification. Before Linux is enabled, Linux experimentation must not block existing releases. +- Test fresh install, pairing persistence through upgrade, cancelled download, invalid signature, interrupted update, unwritable installation path, clean restart and recovery using a previously published package. Uninstall must not unexpectedly erase pairing/settings data. +- Document exact supported OS, desktop/session, architecture and Bluetooth prerequisites. Keep ARM and additional formats out of the first release claim. + +Exit: install and upgrade succeed on clean supported machines, signed updater checks pass, and the complete X11 acceptance matrix has evidence attached to the release issue. + +### 7. Qualify Wayland as a separate support milestone + +- Prototype consent-based RemoteDesktop portal/libei input for GNOME and KDE, recording exact compositor and portal versions. Evaluate text/layout fidelity, relative/absolute movement, coordinate mapping, session restoration, permission denial and logout/suspend behavior. +- Evaluate compositor-specific protocols only as explicitly scoped alternatives. Enigo's experimental backends are feasibility starting points, not a support guarantee. +- If a kernel `uinput` helper is necessary, write a separate architecture/security decision covering authenticated local IPC, minimum permissions, active-seat/session restrictions, lock-screen behavior, bounded command validation, cleanup, helper updates and uninstall. Avoid world-writable device access and a root UI. A virtual input device does not by itself solve global pointer observation, monitor navigation or overlays. +- Independently qualify pointer observation and overlay positioning. Keep unavailable feedback/navigation features disabled on compositors that cannot provide them; require suitable feedback before enabling dwell. +- Test native Wayland and XWayland applications, denied/revoked permission, startup without a restored session, remote/local session boundaries, and lock/logout transitions. Publish a compositor-specific feature matrix. + +Exit: choose and document the supported backend and permission UX, then enable only the compositor/features proven by the matrix. If no acceptable route passes, retain the explicit X11 support boundary and record the unresolved requirements. + +## Validation matrix and release evidence + +| Dimension | Required evidence | +| --- | --- | +| Transport hardware | At least two independent advertising-capable adapters and two Android devices; discovery, pairing, small MTU, subscription churn, queue saturation, disconnect and reconnect. | +| Trust boundaries | Unapproved/invalid/replayed commands rejected; competing subscribers cannot receive secrets; no typed text, tokens, signatures or raw payloads in diagnostics. | +| Lifecycle | Bluetooth off/on, rfkill, adapter removal, daemon restart, suspend/resume, app exit/crash and device forgetting; verify no stuck keys/buttons or replayed queued input. | +| Input | X11 real applications, Unicode and multiple keyboard layouts, modifiers, media, scrolling, dragging and repeat controls; fake injectors for automated coverage. | +| Desktop | Selected X11 desktop with/without tray host, multiple monitors/scales, negative monitor coordinates, fullscreen, workspace changes and autostart. | +| Persistence | Available, absent and locked credential store; failed writes; restart/logout; update preserves identity, settings and pairings. | +| Distribution | Clean Ubuntu 24.04 x86_64 install, `.deb` upgrade, signed AppImage update/restart, failure recovery. Additional releases/desktops require their own recorded results. | +| Existing clients | Protocol fixtures and current Android interoperability; unchanged behavior for Windows/macOS; older capability consumers handle additions safely. | +| Wayland (later) | Exact GNOME/KDE and portal versions, native apps, consent denial/revocation, session restoration, lock/logout and per-feature limitations. | + +Run the repository-required frontend lint/tests/build and Rust format/Clippy/tests with the pinned toolchains for implementation PRs, plus Linux native bundling. Use mocked D-Bus, credentials and input for CI, and an explicitly operated physical test system for hardware checks. Repeat existing Windows and macOS validation for shared-code changes. Independent review must cover the latest PR head; re-review after fixes. Do not merge without user instruction. + +## Sequencing, estimates and decisions + +Milestone 1 precedes 2; 2 precedes secure transport integration in 3; 3 and 4 must both pass before end-to-end qualification in 5 and release work in 6. The Wayland feasibility investigation can begin after the input seam in 4 exists, but it does not gate the explicitly X11 release. + +Planning estimates for one experienced Rust/Linux engineer are 1–2 weeks for a hardware prototype, 4–6 weeks total for a constrained X11 milestone, and 8–12+ weeks total for production qualification including selected Wayland desktops. These are provisional ranges, not commitments. Re-estimate after the BlueZ peer-isolation prototype and Wayland input/overlay tests; hardware access and upstream limitations can dominate elapsed time. + +Before expanding release scope, record decisions on the exact desktop matrix, Bluetooth adapter requirements, credential backend, per-peer notification safety, update ownership by package format, and Wayland permissions. Create implementation issues as work is scheduled rather than assuming all later milestones are already approved for release. + +## Technical references + +Verify these APIs against the chosen dependency and OS versions during implementation: + +- [BlueZ local GATT registration](https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattManager.rst) +- [BlueZ advertising API](https://github.com/bluez/bluez/blob/master/doc/org.bluez.LEAdvertisement.rst) +- [Enigo 0.6.1 Linux backends and limitations](https://docs.rs/crate/enigo/0.6.1) +- [Tauri Linux prerequisites](https://v2.tauri.app/start/prerequisites/) +- [Tauri AppImage distribution constraints](https://v2.tauri.app/distribute/appimage/) From 1b2b04a3392ef0926dd59923c1f8b206100a1d22 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:10:00 +0100 Subject: [PATCH 02/10] feat: establish Linux development runtime and native CI (#712) --- .github/workflows/ci.yml | 10 ++ src-tauri/src/lib.rs | 60 ++++++++++-- src-tauri/src/linux_runtime.rs | 91 +++++++++++++++++++ src-tauri/src/modifier_overlay_unavailable.rs | 37 ++++++++ src-tauri/src/overlay.rs | 11 +++ 5 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/linux_runtime.rs create mode 100644 src-tauri/src/modifier_overlay_unavailable.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31b257d6..5bab4d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,8 @@ jobs: bundles: nsis - os: macos-14 bundles: app,dmg + - os: ubuntu-24.04 + bundles: none runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -111,6 +113,11 @@ jobs: with: toolchain: 1.97.1 components: rustfmt, clippy + - name: Install Linux development libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libxkbcommon-dev libdbus-1-dev - run: npm ci - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings @@ -126,6 +133,9 @@ jobs: run: cargo test --locked --manifest-path src-tauri/startup-launcher/Cargo.toml - if: runner.os == 'macOS' run: npm run tauri build -- --bundles ${{ matrix.bundles }} --no-sign + - name: Build Linux development application + if: runner.os == 'Linux' + run: npm run tauri build -- --debug --no-bundle - if: runner.os == 'Windows' run: pwsh ./scripts/Build-WindowsUiAccess.ps1 -SkipSign - if: runner.os == 'Windows' diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68bac917..1f90df31 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,19 +1,29 @@ +#[cfg_attr(target_os = "linux", allow(dead_code))] // Used when the Linux transport is wired. mod ble_lifecycle; mod diagnostics; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod display_navigation; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod dwell; #[cfg(target_os = "windows")] mod grid3; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod input; +#[cfg(target_os = "linux")] +mod linux_runtime; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] mod macos_overlay_window; #[cfg(target_os = "macos")] mod macos_relaunch; +#[cfg_attr(target_os = "linux", path = "modifier_overlay_unavailable.rs")] mod modifier_overlay; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod mouse_repeat; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod overlay; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod protocol; mod state; mod storage; @@ -28,11 +38,21 @@ mod windows_security; #[cfg(target_os = "windows")] 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, }; use std::sync::Mutex; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::menu::MenuItem; +#[cfg(not(target_os = "linux"))] +use tauri::menu::{Menu, PredefinedMenuItem}; +#[cfg(not(target_os = "linux"))] use tauri::tray::TrayIconBuilder; #[cfg(target_os = "windows")] use tauri::tray::{MouseButton, TrayIconEvent}; @@ -107,6 +127,7 @@ fn request_profile_exit(app: &AppHandle, action: ProfileExitAction) { const NAVIGATE_REQUESTED_EVENT: &str = "navigate-requested"; +#[cfg(not(target_os = "linux"))] fn show_tray_menu_on_left_click() -> bool { cfg!(target_os = "macos") } @@ -130,6 +151,7 @@ impl PendingNavigation { } } +#[cfg(not(target_os = "linux"))] fn show_main_window(app: &AppHandle, destination: Option<&str>) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); @@ -1126,6 +1148,7 @@ fn export_diagnostics(model: State<'_, AppModel>) -> Result { Ok(model.snapshot()) } +#[cfg(not(target_os = "linux"))] fn install_tray(app: &mut tauri::App) -> tauri::Result<()> { let show = MenuItem::with_id(app, "show", "Show Switchify PC", true, None::<&str>)?; let settings = MenuItem::with_id(app, "settings", "Open settings", true, None::<&str>)?; @@ -1231,7 +1254,7 @@ pub fn run() { let modifier_overlay_shared = shared.clone(); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, args, _| { - if has_start_hidden_argument(&args) { + if should_start_hidden(&args) { return; } if let Some(window) = app.get_webview_window("main") { @@ -1250,6 +1273,7 @@ pub fn run() { .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { + #[cfg(not(target_os = "linux"))] install_tray(app)?; if updater_is_configured(app.config().plugins.0.get("updater")) { let model = app.state::(); @@ -1310,7 +1334,7 @@ pub fn run() { _ => {} } } - if has_start_hidden_argument(&std::env::args().collect::>()) { + if should_start_hidden(&std::env::args().collect::>()) { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); } @@ -1329,7 +1353,7 @@ pub fn run() { } if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - request_profile_exit(window.app_handle(), ProfileExitAction::Hide); + request_profile_exit(window.app_handle(), close_window_action()); } }) .invoke_handler(tauri::generate_handler![ @@ -1365,6 +1389,19 @@ fn has_start_hidden_argument(args: &[String]) -> bool { args.iter().any(|argument| argument == "--start-hidden") } +fn should_start_hidden(args: &[String]) -> bool { + // Linux has no qualified tray integration yet; always keep a recovery window. + !cfg!(target_os = "linux") && has_start_hidden_argument(args) +} + +fn close_window_action() -> ProfileExitAction { + if cfg!(target_os = "linux") { + ProfileExitAction::Quit + } else { + ProfileExitAction::Hide + } +} + #[cfg(target_os = "macos")] fn platform_install(app: AppHandle, shared: state::SharedModel) -> Result<(), String> { macos::install(app, shared) @@ -1441,15 +1478,24 @@ fn platform_disconnect_all(app: &AppHandle, shared: &state::SharedModel) -> Resu #[cfg(test)] mod tests { + #[cfg(not(target_os = "linux"))] + use super::show_tray_menu_on_left_click; use super::{ - has_start_hidden_argument, record_update_failure, show_tray_menu_on_left_click, - updater_is_configured, validate_profile, PendingNavigation, PendingProfileExit, - ProfileExitAction, TraySnapshot, NAVIGATE_REQUESTED_EVENT, + has_start_hidden_argument, record_update_failure, updater_is_configured, validate_profile, + PendingNavigation, PendingProfileExit, ProfileExitAction, TraySnapshot, + NAVIGATE_REQUESTED_EVENT, }; use crate::state::{AppModel, BluetoothState, SwitchBinding, SwitchProfile}; use crate::storage::AppStorage; use serde_json::json; + #[cfg(target_os = "linux")] + #[test] + fn linux_without_tray_cannot_be_stranded_in_background() { + assert!(!super::should_start_hidden(&["--start-hidden".into()])); + assert_eq!(super::close_window_action(), ProfileExitAction::Quit); + } + fn custom_profile() -> SwitchProfile { SwitchProfile { id: "3a393675-6434-4e50-a62f-d85ac24bcdf5".into(), diff --git a/src-tauri/src/linux_runtime.rs b/src-tauri/src/linux_runtime.rs new file mode 100644 index 00000000..3f48354a --- /dev/null +++ b/src-tauri/src/linux_runtime.rs @@ -0,0 +1,91 @@ +//! Development foundation. No Bluetooth service, input device or permission +//! prompt is opened until the Linux adapters have been implemented and tested. +use tauri::AppHandle; + +use crate::state::{emit_state, AccessibilityState, BluetoothState, SharedModel}; + +const UNAVAILABLE: &str = "Bluetooth control is not implemented in this Linux development build."; + +fn reset_session(shared: &SharedModel) { + let mut data = shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + data.engine.reset_transport_session(); + data.state.pending_pairings.clear(); + data.state.connected_device_name = None; + data.state.bluetooth = BluetoothState::Unsupported; + data.state.accessibility = AccessibilityState::Unavailable; +} + +pub fn install(app: AppHandle, shared: SharedModel) -> Result<(), String> { + reset_session(&shared); + emit_state(&app, &shared); + Ok(()) +} + +pub fn shutdown(_app: &AppHandle, shared: &SharedModel) { + reset_session(shared); +} + +pub fn check_accessibility( + app: &AppHandle, + shared: &SharedModel, + _prompt: bool, +) -> Result<(), String> { + 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> { + Err(UNAVAILABLE.into()) +} + +pub fn disconnect_all(app: &AppHandle, shared: &SharedModel) -> Result<(), String> { + reset_session(shared); + emit_state(app, shared); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AppModel; + use crate::storage::AppStorage; + + #[test] + fn unavailable_runtime_clears_session_but_preserves_pairing_credentials() { + let root = std::env::temp_dir().join(format!("switchify-linux-{}", uuid::Uuid::new_v4())); + let model = AppModel::with_storage_for_test(AppStorage::at(root.join("state.json"))); + { + let mut data = model.shared.lock().unwrap(); + data.engine + .set_paired_token("test-device".into(), "test-token".into()); + data.state.bluetooth = BluetoothState::Connected; + data.state.accessibility = AccessibilityState::Granted; + data.state.connected_device_name = Some("Test device".into()); + } + reset_session(&model.shared); + reset_session(&model.shared); + let data = model.shared.lock().unwrap(); + assert_eq!(data.state.bluetooth, BluetoothState::Unsupported); + assert_eq!(data.state.accessibility, AccessibilityState::Unavailable); + assert!(data.state.connected_device_name.is_none()); + assert!(data.state.pending_pairings.is_empty()); + assert_eq!(data.engine.token_for("test-device"), Some("test-token")); + drop(data); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/modifier_overlay_unavailable.rs b/src-tauri/src/modifier_overlay_unavailable.rs new file mode 100644 index 00000000..d68ba8ce --- /dev/null +++ b/src-tauri/src/modifier_overlay_unavailable.rs @@ -0,0 +1,37 @@ +//! Linux deliberately creates no feedback window until overlay support is qualified. +use serde::Serialize; + +use crate::input::ModifierKey; +use crate::state::SharedModel; + +// Keep the input seam usable by the shared fake-injector tests on Linux. +#[allow(dead_code)] +pub trait ModifierKeyOverlayNotifier: Send + Sync { + fn set_active_modifiers(&self, active_modifiers: &[ModifierKey]); + fn end_control_session(&self); +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModifierOverlaySnapshot { + pub revision: u64, + pub labels: Vec, +} + +pub struct ModifierOverlay; + +impl ModifierOverlay { + pub fn install(_app: tauri::AppHandle, _shared: SharedModel) -> Result { + Ok(Self) + } + + pub fn ready(&self, _window_label: &str) -> Result { + Err("Modifier feedback is unavailable in this Linux development build.".into()) + } + + pub fn present(&self, _window_label: &str, _revision: u64) -> Result<(), String> { + Err("Modifier feedback is unavailable in this Linux development build.".into()) + } + + pub fn end_session(&self) {} +} diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index f11889bf..446cc7c9 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -15,6 +15,17 @@ mod platform; #[path = "overlay_windows.rs"] mod platform; +#[cfg(target_os = "linux")] +mod platform { + use super::*; + + pub(super) fn spawn(_app: AppHandle, _shared: SharedModel, receiver: Receiver) { + // A closed receiver makes all feedback sends cheap no-ops. No thread or + // window is created while Linux cursor feedback is unavailable. + drop(receiver); + } +} + const FOLLOW_INTERVAL: Duration = Duration::from_millis(75); const DEFAULT_DURATION: Duration = Duration::from_millis(900); const LANDING_DURATION: Duration = Duration::from_millis(300); From 8a764ea8f2fbc15aa53878df2224ff42042e5242 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:13:36 +0100 Subject: [PATCH 03/10] fix: guard Linux dwell sampling and validate stacked PRs --- .github/workflows/codeql.yml | 2 -- src-tauri/src/dwell.rs | 7 +++++++ src-tauri/src/lib.rs | 8 +++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5b34006d..b673b4a5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,8 +2,6 @@ name: CodeQL on: pull_request: - branches: - - main push: branches: - main diff --git a/src-tauri/src/dwell.rs b/src-tauri/src/dwell.rs index 2f207251..44dfb6e5 100644 --- a/src-tauri/src/dwell.rs +++ b/src-tauri/src/dwell.rs @@ -3,6 +3,7 @@ use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; +#[cfg(not(target_os = "linux"))] use crate::display_navigation; use crate::input::PointerFeedback; use crate::overlay::CursorOverlay; @@ -228,6 +229,12 @@ fn schedule_tick(app: AppHandle, generation: u64) { }); } +#[cfg(target_os = "linux")] +fn sample_pointer(_app: &AppHandle) -> Result { + Err("Dwell pointer tracking is unavailable on Linux.".into()) +} + +#[cfg(not(target_os = "linux"))] fn sample_pointer(app: &AppHandle) -> Result { let (position, displays) = display_navigation::displays(app) .map_err(|_| "The dwell pointer position could not be read.".to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f90df31..d6f286be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ -#[cfg_attr(target_os = "linux", allow(dead_code))] // Used when the Linux transport is wired. +// These shared engines remain compiled/tested on Linux before its transport is +// connected. Remove the Linux dead-code allowances as their adapters are wired. +#[cfg_attr(target_os = "linux", allow(dead_code))] mod ble_lifecycle; mod diagnostics; #[cfg_attr(target_os = "linux", allow(dead_code))] @@ -25,6 +27,7 @@ mod mouse_repeat; mod overlay; #[cfg_attr(target_os = "linux", allow(dead_code))] mod protocol; +#[cfg_attr(target_os = "linux", allow(dead_code))] mod state; mod storage; mod telemetry; @@ -125,6 +128,7 @@ fn request_profile_exit(app: &AppHandle, action: ProfileExitAction) { } } +#[cfg(any(not(target_os = "linux"), test))] const NAVIGATE_REQUESTED_EVENT: &str = "navigate-requested"; #[cfg(not(target_os = "linux"))] @@ -136,6 +140,7 @@ fn show_tray_menu_on_left_click() -> bool { struct PendingNavigation(Mutex>); impl PendingNavigation { + #[cfg(any(not(target_os = "linux"), test))] fn set(&self, destination: &str) { *self .0 @@ -371,6 +376,7 @@ async fn disconnect_all_on_main_thread(app: AppHandle) -> Result Date: Thu, 10 Sep 2026 11:20:49 +0100 Subject: [PATCH 04/10] test: isolate model credentials from native desktop keychains --- src-tauri/src/state.rs | 4 +++- src-tauri/src/storage.rs | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4ffdbf45..9dd3b529 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -999,7 +999,9 @@ mod tests { .storage .save_pairing_token(&device_id, "test-token") .unwrap(); - let restored = AppModel::with_storage_for_test(AppStorage::at(state_path)); + // Recreate the model from persisted state while retaining the injected + // credential store; no native keychain or D-Bus service is used in tests. + let restored = AppModel::with_storage_for_test(model.storage); let restored_device = &restored.snapshot().paired_devices[0]; assert_eq!(restored_device.device_name, "Kitchen Remote"); assert_eq!(restored_device.last_seen_at, Some(99)); diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index fbefa8e9..ae869c60 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -279,10 +279,10 @@ impl AppStorage { #[cfg(test)] pub fn at(path: PathBuf) -> Self { - let pairing_tokens = platform_pairing_token_store(&path); Self { path, - pairing_tokens, + // Unit tests must not require or modify the developer's keychain. + pairing_tokens: Box::::default(), } } @@ -387,7 +387,7 @@ mod tests { use std::sync::Mutex; #[derive(Debug, Default)] - struct MemoryPairingTokenStore { + pub(super) struct MemoryPairingTokenStore { tokens: Mutex>, } From 290712dd8734aeab0232742866a1281f00153447 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:27:10 +0100 Subject: [PATCH 05/10] test: retain native storage coverage with explicit mock injection --- src-tauri/src/state.rs | 2 +- src-tauri/src/storage.rs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9dd3b529..f9f5a0f6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -960,7 +960,7 @@ mod tests { let root = std::env::temp_dir().join(format!("switchify-last-connection-{}", Uuid::new_v4())); let state_path = root.join("state.json"); - let model = AppModel::with_storage_for_test(AppStorage::at(state_path.clone())); + let model = AppModel::with_storage_for_test(AppStorage::at_with_memory_tokens(state_path)); let device_id = format!("remote-{}", Uuid::new_v4()); { let mut data = model.shared.lock().unwrap(); diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index ae869c60..d409ac53 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -279,9 +279,19 @@ impl AppStorage { #[cfg(test)] pub fn at(path: PathBuf) -> Self { + let pairing_tokens = platform_pairing_token_store(&path); + Self { + path, + pairing_tokens, + } + } + + #[cfg(test)] + pub fn at_with_memory_tokens(path: PathBuf) -> Self { Self { path, - // Unit tests must not require or modify the developer's keychain. + // Model persistence tests do not require a native credential store. + // Platform storage tests still use `at` to exercise their adapters. pairing_tokens: Box::::default(), } } From 213654e85c13519d8d332d77024bc4490b4baf88 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:13:37 +0100 Subject: [PATCH 06/10] feat: explain Linux development limitations in setup and settings (#713) --- README.md | 28 +++++++++++++++++++++++++++- src/App.test.tsx | 20 ++++++++++++++++++++ src/App.tsx | 16 +++++++++------- src/api.ts | 11 +++++++---- src/platform.test.ts | 13 +++++++++++++ src/platform.ts | 19 +++++++++++++++++++ src/settings/SettingsView.tsx | 6 +++++- src/settings/settings.test.tsx | 19 ++++++++++++++++++- 8 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 src/platform.test.ts create mode 100644 src/platform.ts diff --git a/README.md b/README.md index e251746e..2e6bb022 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,32 @@ npm run tauri dev ## Checks +### Linux development foundation + +Linux currently builds a development UI only. Bluetooth pairing, keyboard/mouse input, +and native feedback overlays are not implemented. X11 and Wayland control are both +unavailable at this stage; this is not a supported Linux release. See the +[Linux implementation plan](docs/linux-support-plan.md) for the remaining milestones. + +On Ubuntu 24.04 x86_64, install Node.js 24, Rust 1.97.1 and the native dependencies: + +```bash +sudo apt-get update +sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libxkbcommon-dev libdbus-1-dev +npm ci +npm run tauri dev +``` + +Use `npm run tauri build -- --debug --no-bundle` to build the development executable. +Linux reports Bluetooth and input unavailable and does not open feedback windows. +It always opens the main window, including at startup, and closing that window quits +after any profile-edit confirmation. Tray/background behavior and Linux installers +are deferred until qualified. CI builds and tests on Ubuntu without injecting input; +physical Bluetooth and graphical session testing are still required before support +can be enabled. The next implementation milestone is BlueZ peripheral interoperability. + +### Repository validation + ```bash npm run lint npm test @@ -56,7 +82,7 @@ cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path src-tauri/Cargo.toml ``` -Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`. +Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`; Ubuntu also runs native checks and an unbundled development build. ## Production releases and signed updates diff --git a/src/App.test.tsx b/src/App.test.tsx index 67243502..915bc1ea 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,6 +8,8 @@ const defaultBrowserSettings = structuredClone(browserState.settings); describe("Switchify PC shell", () => { beforeEach(() => { + browserState.capabilities = { platform: "windows", grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: true }; + browserState.accessibility = "required"; browserState.settings = structuredClone(defaultBrowserSettings); browserState.bluetooth = "initializing"; browserState.pendingPairings = []; @@ -24,6 +26,24 @@ describe("Switchify PC shell", () => { vi.restoreAllMocks(); }); + it("explains the unavailable Linux build without blaming hardware or offering macOS permissions", async () => { + browserState.capabilities.platform = "linux"; + browserState.capabilities.cursorOverlay = false; + browserState.bluetooth = "unsupported"; + browserState.accessibility = "unavailable"; + render(); + expect(await screen.findByText("Bluetooth pairing is not yet available in this Linux development build.")).toBeInTheDocument(); + expect(screen.queryByText("This computer does not support the required Bluetooth features.")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open Accessibility Settings" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Set up" })); + const setup = await screen.findByRole("dialog"); + expect(within(setup).getByText("Input controls are not yet available in this Linux development build.")).toBeInTheDocument(); + expect(within(setup).getByRole("button", { name: "Next" })).toBeDisabled(); + fireEvent.click(within(setup).getByRole("button", { name: "Skip for now" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(browserState.setup.completed).toBe(false); + }); + it("uses the Switchify application icon in the sidebar", async () => { const { container } = render(); await screen.findByRole("heading", { name: "Switchify PC" }); diff --git a/src/App.tsx b/src/App.tsx index 1646b99a..c9bef87b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; import { SettingsView } from "./settings/SettingsView"; import { updateDescription, updateInFlight, updateLiveness, updateProgress, updateStanding, type UpdateAction } from "./settings/UpdatesSection"; import { TabPanel, Tabs } from "./Tabs"; +import { inputAccessAction, linuxBluetoothDescription, linuxInputDescription, linuxInputUnavailable } from "./platform"; type View = "home" | "devices" | "profiles" | "settings" | "support"; @@ -43,6 +44,7 @@ function StatusIcon({ ok, children }: { ok: boolean; children: ReactNode }) { } function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) { + if (linuxInputUnavailable(state)) return

{linuxInputDescription}

; if (state.accessibility === "granted") return

Ready

; if (state.accessibility === "unavailable") return

Unavailable on this system

; if (!detailed || state.capabilities.platform !== "macos") return

Permission required

; @@ -58,12 +60,12 @@ function HomeView({ state, onDisconnect, onAccessibility, onSetup }: { state: Ap

Switchify PC

Android control for this computer

{bluetoothOk ? : } -

{bluetoothLabels[state.bluetooth]}

{state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

+

{bluetoothLabels[state.bluetooth]}

{state.capabilities.platform === "linux" && state.bluetooth === "unsupported" ? linuxBluetoothDescription : state.bluetooth === "connected" ? state.connectedDeviceName ?? bluetoothDescriptions.connected : bluetoothDescriptions[state.bluetooth]}

{state.bluetooth === "connected" ? : }

Bluetooth

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }

Secure pairing

{state.pairedDevices.length === 0 ? "No saved devices" : `${state.pairedDevices.length} saved device${state.pairedDevices.length === 1 ? "" : "s"}`}

; @@ -307,12 +309,12 @@ function SupportView({ state, busy, perform, openSetup, openUpdates }: { state: {tab === "setup" ? <>
{bluetoothReady ? : }

Bluetooth

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" && }
+

Input access

{state.accessibility === "required" && }
0}>

Android device

{state.pairedDevices.length > 0 ? `${state.pairedDevices.length} paired` : "Open Switchify on Android and select this computer"}

Connection

{state.connectedDeviceName ?? "Waiting for a paired device"}

:

Bluetooth connection

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" ? : }
+

Input access

{state.accessibility === "required" ? : }

Application update

Switchify PC {state.version}

Diagnostics

Export sanitized health, capability, and recent event data

Recent Bluetooth changes

{state.diagnostics.recentBluetooth.length > 0 ? state.diagnostics.recentBluetooth.map((event) => event.status).join(" → ") : "No Bluetooth changes recorded yet"}

@@ -338,7 +340,7 @@ function SetupGuide({ state, busy, error, skip, finish, accessibility, approve, const dialogRef = useRef(null); const titles = ["Bluetooth and input access", "Get Switchify for Android", "Pair securely", "Start with system", "Anonymous diagnostics"]; const bluetoothReady = state.bluetooth === "advertising" || state.bluetooth === "connected"; - const canContinue = step === 2 ? state.pairedDevices.length > 0 : step === 3 ? startupChoice !== null : step === 4 ? diagnosticsChoice !== null : true; + const canContinue = !linuxInputUnavailable(state) && (step === 2 ? state.pairedDevices.length > 0 : step === 3 ? startupChoice !== null : step === 4 ? diagnosticsChoice !== null : true); useEffect(() => { dialogRef.current?.focus(); }, [step]); @@ -357,8 +359,8 @@ function SetupGuide({ state, busy, error, skip, finish, accessibility, approve, {error &&
{error}
}
{step === 0 &&
-

Bluetooth

{bluetoothLabels[state.bluetooth]}

-

Input access

{state.accessibility === "required" && }
+

Bluetooth

{state.capabilities.platform === "linux" && state.bluetooth === "unsupported" ? linuxBluetoothDescription : bluetoothLabels[state.bluetooth]}

+

Input access

{state.accessibility === "required" && }
} {step === 1 &&

Install the Android app

Install Switchify from Google Play, then open it near this computer.

Open Google Play
QR code for Switchify on Google Play
} {step === 2 &&

{state.pairedDevices.length > 0 ? "Android device paired" : "Waiting for an Android device"}

{state.pairedDevices.length > 0 ? "Secure pairing is complete. You can continue setup." : "In Switchify for Android, select this computer and confirm the matching code."}

diff --git a/src/api.ts b/src/api.ts index 387a48d5..8c8e5cde 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,13 +1,16 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { AppSettings, AppState, SwitchProfile } from "./types"; +import { browserPlatform } from "./platform"; export type ProfileExitAction = "hide" | "quit"; export type NavigationTarget = "home" | "settings" | "profiles"; +const samplePlatform = browserPlatform(navigator.userAgent); + export const browserState: AppState = { - bluetooth: "initializing", - accessibility: "required", + bluetooth: samplePlatform === "linux" ? "unsupported" : "initializing", + accessibility: samplePlatform === "linux" ? "unavailable" : "required", desktopId: "browser", pendingPairings: [], pairedDevices: [], @@ -24,8 +27,8 @@ export const browserState: AppState = { cursorCrosshairs: false, shareDiagnostics: false, }, capabilities: { - platform: navigator.userAgent.includes("Mac") ? "macos" : "windows", - grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: true, + platform: samplePlatform, + grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: samplePlatform !== "linux", }, version: "1.0.0-rc.6", diagnostics: { recentBluetooth: [], lastDisconnect: null, recentErrors: [] }, diff --git a/src/platform.test.ts b/src/platform.test.ts new file mode 100644 index 00000000..95e372a6 --- /dev/null +++ b/src/platform.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { browserPlatform } from "./platform"; + +describe("browser sample platform", () => { + it.each([ + ["Mozilla/5.0 (X11; Linux x86_64)", "linux"], + ["Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "windows"], + ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "macos"], + ["Mozilla/5.0 (Linux; Android 16)", "windows"], + ])("selects the desktop sample for %s", (agent, expected) => { + expect(browserPlatform(agent)).toBe(expected); + }); +}); diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 00000000..948f2e35 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,19 @@ +import type { AppState, PlatformCapabilities } from "./types"; + +// This only selects browser sample data. Native state comes from Rust. +export function browserPlatform(userAgent: string): PlatformCapabilities["platform"] { + if (/Mac/i.test(userAgent)) return "macos"; + if (/Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return "linux"; + return "windows"; +} + +export function linuxInputUnavailable(state: AppState): boolean { + return state.capabilities.platform === "linux" && state.accessibility === "unavailable"; +} + +export const linuxInputDescription = "Input controls are not yet available in this Linux development build."; +export const linuxBluetoothDescription = "Bluetooth pairing is not yet available in this Linux development build."; + +export function inputAccessAction(state: AppState): string { + return state.capabilities.platform === "linux" ? "Check input access" : "Open Accessibility Settings"; +} diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index a2ff676e..ed22df61 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -7,6 +7,8 @@ import { PointerSection } from "./PointerSection"; import { CursorSection } from "./CursorSection"; import { PrivacySection } from "./PrivacySection"; import { UpdatesSection, type UpdateAction } from "./UpdatesSection"; +import { linuxInputDescription, linuxInputUnavailable } from "../platform"; +import { SettingGroup } from "./controls"; type SettingsTabId = "general" | "pointer" | "cursor" | "privacy" | "updates"; @@ -54,7 +56,9 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat {active === "general" && } - {active === "pointer" && } + {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 c5303d01..09eae9c1 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -5,7 +5,7 @@ import { api, browserState } from "../api"; import type { AppSettings } from "../types"; const defaultBrowserSettings = structuredClone(browserState.settings); -const defaultCapabilities = structuredClone(browserState.capabilities); +const defaultCapabilities = { platform: "windows" as const, grid3: false, uiAccess: false, displayNavigation: false, cursorOverlay: true }; function stateWithSettings(settings: AppSettings) { return { ...structuredClone(browserState), settings: structuredClone(settings) }; @@ -15,6 +15,7 @@ const selectTab = (name: string) => fireEvent.click(screen.getByRole("tab", { na describe("Switchify PC settings", () => { beforeEach(() => { + browserState.accessibility = "required"; browserState.settings = structuredClone(defaultBrowserSettings); browserState.capabilities = structuredClone(defaultCapabilities); browserState.bluetooth = "initializing"; @@ -32,6 +33,22 @@ describe("Switchify PC settings", () => { vi.restoreAllMocks(); }); + it("does not offer ineffective input settings in the Linux development build", async () => { + browserState.capabilities = { ...defaultCapabilities, platform: "linux", cursorOverlay: false }; + browserState.bluetooth = "unsupported"; + browserState.accessibility = "unavailable"; + const save = vi.spyOn(api, "saveSettings"); + render(); + await screen.findByRole("heading", { name: "Switchify PC" }); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Controls"); + expect(screen.getByText("Settings will become available when Linux input support is enabled.")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Dwell to click" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "50% pointer speed" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Cursor appearance" })).not.toBeInTheDocument(); + expect(save).not.toHaveBeenCalled(); + }); + 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 a7b8c9994859d6a0a406ca3da033050cfc71324b Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:09:37 +0100 Subject: [PATCH 07/10] feat: add opt-in BlueZ peripheral transport probe (#716) --- .github/workflows/ci.yml | 8 + .gitignore | 1 + README.md | 2 + docs/linux-bluez-probe.md | 62 +++ src-tauri/src/ble_wire.rs | 221 +++++++++ src-tauri/src/protocol.rs | 228 +-------- tools/linux-ble-probe/Cargo.lock | 769 +++++++++++++++++++++++++++++ tools/linux-ble-probe/Cargo.toml | 20 + tools/linux-ble-probe/src/main.rs | 45 ++ tools/linux-ble-probe/src/probe.rs | 381 ++++++++++++++ 10 files changed, 1519 insertions(+), 218 deletions(-) create mode 100644 docs/linux-bluez-probe.md create mode 100644 src-tauri/src/ble_wire.rs create mode 100644 tools/linux-ble-probe/Cargo.lock create mode 100644 tools/linux-ble-probe/Cargo.toml create mode 100644 tools/linux-ble-probe/src/main.rs create mode 100644 tools/linux-ble-probe/src/probe.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bab4d1e..6846b480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: cargo audit --file src-tauri/Cargo.lock cargo audit --file src-tauri/startup-launcher/Cargo.lock cargo audit --file tools/updater-signature-verifier/Cargo.lock + cargo audit --file tools/linux-ble-probe/Cargo.lock frontend: runs-on: ubuntu-latest @@ -122,6 +123,13 @@ jobs: - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - name: Validate Linux Bluetooth probe without opening Bluetooth + if: runner.os == 'Linux' + run: | + cargo fmt --manifest-path tools/linux-ble-probe/Cargo.toml --check + cargo clippy --locked --manifest-path tools/linux-ble-probe/Cargo.toml --all-targets -- -D warnings + cargo test --locked --manifest-path tools/linux-ble-probe/Cargo.toml + cargo run --locked --manifest-path tools/linux-ble-probe/Cargo.toml -- --help - run: cargo fmt --manifest-path tools/updater-signature-verifier/Cargo.toml --check - run: cargo clippy --locked --manifest-path tools/updater-signature-verifier/Cargo.toml --all-targets -- -D warnings - run: cargo test --locked --manifest-path tools/updater-signature-verifier/Cargo.toml diff --git a/.gitignore b/.gitignore index 242fa8c1..a223816c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ src-tauri/target/ src-tauri/binaries/ src-tauri/startup-launcher/target/ tools/updater-signature-verifier/target/ +tools/linux-ble-probe/target/ src-tauri/gen/ .certs/ .env diff --git a/README.md b/README.md index 2e6bb022..38bdc668 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ after any profile-edit confirmation. Tray/background behavior and Linux installe are deferred until qualified. CI builds and tests on Ubuntu without injecting input; physical Bluetooth and graphical session testing are still required before support can be enabled. The next implementation milestone is BlueZ peripheral interoperability. +An opt-in [BlueZ transport probe](docs/linux-bluez-probe.md) is available for that +hardware investigation; it does not enable pairing or input in the desktop app. ### Repository validation diff --git a/docs/linux-bluez-probe.md b/docs/linux-bluez-probe.md new file mode 100644 index 00000000..9cc3ecb2 --- /dev/null +++ b/docs/linux-bluez-probe.md @@ -0,0 +1,62 @@ +# Linux BlueZ transport probe + +Issue: #716. Stack: #711 → #714 → #715 → this increment. + +This is the hardware-test harness for milestone 2 of the [Linux plan](linux-support-plan.md), **not completion of that milestone**. The desktop runtime remains unavailable on Linux. This standalone tool is not packaged, launched by the app, or added to releases. It cannot approve pairing, read stored credentials or inject input. It discards incoming messages and never echoes their contents or identifiers. + +## Binding and isolation decision + +Use BlueR (`bluer` pinned to 0.17.4, `bluetoothd` feature) for this experiment. It is the BlueZ project's Rust interface and exposes local GATT registration, advertising, request device identity and file-descriptor notifications. A central-only library is unsuitable. Direct D-Bus would still face the same BlueZ delivery constraints while adding object registration and descriptor-lifecycle code to maintain. No new dependency is added to the shipping application; reconsider the binding after the physical tests. + +RX uses callback writes (request and command); status uses callback reads. TX uses `AcquireNotify` through BlueR's IO mode so the probe can observe the device address and payload limit associated with a notification writer. It does not use a `StartNotify` broadcast callback for sensitive replies. Notifications contain only a fixed public `linux_transport_probe_only` error envelope, framed by the exact same source as the desktop protocol. The desktop's framing implementation was moved unchanged into `src-tauri/src/ble_wire.rs` and re-exported from `protocol.rs`; existing protocol tests still exercise it. + +**Device identity on a writer is not proof of exclusive over-the-air delivery.** BlueZ acknowledges IO subscriptions before the application receives the event. This harness accepts one writer and one RX peer at a time, drops competing writers and never combines fragments from different peers. That is a diagnostic policy, not a qualified security boundary. A refused second subscriber may affect the first session at the daemon level. Do not connect this transport to pairing approval or tokens until the competing-central test proves isolation on supported BlueZ versions. If exclusivity cannot be established, design a separately reviewed enforcement mechanism; do not rely on stopping advertisements or polling connected devices as proof. + +The probe stores at most the shared protocol's bounded partial-message set for one peer, plus one active writer. Incoming GATT values are capped at 512 bytes; nonzero write offsets and prepared/reliable writes are rejected. Notifications are generated at a fixed two-second cadence, with a two-second send timeout and no unbounded outbound queue. Only counters, payload limits and fixed diagnostic messages are logged; no payloads, tokens, signatures, device addresses or raw D-Bus errors are printed. + +## Build and run + +On Linux, install Rust 1.97.1, a C build toolchain, pkg-config, `libdbus-1-dev`, BlueZ and an advertising-capable Bluetooth adapter. The probe does not need GTK/WebKit. Use your ordinary desktop user and the system BlueZ service; do not run it as root or install permissive D-Bus policies to bypass a failure. + +```bash +cargo test --locked --manifest-path tools/linux-ble-probe/Cargo.toml +cargo run --locked --manifest-path tools/linux-ble-probe/Cargo.toml -- --help +# Explicitly enables connectable advertising on this already-powered adapter: +cargo run --locked --manifest-path tools/linux-ble-probe/Cargo.toml -- --serve hci0 +``` + +For compilation/testing on hosts without D-Bus headers, add `--features vendored-dbus` before `--`. CI uses the system library. The default invocation and `--help` do not connect to D-Bus or open Bluetooth. Automated tests use in-memory requests only. + +Close the desktop companion first to avoid duplicate service UUIDs during the experiment. The probe advertises `Switchify probe`, the existing Switchify service UUID, and the existing RX (write/write-without-response), TX (notify), and status (read) UUIDs. Its status has a fresh `probe-` desktop ID on each run, protocol version 1 and platform `linux`; it does not impersonate a saved pairing identity. It does not change radio power, remove Bluetooth pairings or touch another process's registrations. Ctrl-C or the five-minute deadline drops its registration handles. Crashes also lose the owning D-Bus connection. No automatic daemon/radio recovery is implemented yet; restart the probe after disruptions. + +Use the existing Android app to check discovery, status reads, subscription and outgoing framing. Pairing intentionally cannot complete. For writes, small-MTU tests, offset reads and a competing subscriber, also use a GATT test client capable of selecting MTU and write mode. The fixed error response ID is diagnostic, not correlated to the Android request. A UI error alone does not prove notification delivery: record the characteristic traffic using test data only. + +Protocol v1's JSON/base64 frame cannot fit the default 20-byte ATT payload. The probe uses BlueR's writer payload limit directly (do not subtract ATT overhead again), refuses an insufficient limit, and asks the operator to test a larger negotiated MTU. This exposes an interoperability constraint rather than inventing a second wire-fragmentation format. + +If registration fails, check system D-Bus and bluetoothd availability, ordinary-user permissions, adapter power/rfkill, GATT/peripheral support and available advertising instances. The tool never powers an adapter on or disconnects unrelated peers to make room. A host with a locked-down D-Bus policy remains an unsupported test configuration until its normal-user configuration is understood. + +## Required physical evidence (not yet performed) + +Record Ubuntu/BlueZ version, kernel, adapter model/firmware, Android model/app version, client tooling and outcome for each test. Use at least two independent adapters and two Android devices. Do not include typed text, secrets or packet dumps containing credentials in evidence. + +| Test | Expected observation | Result | +| --- | --- | --- | +| Ordinary user, powered adapter | Advertising and GATT registration; Android sees status and correct UUID/properties | Not run | +| RX request and write-without-response | Complete test messages counted; no execution or echo | Not run | +| Fragmented messages and interleaved IDs | Shared framing limits and expiry enforced | Not run | +| Status offsets and long reads | Reconstructed status matches protocol v1 JSON | Not run | +| Default/small and larger MTUs | Explicit failure at insufficient payload; complete public response at sufficient payload | Not run | +| Subscribe, unsubscribe, reconnect | Channel cleanup and fresh reassembly; no stale fragments | Not run | +| Second Android/GATT subscriber | Record whether either peer receives the other's public test stream, and effect of refusing the second writer | Not run; security gate | +| No adapter/daemon, power off, rfkill, advertising exhaustion | Sanitized failure; no radio or unrelated-device changes | Not run | +| Daemon restart, adapter removal, suspend, slow reader | No unbounded queue; restart needed; no persisted state | Not run | +| Ctrl-C, deadline, process termination | Owned service/advertisement disappear; fresh run registers cleanly | Not run | + +Only successful hardware evidence can close the interoperability/peer-isolation checkpoint. Secure session integration, persistent Linux credentials, reconnect recovery and X11 input remain separate increments. + +## Primary references + +- [BlueR 0.17.4 local GATT API](https://docs.rs/bluer/0.17.4/bluer/gatt/local/index.html) +- [BlueR notification writer API](https://docs.rs/bluer/0.17.4/bluer/gatt/struct.CharacteristicWriter.html) +- [BlueZ characteristic API: request options and AcquireNotify](https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattCharacteristic.rst) +- [BlueZ advertising registration API](https://github.com/bluez/bluez/blob/master/doc/org.bluez.LEAdvertisingManager.rst) diff --git a/src-tauri/src/ble_wire.rs b/src-tauri/src/ble_wire.rs new file mode 100644 index 00000000..c86d8fe8 --- /dev/null +++ b/src-tauri/src/ble_wire.rs @@ -0,0 +1,221 @@ +use base64::{engine::general_purpose, Engine as _}; +use serde::{Deserialize, Serialize}; +/// Transport-only protocol v1 framing shared with the Linux hardware probe. +use std::collections::{BTreeMap, HashMap}; +use uuid::Uuid; + +pub const PROTOCOL_VERSION: i64 = 1; +pub const FRAME_PAYLOAD_BYTES: usize = 160; +const MAX_ENCODED_CHUNK_BYTES: usize = 4 * FRAME_PAYLOAD_BYTES.div_ceil(3); +pub const MAX_MESSAGE_BYTES: usize = 16 * 1024; +pub const MAX_PARTIAL_MESSAGES: usize = 8; +pub const MAX_IDENTIFIER_BYTES: usize = 128; +pub const PARTIAL_TIMEOUT_MS: i64 = 10_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BluetoothFrame { + pub version: i64, + pub message_id: String, + pub sequence: i64, + pub is_final: bool, + pub total_bytes: i64, + pub payload_base64: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BluetoothStatus<'a> { + protocol_version: i64, + display_name: &'a str, + desktop_id: &'a str, + platform: &'a str, +} + +pub fn bluetooth_status_payload( + display_name: &str, + desktop_id: &str, + platform: &str, +) -> Result, String> { + serde_json::to_vec(&BluetoothStatus { + protocol_version: PROTOCOL_VERSION, + display_name, + desktop_id, + platform, + }) + .map_err(|error| error.to_string()) +} + +#[derive(Debug)] +struct PartialMessage { + total_bytes: usize, + created_at: i64, + chunks: BTreeMap>, + final_sequence: Option, +} + +#[derive(Debug, Default)] +pub struct FrameReassembler { + partials: HashMap, +} + +impl FrameReassembler { + pub fn accept(&mut self, frame: BluetoothFrame, now_ms: i64) -> Result, String> { + self.clear_expired(now_ms); + if frame.version != PROTOCOL_VERSION + || frame.message_id.is_empty() + || frame.message_id.len() > MAX_IDENTIFIER_BYTES + || frame.payload_base64.len() > MAX_ENCODED_CHUNK_BYTES + || frame.sequence < 0 + || frame.total_bytes < 0 + || frame.total_bytes as usize > MAX_MESSAGE_BYTES + { + return Err(if frame.total_bytes > MAX_MESSAGE_BYTES as i64 { + "message_too_large".into() + } else { + "invalid_frame".into() + }); + } + + let chunk = general_purpose::STANDARD + .decode(&frame.payload_base64) + .map_err(|_| "invalid_frame".to_string())?; + let total_bytes = frame.total_bytes as usize; + let sequence = frame.sequence as usize; + let invalid_empty_message = + total_bytes == 0 && (sequence != 0 || !frame.is_final || !chunk.is_empty()); + let invalid_nonempty_message = total_bytes > 0 + && (chunk.is_empty() + || chunk.len() > FRAME_PAYLOAD_BYTES + || chunk.len() > total_bytes + || sequence >= total_bytes); + if invalid_empty_message || invalid_nonempty_message { + self.partials.remove(&frame.message_id); + return Err("invalid_frame".into()); + } + if !self.partials.contains_key(&frame.message_id) + && self.partials.len() >= MAX_PARTIAL_MESSAGES + { + return Err("invalid_frame".into()); + } + let partial = self + .partials + .entry(frame.message_id.clone()) + .or_insert_with(|| PartialMessage { + total_bytes, + created_at: now_ms, + chunks: BTreeMap::new(), + final_sequence: None, + }); + + if partial.total_bytes != total_bytes { + self.partials.remove(&frame.message_id); + return Err("invalid_frame".into()); + } + + partial.chunks.entry(frame.sequence).or_insert(chunk); + if partial.chunks.values().map(Vec::len).sum::() > total_bytes { + self.partials.remove(&frame.message_id); + return Err("invalid_frame".into()); + } + if frame.is_final { + if partial + .final_sequence + .is_some_and(|sequence| sequence != frame.sequence) + { + self.partials.remove(&frame.message_id); + return Err("invalid_frame".into()); + } + partial.final_sequence = Some(frame.sequence); + } + + let Some(final_sequence) = partial.final_sequence else { + return Ok(None); + }; + if partial + .chunks + .keys() + .any(|sequence| *sequence > final_sequence) + { + self.partials.remove(&frame.message_id); + return Err("invalid_frame".into()); + } + + let mut payload = Vec::with_capacity(total_bytes); + for sequence in 0..=final_sequence { + let Some(chunk) = partial.chunks.get(&sequence) else { + return Ok(None); + }; + payload.extend_from_slice(chunk); + if payload.len() > total_bytes { + self.partials.remove(&frame.message_id); + return Err("invalid_frame".into()); + } + } + if payload.len() != total_bytes { + return Ok(None); + } + + self.partials.remove(&frame.message_id); + Ok(Some(String::from_utf8_lossy(&payload).into_owned())) + } + + pub fn clear_expired(&mut self, now_ms: i64) -> usize { + let before = self.partials.len(); + self.partials + .retain(|_, partial| now_ms - partial.created_at < PARTIAL_TIMEOUT_MS); + before - self.partials.len() + } +} + +#[cfg(test)] +pub fn create_frames(message: &str) -> Result>, String> { + create_frames_with_payload_bytes(message, FRAME_PAYLOAD_BYTES) +} + +pub fn create_notification_frames( + message: &str, + maximum_encoded_bytes: usize, +) -> Result>, String> { + for payload_bytes in (1..=FRAME_PAYLOAD_BYTES).rev() { + let frames = create_frames_with_payload_bytes(message, payload_bytes)?; + if frames + .iter() + .all(|frame| frame.len() <= maximum_encoded_bytes) + { + return Ok(frames); + } + } + Err("Bluetooth notification size is too small for a protocol frame.".into()) +} + +fn create_frames_with_payload_bytes( + message: &str, + payload_bytes: usize, +) -> Result>, String> { + let bytes = message.as_bytes(); + if bytes.len() > MAX_MESSAGE_BYTES { + return Err("message_too_large".into()); + } + let message_id = Uuid::new_v4().to_string(); + let chunks: Vec<&[u8]> = if bytes.is_empty() { + vec![&[]] + } else { + bytes.chunks(payload_bytes).collect() + }; + chunks + .iter() + .enumerate() + .map(|(sequence, chunk)| { + serde_json::to_vec(&BluetoothFrame { + version: PROTOCOL_VERSION, + message_id: message_id.clone(), + sequence: sequence as i64, + is_final: sequence + 1 == chunks.len(), + total_bytes: bytes.len() as i64, + payload_base64: general_purpose::STANDARD.encode(chunk), + }) + .map_err(|error| error.to_string()) + }) + .collect() +} diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index f2486902..da7288db 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -1,11 +1,11 @@ +use std::collections::HashMap; #[cfg(any(target_os = "macos", test))] use std::collections::VecDeque; -use std::collections::{BTreeMap, HashMap}; use base64::{engine::general_purpose, Engine as _}; use hmac::{Hmac, Mac}; use rand::{rngs::OsRng, RngCore}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::{json, Map, Value}; use sha2::Sha256; use uuid::Uuid; @@ -13,227 +13,16 @@ use uuid::Uuid; use crate::mouse_repeat::RepeatKey; use crate::state::AppSettings; -pub const PROTOCOL_VERSION: i64 = 1; -pub const FRAME_PAYLOAD_BYTES: usize = 160; -const MAX_ENCODED_CHUNK_BYTES: usize = 4 * FRAME_PAYLOAD_BYTES.div_ceil(3); -pub const MAX_MESSAGE_BYTES: usize = 16 * 1024; -pub const MAX_PARTIAL_MESSAGES: usize = 8; +#[path = "ble_wire.rs"] +mod wire; +pub use wire::*; + pub const MAX_PENDING_PAIRINGS: usize = 8; -pub const MAX_IDENTIFIER_BYTES: usize = 128; -pub const PARTIAL_TIMEOUT_MS: i64 = 10_000; pub const PAIRING_TIMEOUT_MS: i64 = 2 * 60 * 1_000; pub const COMMAND_TIMESTAMP_TOLERANCE_MS: i64 = 2 * 60 * 1_000; pub const MAX_TEXT_UTF16_UNITS: usize = 2_000; pub const MAX_POINTER_DELTA: f64 = 500.0; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BluetoothFrame { - pub version: i64, - pub message_id: String, - pub sequence: i64, - pub is_final: bool, - pub total_bytes: i64, - pub payload_base64: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct BluetoothStatus<'a> { - protocol_version: i64, - display_name: &'a str, - desktop_id: &'a str, - platform: &'a str, -} - -pub fn bluetooth_status_payload( - display_name: &str, - desktop_id: &str, - platform: &str, -) -> Result, String> { - serde_json::to_vec(&BluetoothStatus { - protocol_version: PROTOCOL_VERSION, - display_name, - desktop_id, - platform, - }) - .map_err(|error| error.to_string()) -} - -#[derive(Debug)] -struct PartialMessage { - total_bytes: usize, - created_at: i64, - chunks: BTreeMap>, - final_sequence: Option, -} - -#[derive(Debug, Default)] -pub struct FrameReassembler { - partials: HashMap, -} - -impl FrameReassembler { - pub fn accept(&mut self, frame: BluetoothFrame, now_ms: i64) -> Result, String> { - self.clear_expired(now_ms); - if frame.version != PROTOCOL_VERSION - || frame.message_id.is_empty() - || frame.message_id.len() > MAX_IDENTIFIER_BYTES - || frame.payload_base64.len() > MAX_ENCODED_CHUNK_BYTES - || frame.sequence < 0 - || frame.total_bytes < 0 - || frame.total_bytes as usize > MAX_MESSAGE_BYTES - { - return Err(if frame.total_bytes > MAX_MESSAGE_BYTES as i64 { - "message_too_large".into() - } else { - "invalid_frame".into() - }); - } - - let chunk = general_purpose::STANDARD - .decode(&frame.payload_base64) - .map_err(|_| "invalid_frame".to_string())?; - let total_bytes = frame.total_bytes as usize; - let sequence = frame.sequence as usize; - let invalid_empty_message = - total_bytes == 0 && (sequence != 0 || !frame.is_final || !chunk.is_empty()); - let invalid_nonempty_message = total_bytes > 0 - && (chunk.is_empty() - || chunk.len() > FRAME_PAYLOAD_BYTES - || chunk.len() > total_bytes - || sequence >= total_bytes); - if invalid_empty_message || invalid_nonempty_message { - self.partials.remove(&frame.message_id); - return Err("invalid_frame".into()); - } - if !self.partials.contains_key(&frame.message_id) - && self.partials.len() >= MAX_PARTIAL_MESSAGES - { - return Err("invalid_frame".into()); - } - let partial = self - .partials - .entry(frame.message_id.clone()) - .or_insert_with(|| PartialMessage { - total_bytes, - created_at: now_ms, - chunks: BTreeMap::new(), - final_sequence: None, - }); - - if partial.total_bytes != total_bytes { - self.partials.remove(&frame.message_id); - return Err("invalid_frame".into()); - } - - partial.chunks.entry(frame.sequence).or_insert(chunk); - if partial.chunks.values().map(Vec::len).sum::() > total_bytes { - self.partials.remove(&frame.message_id); - return Err("invalid_frame".into()); - } - if frame.is_final { - if partial - .final_sequence - .is_some_and(|sequence| sequence != frame.sequence) - { - self.partials.remove(&frame.message_id); - return Err("invalid_frame".into()); - } - partial.final_sequence = Some(frame.sequence); - } - - let Some(final_sequence) = partial.final_sequence else { - return Ok(None); - }; - if partial - .chunks - .keys() - .any(|sequence| *sequence > final_sequence) - { - self.partials.remove(&frame.message_id); - return Err("invalid_frame".into()); - } - - let mut payload = Vec::with_capacity(total_bytes); - for sequence in 0..=final_sequence { - let Some(chunk) = partial.chunks.get(&sequence) else { - return Ok(None); - }; - payload.extend_from_slice(chunk); - if payload.len() > total_bytes { - self.partials.remove(&frame.message_id); - return Err("invalid_frame".into()); - } - } - if payload.len() != total_bytes { - return Ok(None); - } - - self.partials.remove(&frame.message_id); - Ok(Some(String::from_utf8_lossy(&payload).into_owned())) - } - - pub fn clear_expired(&mut self, now_ms: i64) -> usize { - let before = self.partials.len(); - self.partials - .retain(|_, partial| now_ms - partial.created_at < PARTIAL_TIMEOUT_MS); - before - self.partials.len() - } -} - -#[cfg(test)] -pub fn create_frames(message: &str) -> Result>, String> { - create_frames_with_payload_bytes(message, FRAME_PAYLOAD_BYTES) -} - -pub fn create_notification_frames( - message: &str, - maximum_encoded_bytes: usize, -) -> Result>, String> { - for payload_bytes in (1..=FRAME_PAYLOAD_BYTES).rev() { - let frames = create_frames_with_payload_bytes(message, payload_bytes)?; - if frames - .iter() - .all(|frame| frame.len() <= maximum_encoded_bytes) - { - return Ok(frames); - } - } - Err("Bluetooth notification size is too small for a protocol frame.".into()) -} - -fn create_frames_with_payload_bytes( - message: &str, - payload_bytes: usize, -) -> Result>, String> { - let bytes = message.as_bytes(); - if bytes.len() > MAX_MESSAGE_BYTES { - return Err("message_too_large".into()); - } - let message_id = Uuid::new_v4().to_string(); - let chunks: Vec<&[u8]> = if bytes.is_empty() { - vec![&[]] - } else { - bytes.chunks(payload_bytes).collect() - }; - chunks - .iter() - .enumerate() - .map(|(sequence, chunk)| { - serde_json::to_vec(&BluetoothFrame { - version: PROTOCOL_VERSION, - message_id: message_id.clone(), - sequence: sequence as i64, - is_final: sequence + 1 == chunks.len(), - total_bytes: bytes.len() as i64, - payload_base64: general_purpose::STANDARD.encode(chunk), - }) - .map_err(|error| error.to_string()) - }) - .collect() -} - #[derive(Debug, Default)] #[cfg(any(target_os = "macos", test))] pub struct OutboundQueue { @@ -2050,7 +1839,10 @@ mod tests { assert!(engine.pending_pairings().is_empty()); assert_eq!(engine.token_for("android-1"), Some("token-1")); assert_eq!(engine.replay_cache.get("android-1:request-1"), Some(&NOW)); - assert!(engine.reassembler.partials.is_empty()); + assert_eq!( + engine.reassembler.clear_expired(NOW + PARTIAL_TIMEOUT_MS), + 0 + ); } #[test] diff --git a/tools/linux-ble-probe/Cargo.lock b/tools/linux-ble-probe/Cargo.lock new file mode 100644 index 00000000..c21806ed --- /dev/null +++ b/tools/linux-ble-probe/Cargo.lock @@ -0,0 +1,769 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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", + "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", + "darling_macro", +] + +[[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]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +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", +] + +[[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 = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +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", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "switchify-linux-ble-probe" +version = "0.1.0" +dependencies = [ + "base64", + "bluer", + "dbus", + "futures", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/linux-ble-probe/Cargo.toml b/tools/linux-ble-probe/Cargo.toml new file mode 100644 index 00000000..e4e3b7c8 --- /dev/null +++ b/tools/linux-ble-probe/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "switchify-linux-ble-probe" +version = "0.1.0" +edition = "2021" +rust-version = "1.97.1" +publish = false + +[features] +# For headless development hosts without libdbus headers. CI uses system D-Bus. +vendored-dbus = ["dbus/vendored"] + +[target.'cfg(target_os = "linux")'.dependencies] +base64 = "0.22" +bluer = { version = "=0.17.4", features = ["bluetoothd"] } +dbus = "0.9" +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt", "signal", "sync", "time"] } +uuid = { version = "1", features = ["v4"] } diff --git a/tools/linux-ble-probe/src/main.rs b/tools/linux-ble-probe/src/main.rs new file mode 100644 index 00000000..7724bb0d --- /dev/null +++ b/tools/linux-ble-probe/src/main.rs @@ -0,0 +1,45 @@ +#[cfg(target_os = "linux")] +#[path = "../../../src-tauri/src/ble_wire.rs"] +mod ble_wire; +#[cfg(target_os = "linux")] +mod probe; + +fn main() -> std::process::ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() || args == ["--help"] { + println!("Linux Bluetooth transport probe (no pairing or input).\nUsage: switchify-linux-ble-probe --serve hci0\nRequires an already powered adapter. Stops after five minutes or Ctrl-C."); + return std::process::ExitCode::SUCCESS; + } + #[cfg(target_os = "linux")] + if args.len() == 2 && args[0] == "--serve" && valid_adapter(&args[1]) { + return match probe::run(&args[1]) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + std::process::ExitCode::FAILURE + } + }; + } + eprintln!("Use --help. Serving is available only on Linux with --serve hciN."); + std::process::ExitCode::FAILURE +} + +#[cfg(target_os = "linux")] +fn valid_adapter(name: &str) -> bool { + name.strip_prefix("hci").is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + #[test] + fn adapter_argument_is_an_explicit_adapter_not_a_path() { + for name in ["hci0", "hci12"] { + assert!(super::valid_adapter(name)); + } + for name in ["", "hci", "hci0/../hci1", "hci-1", "all"] { + assert!(!super::valid_adapter(name)); + } + } +} diff --git a/tools/linux-ble-probe/src/probe.rs b/tools/linux-ble-probe/src/probe.rs new file mode 100644 index 00000000..f88fb78c --- /dev/null +++ b/tools/linux-ble-probe/src/probe.rs @@ -0,0 +1,381 @@ +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use bluer::{ + adv::Advertisement, + gatt::{ + local::{ + characteristic_control, Application, Characteristic, CharacteristicControlEvent, + CharacteristicNotify, CharacteristicNotifyMethod, CharacteristicRead, + CharacteristicWrite, CharacteristicWriteMethod, ReqError, Service, + }, + CharacteristicWriter, WriteOp, + }, + Address, +}; +use futures::StreamExt; +use tokio::time::{interval, timeout}; +use uuid::Uuid; + +use crate::ble_wire::{ + bluetooth_status_payload, create_notification_frames, BluetoothFrame, FrameReassembler, +}; + +const SERVICE: Uuid = Uuid::from_u128(0x7a78f7e8_1d6d_4d92_9ef0_1f89d3db21f4); +const RX: Uuid = Uuid::from_u128(0x7a78f7e9_1d6d_4d92_9ef0_1f89d3db21f4); +const TX: Uuid = Uuid::from_u128(0x7a78f7ea_1d6d_4d92_9ef0_1f89d3db21f4); +const STATUS: Uuid = Uuid::from_u128(0x7a78f7eb_1d6d_4d92_9ef0_1f89d3db21f4); +// Never derived from an incoming message. Safe even if BlueZ broadcasts it. +const PUBLIC_RESPONSE: &str = r#"{"version":1,"id":"linux-transport-probe","type":"error","ok":false,"error":"linux_transport_probe_only"}"#; +const MAX_WRITE_BYTES: usize = 512; + +#[derive(Default)] +struct Receiver { + peer: Option
, + frames: FrameReassembler, + accepted: u64, + completed: u64, + rejected: u64, +} + +impl Receiver { + fn write( + &mut self, + peer: Address, + bytes: &[u8], + offset: u16, + prepared: bool, + now: i64, + ) -> Result<(), ReqError> { + let result = self.accept(peer, bytes, offset, prepared, now); + if result.is_err() { + self.rejected = self.rejected.saturating_add(1); + } + result + } + + fn accept( + &mut self, + peer: Address, + bytes: &[u8], + offset: u16, + prepared: bool, + now: i64, + ) -> Result<(), ReqError> { + if offset != 0 { + return Err(ReqError::InvalidOffset); + } + if prepared { + return Err(ReqError::NotSupported); + } + if bytes.is_empty() || bytes.len() > MAX_WRITE_BYTES { + return Err(ReqError::InvalidValueLength); + } + if self.peer.is_some_and(|owner| owner != peer) { + return Err(ReqError::NotAuthorized); + } + let frame: BluetoothFrame = serde_json::from_slice(bytes).map_err(|_| ReqError::Failed)?; + // Own the reassembler before accepting even a partial frame. Never mix peers. + self.peer = Some(peer); + let message = self + .frames + .accept(frame, now) + .map_err(|_| ReqError::Failed)?; + self.accepted = self.accepted.saturating_add(1); + if message.is_some() { + self.completed = self.completed.saturating_add(1); + } + // Drop message contents: do not execute, echo, log or persist them. + Ok(()) + } + + fn clear_session(&mut self) { + self.peer = None; + self.frames = FrameReassembler::default(); + } +} + +fn read_status(value: &[u8], offset: u16, mtu: u16) -> Result, ReqError> { + let tail = value + .get(usize::from(offset)..) + .ok_or(ReqError::InvalidOffset)?; + if mtu < 23 { + return Err(ReqError::NotSupported); + } + Ok(tail[..tail.len().min(usize::from(mtu) - 1)].to_vec()) +} + +#[tokio::main(flavor = "current_thread")] +pub async fn run(adapter_name: &str) -> Result<(), &'static str> { + let session = timeout(Duration::from_secs(10), bluer::Session::new()) + .await + .map_err(|_| "System D-Bus connection timed out.")? + .map_err(|_| { + "Cannot connect to system D-Bus. Run as your normal desktop user with BlueZ installed." + })?; + let adapter = session + .adapter(adapter_name) + .map_err(|_| "Invalid adapter.")?; + let powered = timeout(Duration::from_secs(10), adapter.is_powered()) + .await + .map_err(|_| "BlueZ adapter check timed out.")? + .map_err(|_| { + "Cannot query adapter. Check bluetoothd, adapter presence and D-Bus permissions." + })?; + if !powered { + return Err( + "Adapter is powered off. Enable Bluetooth in desktop settings and check rfkill.", + ); + } + let start = Instant::now(); + let received = Arc::new(Mutex::new(Receiver::default())); + let rx_received = received.clone(); + let status = bluetooth_status_payload( + "Switchify PC transport probe", + &format!("probe-{}", Uuid::new_v4()), + "linux", + ) + .map_err(|_| "Cannot construct public status.")?; + let (mut control, control_handle) = characteristic_control(); + 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 received = rx_received.clone(); + Box::pin(async move { + received.lock().map_err(|_| ReqError::Failed)?.write( + request.device_address, + &bytes, + request.offset, + request.prepare_authorize + || request.op_type == WriteOp::Reliable, + start.elapsed().as_millis() as i64, + ) + }) + })), + ..Default::default() + }), + ..Default::default() + }, + Characteristic { + uuid: TX, + notify: Some(CharacteristicNotify { + notify: true, + method: CharacteristicNotifyMethod::Io, + ..Default::default() + }), + control_handle, + ..Default::default() + }, + Characteristic { + uuid: STATUS, + read: Some(CharacteristicRead { + read: true, + fun: Box::new(move |request| { + let result = read_status(&status, request.offset, request.mtu); + Box::pin(async move { result }) + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }], + ..Default::default() + }; + let app_handle = timeout(Duration::from_secs(10), adapter.serve_gatt_application(application)).await + .map_err(|_| "GATT registration timed out.")? + .map_err(|_| "GATT registration failed. Check BlueZ GATT server support and ordinary-user D-Bus permissions.")?; + let advertisement = Advertisement { + service_uuids: [SERVICE].into_iter().collect(), + local_name: Some("Switchify probe".into()), + discoverable: Some(true), + ..Default::default() + }; + let adv_handle = timeout(Duration::from_secs(10), adapter.advertise(advertisement)).await + .map_err(|_| "Advertising registration timed out.")? + .map_err(|_| "Advertising failed. Check LE peripheral support, rfkill and available advertising instances.")?; + println!( + "Probe registered on {adapter_name}; no pairing, credentials or input. Stop with Ctrl-C." + ); + let mut writer: Option = None; + let mut tick = interval(Duration::from_secs(2)); + let deadline = tokio::time::sleep(Duration::from_secs(300)); + tokio::pin!(deadline); + let stop = tokio::signal::ctrl_c(); + tokio::pin!(stop); + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|_| "Cannot listen for termination.")?; + loop { + tokio::select! { + _ = &mut deadline => break, + _ = terminate.recv() => break, + result = &mut stop => { result.map_err(|_| "Cannot listen for Ctrl-C.")?; break; }, + _ = async { + match &writer { + Some(channel) => { let _ = channel.closed().await; }, + None => std::future::pending::<()>().await, + } + } => { + writer = None; + received.lock().map_err(|_| "Receiver lock failed.")?.clear_session(); + println!("Notification session ended; reassembly cleared."); + }, + event = control.next() => { + let Some(CharacteristicControlEvent::Notify(candidate)) = event else { break; }; + let mut receiver = received.lock().map_err(|_| "Receiver lock failed.")?; + if writer.is_some() || receiver.peer.is_some_and(|peer| peer != candidate.device_address()) { + println!("Competing notification session refused (not a security qualification)."); + drop(candidate); + continue; + } + receiver.clear_session(); + receiver.peer = Some(candidate.device_address()); + println!("Notification channel opened; payload limit {} bytes.", candidate.mtu()); + writer = Some(candidate); + }, + _ = tick.tick() => { + let mut failed = false; + if let Some(channel) = &writer { + match create_notification_frames(PUBLIC_RESPONSE, channel.mtu()) { + Ok(frames) => { + for frame in frames { + if !matches!(timeout(Duration::from_secs(2), channel.send(&frame)).await, Ok(Ok(()))) { + println!("Notification channel closed or backpressured; session cleared."); + failed = true; + break; + } + } + }, + Err(_) => { println!("MTU too small for protocol v1 framing; session cleared."); failed = true; } + } + } + let mut receiver = received.lock().map_err(|_| "Receiver lock failed.")?; + if failed { writer = None; receiver.clear_session(); } + receiver.frames.clear_expired(start.elapsed().as_millis() as i64); + println!("RX totals: accepted={} complete={} rejected={}", receiver.accepted, receiver.completed, receiver.rejected); + } + } + } + drop(writer); + received + .lock() + .map_err(|_| "Receiver lock failed.")? + .clear_session(); + // Drop only registrations owned by this process; never unpair remote devices. + drop(adv_handle); + drop(app_handle); + tokio::time::sleep(Duration::from_millis(250)).await; + println!("Probe stopped."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ble_wire::{create_frames, PARTIAL_TIMEOUT_MS}; + + fn peer(last: u8) -> Address { + Address::new([0, 0, 0, 0, 0, last]) + } + + #[test] + fn fragmented_messages_are_counted_without_retaining_contents() { + let mut receiver = Receiver::default(); + for frame in create_frames(&"private text".repeat(80)).unwrap() { + receiver.write(peer(1), &frame, 0, false, 0).unwrap(); + } + assert_eq!(receiver.completed, 1); + assert!(receiver.accepted > 1); + } + + #[test] + fn competing_peer_cannot_complete_another_peers_message() { + let frames = create_frames(&"x".repeat(200)).unwrap(); + let mut receiver = Receiver::default(); + receiver.write(peer(1), &frames[0], 0, false, 0).unwrap(); + assert_eq!( + receiver.write(peer(2), &frames[1], 0, false, 1), + Err(ReqError::NotAuthorized) + ); + assert_eq!(receiver.completed, 0); + receiver.clear_session(); + receiver.write(peer(2), &frames[1], 0, false, 2).unwrap(); + assert_eq!(receiver.completed, 0); + } + + #[test] + fn rejects_offsets_prepared_writes_and_oversized_values() { + let mut receiver = Receiver::default(); + let frame = create_frames("hello").unwrap().remove(0); + assert_eq!( + receiver.write(peer(1), &frame, 1, false, 0), + Err(ReqError::InvalidOffset) + ); + assert_eq!( + receiver.write(peer(1), &frame, 0, true, 0), + Err(ReqError::NotSupported) + ); + assert_eq!( + receiver.write(peer(1), &[0; 513], 0, false, 0), + Err(ReqError::InvalidValueLength) + ); + assert!(receiver.write(peer(1), b"not json", 0, false, 0).is_err()); + assert_eq!(receiver.rejected, 4); + assert_eq!(receiver.accepted, 0); + } + + #[test] + fn stale_partial_messages_are_not_completed() { + let frames = create_frames(&"x".repeat(200)).unwrap(); + let mut receiver = Receiver::default(); + receiver.write(peer(1), &frames[0], 0, false, 0).unwrap(); + receiver + .write(peer(1), &frames[1], 0, false, PARTIAL_TIMEOUT_MS) + .unwrap(); + assert_eq!(receiver.completed, 0); + } + + #[test] + fn status_supports_offsets_and_att_read_limits() { + let value = bluetooth_status_payload("probe", "probe-id", "linux").unwrap(); + let mut reconstructed = Vec::new(); + while reconstructed.len() < value.len() { + reconstructed.extend(read_status(&value, reconstructed.len() as u16, 23).unwrap()); + } + assert_eq!(reconstructed, value); + assert_eq!( + read_status(&value, value.len() as u16 + 1, 23), + Err(ReqError::InvalidOffset) + ); + assert!(read_status(&value, value.len() as u16, 23) + .unwrap() + .is_empty()); + } + + #[test] + fn notifications_use_shared_framing_and_only_public_data() { + assert!(create_notification_frames(PUBLIC_RESPONSE, 20).is_err()); + for limit in [180, 244, 512] { + let mut reassembler = FrameReassembler::default(); + let mut complete = None; + for bytes in create_notification_frames(PUBLIC_RESPONSE, limit).unwrap() { + assert!(bytes.len() <= limit); + complete = reassembler + .accept(serde_json::from_slice(&bytes).unwrap(), 0) + .unwrap(); + } + assert_eq!(complete.as_deref(), Some(PUBLIC_RESPONSE)); + } + } +} From 3fa6751177e981cdd27462ecc904855766e53a55 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:10:27 +0100 Subject: [PATCH 08/10] ci: exclude probe dependency versions from product identity scan --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6846b480..b79a50b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Reject retired product identity run: | retired_identity="pre""view" - if git grep -n -i "$retired_identity" -- ':!src-tauri/Cargo.lock'; then + if git grep -n -i "$retired_identity" -- ':!src-tauri/Cargo.lock' ':!tools/linux-ble-probe/Cargo.lock'; then exit 1 fi - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 From 9713d2b89987c67aaa8623a444a60b56352b9c26 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:11:38 +0100 Subject: [PATCH 09/10] fix: expire idle probe ownership and reject invalid reservations --- tools/linux-ble-probe/src/probe.rs | 92 +++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/tools/linux-ble-probe/src/probe.rs b/tools/linux-ble-probe/src/probe.rs index f88fb78c..f968474b 100644 --- a/tools/linux-ble-probe/src/probe.rs +++ b/tools/linux-ble-probe/src/probe.rs @@ -30,10 +30,13 @@ const STATUS: Uuid = Uuid::from_u128(0x7a78f7eb_1d6d_4d92_9ef0_1f89d3db21f4); // Never derived from an incoming message. Safe even if BlueZ broadcasts it. const PUBLIC_RESPONSE: &str = r#"{"version":1,"id":"linux-transport-probe","type":"error","ok":false,"error":"linux_transport_probe_only"}"#; const MAX_WRITE_BYTES: usize = 512; +const RX_IDLE_MS: i64 = 10_000; #[derive(Default)] struct Receiver { peer: Option
, + notifications: bool, + last_write_ms: Option, frames: FrameReassembler, accepted: u64, completed: u64, @@ -64,6 +67,7 @@ impl Receiver { prepared: bool, now: i64, ) -> Result<(), ReqError> { + self.expire_rx_owner(now); if offset != 0 { return Err(ReqError::InvalidOffset); } @@ -77,12 +81,18 @@ impl Receiver { return Err(ReqError::NotAuthorized); } let frame: BluetoothFrame = serde_json::from_slice(bytes).map_err(|_| ReqError::Failed)?; - // Own the reassembler before accepting even a partial frame. Never mix peers. + // Validation and ownership changes happen under the same receiver lock. + let message = match self.frames.accept(frame, now) { + Ok(message) => message, + Err(_) => { + if self.peer.is_none() { + self.frames = FrameReassembler::default(); + } + return Err(ReqError::Failed); + } + }; self.peer = Some(peer); - let message = self - .frames - .accept(frame, now) - .map_err(|_| ReqError::Failed)?; + self.last_write_ms = Some(now); self.accepted = self.accepted.saturating_add(1); if message.is_some() { self.completed = self.completed.saturating_add(1); @@ -93,8 +103,31 @@ impl Receiver { fn clear_session(&mut self) { self.peer = None; + self.notifications = false; + self.last_write_ms = None; self.frames = FrameReassembler::default(); } + + fn expire_rx_owner(&mut self, now: i64) { + if !self.notifications + && self + .last_write_ms + .is_some_and(|last| now - last >= RX_IDLE_MS) + { + self.clear_session(); + } + } + + fn begin_notifications(&mut self, peer: Address, now: i64) -> bool { + self.expire_rx_owner(now); + if self.notifications || self.peer.is_some_and(|owner| owner != peer) { + return false; + } + self.clear_session(); + self.peer = Some(peer); + self.notifications = true; + true + } } fn read_status(value: &[u8], offset: u16, mtu: u16) -> Result, ReqError> { @@ -234,13 +267,11 @@ pub async fn run(adapter_name: &str) -> Result<(), &'static str> { event = control.next() => { let Some(CharacteristicControlEvent::Notify(candidate)) = event else { break; }; let mut receiver = received.lock().map_err(|_| "Receiver lock failed.")?; - if writer.is_some() || receiver.peer.is_some_and(|peer| peer != candidate.device_address()) { + if !receiver.begin_notifications(candidate.device_address(), start.elapsed().as_millis() as i64) { println!("Competing notification session refused (not a security qualification)."); drop(candidate); continue; } - receiver.clear_session(); - receiver.peer = Some(candidate.device_address()); println!("Notification channel opened; payload limit {} bytes.", candidate.mtu()); writer = Some(candidate); }, @@ -262,6 +293,7 @@ pub async fn run(adapter_name: &str) -> Result<(), &'static str> { } let mut receiver = received.lock().map_err(|_| "Receiver lock failed.")?; if failed { writer = None; receiver.clear_session(); } + receiver.expire_rx_owner(start.elapsed().as_millis() as i64); receiver.frames.clear_expired(start.elapsed().as_millis() as i64); println!("RX totals: accepted={} complete={} rejected={}", receiver.accepted, receiver.completed, receiver.rejected); } @@ -289,6 +321,50 @@ mod tests { Address::new([0, 0, 0, 0, 0, last]) } + #[test] + fn rejected_first_frame_does_not_reserve_rx_or_notifications() { + let frame = create_frames("test").unwrap().remove(0); + let mut invalid: BluetoothFrame = serde_json::from_slice(&frame).unwrap(); + invalid.version = 2; + let mut receiver = Receiver::default(); + assert!(receiver + .write(peer(1), &serde_json::to_vec(&invalid).unwrap(), 0, false, 0) + .is_err()); + assert_eq!(receiver.peer, None); + receiver.write(peer(2), &frame, 0, false, 1).unwrap(); + assert!(receiver.begin_notifications(peer(2), 1)); + } + + #[test] + fn rx_only_ownership_expires_without_combining_stale_fragments() { + let frames = create_frames(&"x".repeat(200)).unwrap(); + let mut receiver = Receiver::default(); + receiver.write(peer(1), &frames[0], 0, false, 0).unwrap(); + assert!(!receiver.begin_notifications(peer(2), RX_IDLE_MS - 1)); + receiver + .write(peer(2), &frames[1], 0, false, RX_IDLE_MS) + .unwrap(); + assert_eq!(receiver.completed, 0); + assert_eq!(receiver.peer, Some(peer(2))); + assert!(receiver.begin_notifications(peer(3), RX_IDLE_MS * 2)); + } + + #[test] + fn active_notification_owner_is_not_expired_by_rx_inactivity() { + let mut receiver = Receiver::default(); + assert!(receiver.begin_notifications(peer(1), 0)); + let frame = create_frames("test").unwrap().remove(0); + receiver.write(peer(1), &frame, 0, false, 1).unwrap(); + receiver.expire_rx_owner(RX_IDLE_MS * 2); + assert!(!receiver.begin_notifications(peer(2), RX_IDLE_MS * 2)); + assert_eq!( + receiver.write(peer(2), &frame, 0, false, RX_IDLE_MS * 2), + Err(ReqError::NotAuthorized) + ); + receiver.clear_session(); + assert!(receiver.begin_notifications(peer(2), RX_IDLE_MS * 2)); + } + #[test] fn fragmented_messages_are_counted_without_retaining_contents() { let mut receiver = Receiver::default(); From 773ffc9094ac302f65c286fdbbc89a661eb7ab3d Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:22:32 +0100 Subject: [PATCH 10/10] fix: clear closed notification ownership before admitting reconnects --- tools/linux-ble-probe/src/probe.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tools/linux-ble-probe/src/probe.rs b/tools/linux-ble-probe/src/probe.rs index f968474b..ec28234a 100644 --- a/tools/linux-ble-probe/src/probe.rs +++ b/tools/linux-ble-probe/src/probe.rs @@ -128,6 +128,15 @@ impl Receiver { self.notifications = true; true } + + fn replace_notifications(&mut self, peer: Address, now: i64, previous_closed: bool) -> bool { + // Notify and writer closure may become ready in the same select iteration. + // Do not require the close branch to have won before accepting a reconnect. + if previous_closed { + self.clear_session(); + } + self.begin_notifications(peer, now) + } } fn read_status(value: &[u8], offset: u16, mtu: u16) -> Result, ReqError> { @@ -267,7 +276,8 @@ pub async fn run(adapter_name: &str) -> Result<(), &'static str> { event = control.next() => { let Some(CharacteristicControlEvent::Notify(candidate)) = event else { break; }; let mut receiver = received.lock().map_err(|_| "Receiver lock failed.")?; - if !receiver.begin_notifications(candidate.device_address(), start.elapsed().as_millis() as i64) { + let previous_closed = writer.as_ref().is_some_and(|channel| channel.is_closed().unwrap_or(true)); + if !receiver.replace_notifications(candidate.device_address(), start.elapsed().as_millis() as i64, previous_closed) { println!("Competing notification session refused (not a security qualification)."); drop(candidate); continue; @@ -321,6 +331,23 @@ mod tests { Address::new([0, 0, 0, 0, 0, last]) } + #[test] + fn replacement_notification_clears_closed_owner_before_admission() { + let mut receiver = Receiver::default(); + assert!(receiver.begin_notifications(peer(1), 0)); + let frames = create_frames(&"x".repeat(200)).unwrap(); + receiver.write(peer(1), &frames[0], 0, false, 1).unwrap(); + // A live writer still excludes competitors. + assert!(!receiver.replace_notifications(peer(2), 2, false)); + // Simulate Notify winning selection while the previous writer is closed. + assert!(receiver.replace_notifications(peer(2), 2, true)); + receiver.write(peer(2), &frames[1], 0, false, 3).unwrap(); + assert_eq!(receiver.completed, 0); + assert_eq!(receiver.peer, Some(peer(2))); + // Same-peer reconnects also succeed without a second subscription. + assert!(receiver.replace_notifications(peer(2), 4, true)); + } + #[test] fn rejected_first_frame_does_not_reserve_rx_or_notifications() { let frame = create_frames("test").unwrap().remove(0);