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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions docs/linux-bluez-probe.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,45 @@ Protocol v1's JSON/base64 frame cannot fit the default 20-byte ATT payload. The

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)
## Cooperative public round-trip verifier

Run the optional `--verify hciN ADDRESS` mode on a **separate Linux adapter/host**, selecting the probe's already-discovered address using normal Bluetooth tooling. Do not use the server adapter as its own central. Stop other clients for this baseline test. The verifier does not scan, change power, pair, trust devices, remove pairings, or send arbitrary data. It refuses an already-connected target and checks the fixed probe status before subscribing or writing. Status checks identify a test fixture, not an authenticated peer.

```bash
cargo run --locked --manifest-path tools/linux-ble-probe/Cargo.toml --features vendored-dbus -- --verify hci1 ADDRESS
```

Replace `ADDRESS` locally with the selected test probe's address; do not paste it into shared diagnostic evidence. Setup is bounded to five seconds, exchange to 25 seconds, and disconnect cleanup to five seconds. Ctrl-C/SIGTERM during exchange also perform bounded cleanup. Cleanup failure is reported explicitly. Avoid another application connecting to the selected test device concurrently: BlueZ connections are shared daemon state, not exclusive client leases.

The verifier reassembles notifications using shared bounded v1 framing and accepts only the exact public probe response. Only then does it write a fixed `linux_transport_probe_receipt` message using request writes and the negotiated payload limit. Confirm both the client's receipt message and an increment of the server's `public_receipts` counter. No received content or IDs are echoed or logged. The server still discards all other messages without execution.

This cooperative test proves delivery in both directions only when physically observed. Its fixed receipt can be forged/replayed, is not authenticated, and is **not** a security or subscriber-isolation mechanism. The periodic response is not correlated to a request. This Linux central test does not establish Android notification receipt. For competing-central qualification, use independent receivers and public test data, observing what each actually receives; a successful single-client test cannot close that gate.

## Observed S26 evidence — 2026-09-11

Server source: `773ffc9094ac302f65c286fdbbc89a661eb7ab3d` (now contained in `linux-support`). Host: Linux Mint 22.3, kernel 7.0.0-31-generic, BlueZ 5.72, Intel USB adapter 8087:0aaa on hci0. Client reported as Galaxy S26; exact Android version and installed version/build were not independently verified. The later supplied log includes the diagnostics introduced in Remote beta.23/build 25.

- First attempt reached MTU negotiation, notification readiness and pairing requested at 12:24:23 UTC. Server opened a notification channel with a 512-byte payload limit and counted two fragments forming one complete message, zero rejections; later observed notification-session cleanup.
- Second attempt resolved the selected PC in about 7.9 seconds (12:27:12–12:27:20 UTC), with status parsing, identity match, MTU and notification readiness all succeeding. Server opened another 512-byte channel; totals reached four fragments and two complete messages, zero rejections. The probe then stopped at its deadline.
- Messages were discarded, not inspected. Their counts do not establish authenticated pairing or execution. Earlier nonmatching candidates and discovery failures did not prevent the later handoff; their root cause remains unproven.
- Phone-side receipt of notifications, second-adapter coverage, isolation, input and credential persistence remain unverified. No public-receipt verifier hardware run has been performed yet.

## Required physical evidence (partially 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 |
| Ordinary user, powered adapter | Advertising and GATT registration; Android sees status and correct UUID/properties | Partial: one adapter/S26 discovered and resolved; see dated evidence above |
| 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 |
| Fragmented messages and interleaved IDs | Shared framing limits and expiry enforced | Partial: two complete messages from four fragments; interleaving not physically tested |
| 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 |
| Subscribe, unsubscribe, reconnect | Channel cleanup and fresh reassembly; no stale fragments | Partial: two notification sessions and one observed session cleanup; stale-fragment hardware test pending |
| 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 |
| Ctrl-C, deadline, process termination | Owned service/advertisement disappear; fresh run registers cleanly | Partial: deadline exit observed; complete registration-cleanup matrix pending |

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.

Expand Down
6 changes: 4 additions & 2 deletions docs/linux-support-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.
Status: build foundation, setup messaging and opt-in transport probe merged into `linux-support` through #711, #714, #715 and #717. Secure pairing and input remain unavailable. The baseline above describes the original plan, not completion of the implementation.

## Goal and support boundaries

Expand Down Expand Up @@ -30,7 +30,9 @@ Static inspection identifies missing runtime and overlay symbols on Linux. The i

## 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.
Remaining milestones use focused issue branches from `linux-support` and PRs targeting `linux-support`, as requested by the user. Each requires validation evidence and independent latest-head review under [AGENTS.md](../AGENTS.md). Keep explicit dependency order between PRs, and do not merge without user approval. 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.

Next sequence: qualify bidirectional transport and subscriber isolation (#718 begins with a public-data verifier and recorded evidence), implement the production transport owner, verify persistent credentials, integrate secure pairing, deliver X11 input, complete recovery and desktop integration, then qualify packages and updates. Never connect pairing tokens to the transport before subscriber isolation passes. The first end-to-end checkpoint is discovery → approval → persistence → authentication → supervised input → safe disconnect → restart/reconnect. Wayland qualification remains separate.

### 1. Establish a Linux build and test baseline

Expand Down
18 changes: 16 additions & 2 deletions tools/linux-ble-probe/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
mod ble_wire;
#[cfg(target_os = "linux")]
mod probe;
#[cfg(target_os = "linux")]
mod verify;

fn main() -> std::process::ExitCode {
let args: Vec<String> = 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.");
println!("Linux Bluetooth transport probe (no pairing or input).\nUsage: switchify-linux-ble-probe --serve hci0\n switchify-linux-ble-probe --verify hci1 ADDRESS\nServe stops after five minutes. Verify requires a separate powered adapter and an already discovered probe; stops within 30 seconds plus cleanup. No radio power or pairing changes.");
return std::process::ExitCode::SUCCESS;
}
#[cfg(target_os = "linux")]
Expand All @@ -20,7 +22,19 @@ fn main() -> std::process::ExitCode {
}
};
}
eprintln!("Use --help. Serving is available only on Linux with --serve hciN.");
#[cfg(target_os = "linux")]
if args.len() == 3 && args[0] == "--verify" && valid_adapter(&args[1]) {
if let Ok(address) = args[2].parse::<bluer::Address>() {
return match verify::run(&args[1], address) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(message) => {
eprintln!("{message}");
std::process::ExitCode::FAILURE
}
};
}
}
eprintln!("Use --help. Probe operations require Linux and explicit arguments.");
std::process::ExitCode::FAILURE
}

Expand Down
41 changes: 35 additions & 6 deletions tools/linux-ble-probe/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ 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);
pub(crate) const SERVICE: Uuid = Uuid::from_u128(0x7a78f7e8_1d6d_4d92_9ef0_1f89d3db21f4);
pub(crate) const RX: Uuid = Uuid::from_u128(0x7a78f7e9_1d6d_4d92_9ef0_1f89d3db21f4);
pub(crate) const TX: Uuid = Uuid::from_u128(0x7a78f7ea_1d6d_4d92_9ef0_1f89d3db21f4);
pub(crate) 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"}"#;
pub(crate) const PUBLIC_RESPONSE: &str = r#"{"version":1,"id":"linux-transport-probe","type":"error","ok":false,"error":"linux_transport_probe_only"}"#;
pub(crate) const PUBLIC_RECEIPT: &str = r#"{"type":"linux_transport_probe_receipt","version":1}"#;
const MAX_WRITE_BYTES: usize = 512;
const RX_IDLE_MS: i64 = 10_000;

Expand All @@ -41,6 +42,7 @@ struct Receiver {
accepted: u64,
completed: u64,
rejected: u64,
receipts: u64,
}

impl Receiver {
Expand Down Expand Up @@ -97,6 +99,9 @@ impl Receiver {
if message.is_some() {
self.completed = self.completed.saturating_add(1);
}
if self.notifications && message.as_deref() == Some(PUBLIC_RECEIPT) {
self.receipts = self.receipts.saturating_add(1);
}
// Drop message contents: do not execute, echo, log or persist them.
Ok(())
}
Expand Down Expand Up @@ -305,7 +310,7 @@ pub async fn run(adapter_name: &str) -> Result<(), &'static str> {
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);
println!("RX totals: accepted={} complete={} rejected={} public_receipts={}", receiver.accepted, receiver.completed, receiver.rejected, receiver.receipts);
}
}
}
Expand All @@ -331,6 +336,30 @@ mod tests {
Address::new([0, 0, 0, 0, 0, last])
}

#[test]
fn public_receipts_require_complete_fixed_content_and_active_owner() {
let mut receiver = Receiver::default();
for frame in create_frames(PUBLIC_RECEIPT).unwrap() {
receiver.write(peer(1), &frame, 0, false, 0).unwrap();
}
assert_eq!(receiver.receipts, 0);
assert!(receiver.begin_notifications(peer(1), 1));
for frame in create_frames("private unrelated content").unwrap() {
receiver.write(peer(1), &frame, 0, false, 2).unwrap();
}
assert_eq!(receiver.receipts, 0);
let frames = create_frames(PUBLIC_RECEIPT).unwrap();
assert!(receiver.write(peer(2), &frames[0], 0, false, 3).is_err());
assert_eq!(receiver.receipts, 0);
for frame in frames {
receiver.write(peer(1), &frame, 0, false, 4).unwrap();
}
assert_eq!(receiver.receipts, 1);
receiver.clear_session();
assert_eq!(receiver.receipts, 1); // Run totals, not retained message contents.
assert_eq!(receiver.peer, None);
}

#[test]
fn replacement_notification_clears_closed_owner_before_admission() {
let mut receiver = Receiver::default();
Expand Down
Loading