From c874043efb82019afe1cabc073066a9d7fb0ecf3 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 18:52:08 +0200 Subject: [PATCH 01/71] fix(pairing): drop the BT bond when a peer is forgotten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgetting a peer left the Bluetooth bond in place on both sides, so the next pairing attempt fought a bond only one side still held: the link is torn down during encryption, `ServicesResolved` never arrives, and pairing dies with `timeout: service discovery`. That is the "retry several times until it works" symptom. Reproduced live 2026-08-25 — the phone held a `[DUAL]` bond for a laptop whose own bond had been dropped, and pairing failed three times in a row until the bond was cleared by hand. Vortex never creates these bonds itself (Linux deliberately skips `Device::pair()` — see the 2026-06-02 note in pairing.rs), which is exactly why nothing ever cleaned them up: they arrive via the desktop's Bluetooth panel or an older build, and Android hides profile-less LE bonds from Settings, so the user cannot clear one by hand either. Android — the side that actually holds a bond: * PeerStore gains load/savePeerBtAddr (defaulted, so the no-op stores are unaffected). BondCleaner needs an address and the phone stored none, which is why BondCleaner was reachable only from the DEBUG `remove_bond` intent and never from Forget. * Recorded at pairing from the central's address, and backfilled on every successful IK so pairings predating this commit get cleaned without re-pairing. * onForgetPeerClicked / onForgetAllClicked now drop the bond *before* the store entry goes away, while the address is still on file. Linux — no bond to drop, but the peer's BlueZ device object lingers, and its stale RPA is what feeds the RPA-churn connect wedge on the next pairing. Recorded per peer in PEER_BLE_ADDRS and evicted on forget. The address is recorded only *after* IK succeeds, on both sides. Before IK we would only be trusting a presence-token match, and acting on that could evict a stranger's device object or clear an unrelated bond. Co-Authored-By: Claude Opus 5 --- .../a3/core/pairing/ReconnectOrchestrator.kt | 12 ++++++ .../core/storage/EncryptedPrefsPeerStore.kt | 27 +++++++++++++ .../com/vortex/a3/ui/MainActivityPairing.kt | 24 ++++++++++++ linux/ui-tauri/src-tauri/src/ble.rs | 39 ++++++++++++++++++- linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 29 ++++++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt b/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt index 6f99b78..4e55a82 100644 --- a/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt +++ b/android/app/src/main/java/com/vortex/a3/core/pairing/ReconnectOrchestrator.kt @@ -228,6 +228,18 @@ class ReconnectOrchestrator( Log.i(TAG, " peer_static_pub = ${peerStaticPub.toHexPrefix()}") Log.i(TAG, " transcript_hash = ${transcript.toHexPrefix()}") + // IK proved this address really is this peer, so it is safe to record + // (before IK we would only be trusting a presence-token match). This + // also backfills pairings made before the address was persisted at + // pair time, so Forget can clean their bonds without re-pairing. + runCatching { peerStore.savePeerBtAddr(peerStaticPub, device.address) } + .onSuccess { + // Prefix only, per the redaction rule above: enough to confirm + // which laptop was recorded, not enough to correlate. + Log.i(TAG, " peer_bt_addr = ${device.address.take(8)}…") + } + .onFailure { Log.w(TAG, "could not persist peer BT addr: ${it.message}") } + states[device.address] = IkState.Established(peerStaticPub, transcript) // CopyOnWriteArrayList — iteration is lock-free over a snapshot, // safe to interleave with add()/forgetDevice() on other threads. diff --git a/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt index 9fc71c2..e6cd4a7 100644 --- a/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt +++ b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt @@ -141,6 +141,25 @@ interface PeerStore { commitAudioInNonce(peerStaticPub, nonce) return true } + + /** + * The laptop's Bluetooth address (BD_ADDR string), learned from the + * connected central during pairing and refreshed on every reconnect. + * + * Exists so [forget] can hand it to + * [com.vortex.a3.core.ble.BondCleaner.removeBond]. Vortex itself never + * bonds — Linux deliberately skips `Device::pair()` — but a bond can + * still appear via the desktop's Bluetooth panel or an older build, and + * Android hides such profile-less LE bonds from Settings, so the user + * cannot clear them by hand. Without this the phone keeps its half of a + * bond the laptop has dropped, and the next pairing dies during + * encryption with `timeout: service discovery`. + * + * Laptops use a public static address, so one value per peer is stable — + * unlike the phone's own rotating RPA. + */ + fun loadPeerBtAddr(peerStaticPub: ByteArray): String? = null + fun savePeerBtAddr(peerStaticPub: ByteArray, addr: String) {} } /** EncryptedSharedPreferences-backed Trusted Peer store. */ @@ -192,9 +211,17 @@ class EncryptedPrefsPeerStore(context: Context) : PeerStore { .remove("counter-$hex") .remove("audio_out_nonce-$hex") .remove("audio_in_nonce-$hex") + .remove("btaddr-$hex") .apply() } + override fun loadPeerBtAddr(peerStaticPub: ByteArray): String? = + prefs.getString("btaddr-${peerStaticPub.toHex()}", null) + + override fun savePeerBtAddr(peerStaticPub: ByteArray, addr: String) { + prefs.edit().putString("btaddr-${peerStaticPub.toHex()}", addr).apply() + } + override fun loadCounter(peerStaticPub: ByteArray): Long { val key = "counter-${peerStaticPub.toHex()}" // The read alone needs no lock — SharedPreferences is internally diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt index db6a7cd..2432520 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt @@ -78,6 +78,11 @@ internal fun MainActivity.wirePairingOrchestrator(identity: IdentityRecord) { } catch (e: Exception) { android.util.Log.w("Pairing", "createBond: ${e.message}") } + // Remember the laptop's BD_ADDR so Forget can clear any BT + // bond for it later (see PeerStore.loadPeerBtAddr). The + // central's address here is the laptop's public static + // address, so it stays valid for the life of the pairing. + peerStore.savePeerBtAddr(outcome.peerStaticPub, outcome.device.address) refreshPeerList() state.value = AdvertiseState.TrustedPresence // Hand off to the background service: stop our @@ -187,6 +192,20 @@ internal fun MainActivity.onForgetPeerClicked(peer: TrustedPeer) { // be offline forever. kotlinx.coroutines.delay(1_500) VortexService.pendingRevokes.remove(hex) + // Drop any BT bond for this laptop BEFORE forgetting, while its + // address is still on file. Vortex never creates these bonds (Linux + // deliberately skips Device::pair()), but one added via the desktop's + // Bluetooth panel or left by an older build survives the peer-store + // wipe — and a bond the laptop no longer holds makes the next pairing + // fail during encryption with `timeout: service discovery`, which is + // the "retry several times until it works" symptom. Android also + // hides profile-less LE bonds from Settings, so this is the only way + // for the user to clear one. + peerStore.loadPeerBtAddr(peer.peerStaticPub)?.let { mac -> + val btAdapter = + getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter + btAdapter?.let { com.vortex.a3.core.ble.BondCleaner.removeBond(it, mac) } + } peerStore.forget(peer.peerStaticPub) refreshPeerList() if (peerStore.list().isEmpty()) { @@ -200,7 +219,12 @@ internal fun MainActivity.onForgetAllClicked() { // Legacy entrypoint — kept around for any future Settings page // 'Danger zone' but no longer wired into the home screen. VortexService.stop(applicationContext) + val btAdapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter for (peer in peerStore.list()) { + // Same bond cleanup as the single-peer path — see onForgetPeerClicked. + peerStore.loadPeerBtAddr(peer.peerStaticPub)?.let { mac -> + btAdapter?.let { com.vortex.a3.core.ble.BondCleaner.removeBond(it, mac) } + } peerStore.forget(peer.peerStaticPub) } refreshPeerList() diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index f84fef9..088c063 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -118,6 +118,40 @@ pub(crate) fn shutdown_link_blocking() { note_session_addr(None); } +/// Last BLE address we completed a Noise IK exchange with, per peer. +/// +/// Recorded only *after* IK succeeds, so the address is positively tied to +/// that `peer_static_pub` — before IK we merely believe an RPA belongs to the +/// peer whose presence token matched, and acting on a belief would let us +/// remove a stranger's BlueZ device object. +/// +/// Used by `Forget` to clean up the peer's BlueZ device object (see +/// [`forget_stale_device`]). Vortex deliberately creates no BT bond on Linux +/// (see the 2026-06-02 note in `pairing.rs`), so there is usually no *bond* +/// to drop here — but a cached device object with a stale RPA does linger, and +/// leaving it behind is what feeds the RPA-churn connect wedge on the next +/// pairing. Entries are dropped on forget; the map holds one small entry per +/// trusted peer, so it needs no eviction. +static PEER_BLE_ADDRS: std::sync::Mutex< + Option>, +> = std::sync::Mutex::new(None); + +/// Tie `addr` to `peer_pub` after a successful IK. +pub(crate) fn remember_peer_addr(peer_pub: &[u8; 32], addr: bluer::Address) { + if let Ok(mut g) = PEER_BLE_ADDRS.lock() { + g.get_or_insert_with(std::collections::HashMap::new) + .insert(*peer_pub, addr); + } +} + +/// Remove and return the address last tied to `peer_pub`, if any. +pub(crate) fn take_peer_addr(peer_pub: &[u8; 32]) -> Option { + PEER_BLE_ADDRS + .lock() + .ok() + .and_then(|mut g| g.as_mut().and_then(|m| m.remove(peer_pub))) +} + /// Ms since we last heard from the phone over any transport (huge if never). pub(crate) fn peer_contact_age_ms() -> u64 { let last = LAST_PEER_CONTACT_MS.load(std::sync::atomic::Ordering::Relaxed); @@ -719,7 +753,7 @@ pub(crate) async fn connect_bonded_or_scan( /// (live-observed: 67s walk-up reconnect, the user typed their password /// long before the eager unlock could fire). RPA entries are transient by /// nature — removing one can't lose anything durable. -async fn forget_stale_device(adapter: &bluer::Adapter, addr: bluer::Address) { +pub(crate) async fn forget_stale_device(adapter: &bluer::Adapter, addr: bluer::Address) { match tokio::time::timeout(Duration::from_secs(3), adapter.remove_device(addr)).await { Ok(Ok(())) => tracing::debug!(addr = %addr, "stale RPA entry removed from BlueZ"), Ok(Err(e)) => tracing::debug!(addr = %addr, "remove_device: {e} (ignored)"), @@ -964,6 +998,9 @@ pub(crate) async fn run_ble_persistent_loop( }; consec_ik_fail = 0; tracing::info!("P2.13: BLE IK returned; peer_counter={}", outcome.peer_counter); + // IK proved this address really is this peer — safe to remember for + // Forget's BlueZ cleanup (see PEER_BLE_ADDRS). + remember_peer_addr(&peer.peer_static_pub, client.address); let Some(transport) = outcome.transport else { tracing::error!("P2.13: IK outcome missing transport state — internal bug"); diff --git a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index c9bc61a..57da5b6 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -162,6 +162,17 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { } Err(e) => tracing::warn!("peer_store.forget JOIN ERROR: {}", e), } + // Drop the peer's BlueZ device object too. Vortex creates no BT bond on + // Linux (see the 2026-06-02 note in `pairing.rs`), so this is normally not + // a *bond* removal — it evicts the cached device entry whose stale RPA + // otherwise gets re-served from the adapter's advertisement cache and + // burns connect timeouts on the next pairing. If a bond *does* exist + // (added by hand in the desktop's Bluetooth panel, or by an older build), + // this drops it, which is what keeps the two sides from ending up in the + // one-sided-bond state that fails with `timeout: service discovery`. + if let Some(addr) = crate::ble::take_peer_addr(&arr) { + crate::ble::forget_stale_device(&ctx.adapter, addr).await; + } // Drop all of the forgotten phone's cached data + blank its UI pages. purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; @@ -197,6 +208,18 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { /// `UiCmd::ForgetAll` — drop every trusted peer (local only). pub(crate) async fn forget_all(ctx: &WorkerCtx) { + // Collect the pubkeys before forgetting so the BlueZ cleanup below still + // knows which peers existed (the store is empty by then). + let ps = ctx.peer_store.clone(); + let pubs = tokio::task::spawn_blocking(move || { + ps.list() + .unwrap_or_default() + .into_iter() + .map(|p| p.peer_static_pub) + .collect::>() + }) + .await + .unwrap_or_default(); let ps = ctx.peer_store.clone(); let _ = tokio::task::spawn_blocking(move || { if let Ok(list) = ps.list() { @@ -212,6 +235,12 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { } }) .await; + // Same BlueZ cleanup as `forget_peer`, for every dropped peer. + for peer_pub in &pubs { + if let Some(addr) = crate::ble::take_peer_addr(peer_pub) { + crate::ble::forget_stale_device(&ctx.adapter, addr).await; + } + } purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; } From b560167bd3f55700a40e67a38c7e80dddf5e543f Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 18:56:28 +0200 Subject: [PATCH 02/71] feat(cache): namespace phone-specific caches per peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone-specific caches lived directly in ~/.cache/vortex/ — sms.json, contacts.json, call_log.json, the history files, last_peer_ip, last_adb_port. With one trusted phone that was fine. With two it is silent data corruption: each phone's sync overwrites the other's file, so the SMS and Contacts pages show whichever phone synced last. That has to be fixed before a laptop can remember several phones. Caches move to: ~/.cache/vortex/peers//… Keyed on the public key, not the peer's name: the display name arrives from the peer's APPROVE payload, so it is attacker-influenced (hence the existing sanitize_peer_name), it can contain path separators, it collides ("Laptop"), and it changes when the device is renamed. A public key is stable, unique, and safe as a path component. Genuinely shared state deliberately stays global: notes/todos are one list across all devices by design, and so is clipboard history. Existing installs migrate on first use. The rename is safe precisely because the old layout could only ever hold ONE phone's data, so whatever sits in the legacy path belongs to the peer we now key under. It runs once per file, and a failed rename only means the cache refills on the next sync. Verified against real data: contacts.json, sms.json, a 1.1 MB sms_history.json, sms_history.since and last_peer_ip all moved intact. Both directories are chmod 0700 explicitly. create_dir_all applies the umask, which on most desktops yields 0755 — and these hold SMS bodies and the full contact list. The 0700 cache root meant nothing was actually exposed, but relying on an ancestor's mode to protect this is fragile. Forget now removes the peer's directory as well, so a forgotten phone leaves no messages or contacts behind. ACTIVE_PEER is the interim owner of "which peer is active": set from the single trusted peer at startup and refreshed whenever a BLE session completes IK. Deliberately only when exactly one peer exists — with several, "which phone's data" has no answer until a session picks one, and guessing would render the wrong phone's messages. The arbiter in the multi-peer design (docs/design/multi-peer.md §D4) takes this over. Co-Authored-By: Claude Opus 5 --- linux/ui-tauri/src-tauri/src/ble.rs | 4 +- linux/ui-tauri/src-tauri/src/call_log.rs | 12 +- linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 8 +- linux/ui-tauri/src-tauri/src/contacts.rs | 4 +- linux/ui-tauri/src-tauri/src/lan.rs | 4 +- linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src-tauri/src/mirror_inject.rs | 4 +- linux/ui-tauri/src-tauri/src/peer_cache.rs | 143 ++++++++++++++++++ linux/ui-tauri/src-tauri/src/sms.rs | 12 +- linux/ui-tauri/src-tauri/src/worker.rs | 12 +- 10 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/peer_cache.rs diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index 088c063..5a513b9 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -999,8 +999,10 @@ pub(crate) async fn run_ble_persistent_loop( consec_ik_fail = 0; tracing::info!("P2.13: BLE IK returned; peer_counter={}", outcome.peer_counter); // IK proved this address really is this peer — safe to remember for - // Forget's BlueZ cleanup (see PEER_BLE_ADDRS). + // Forget's BlueZ cleanup (see PEER_BLE_ADDRS), and to point the + // phone-specific caches at this peer. remember_peer_addr(&peer.peer_static_pub, client.address); + crate::peer_cache::set_active_peer(&peer.peer_static_pub); let Some(transport) = outcome.transport else { tracing::error!("P2.13: IK outcome missing transport state — internal bug"); diff --git a/linux/ui-tauri/src-tauri/src/call_log.rs b/linux/ui-tauri/src-tauri/src/call_log.rs index 538c936..13e7266 100644 --- a/linux/ui-tauri/src-tauri/src/call_log.rs +++ b/linux/ui-tauri/src-tauri/src/call_log.rs @@ -11,9 +11,7 @@ use vortex_l3_daemon::core::call_log::{CallLogAssembler, CallLogEntry}; /// `~/.cache/vortex/call_log.json` — survives a daemon restart so the page /// shows the last-known list instantly while a fresh sync arrives. fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/call_log.json"); - Some(p) + crate::peer_cache::peer_file("call_log.json") } /// Validate a complete call-log JSON blob, persist it to the disk cache and @@ -60,15 +58,11 @@ pub(crate) fn cache_hash() -> String { // The twin of sms.rs's history store; see there for the model. fn history_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/call_log_history.json"); - Some(p) + crate::peer_cache::peer_file("call_log_history.json") } fn history_since_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/call_log_history.since"); - Some(p) + crate::peer_cache::peer_file("call_log_history.since") } /// The history watermark: newest call date we've synced, 0 = nothing yet. diff --git a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index 57da5b6..3794904 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -174,7 +174,11 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { crate::ble::forget_stale_device(&ctx.adapter, addr).await; } // Drop all of the forgotten phone's cached data + blank its UI pages. + // Order matters: clear the in-page state (which reads the still-active + // paths) BEFORE dropping the peer's directory and unsetting it. purge_peer_cache(&ctx.app); + crate::peer_cache::remove_peer_dir(&arr); + crate::peer_cache::clear_active_peer(&arr); emit_peers(&ctx.app, ctx.peer_store.clone()).await; // Background revoke retries (best-effort). Peer may be offline now; keep // trying for up to 60 s so a peer that comes back inside that window still @@ -235,11 +239,13 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { } }) .await; - // Same BlueZ cleanup as `forget_peer`, for every dropped peer. + // Same BlueZ + per-peer cache cleanup as `forget_peer`, for every peer. for peer_pub in &pubs { if let Some(addr) = crate::ble::take_peer_addr(peer_pub) { crate::ble::forget_stale_device(&ctx.adapter, addr).await; } + crate::peer_cache::remove_peer_dir(peer_pub); + crate::peer_cache::clear_active_peer(peer_pub); } purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; diff --git a/linux/ui-tauri/src-tauri/src/contacts.rs b/linux/ui-tauri/src-tauri/src/contacts.rs index 3307b45..43ec8c2 100644 --- a/linux/ui-tauri/src-tauri/src/contacts.rs +++ b/linux/ui-tauri/src-tauri/src/contacts.rs @@ -11,9 +11,7 @@ use vortex_l3_daemon::core::contacts::{Contact, ContactsAssembler}; /// `~/.cache/vortex/contacts.json` — survives a daemon restart so the page /// shows the last-known list instantly while a fresh sync arrives. fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/contacts.json"); - Some(p) + crate::peer_cache::peer_file("contacts.json") } /// Validate a complete contacts JSON blob, persist it to the disk cache and diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 2557d10..14e438a 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -79,9 +79,7 @@ async fn wait_for_settled( } fn last_peer_ip_path() -> Option { - let mut p = std::path::PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/last_peer_ip"); - Some(p) + crate::peer_cache::peer_file("last_peer_ip") } /// Is a phone→laptop file pull waiting on the next heartbeat round? The pull is diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 2625d2b..5e592c9 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -55,6 +55,7 @@ mod live_activity; mod media_remote; mod mirror; mod mirror_inject; +mod peer_cache; mod mirror_window; mod notes; mod notifications; diff --git a/linux/ui-tauri/src-tauri/src/mirror_inject.rs b/linux/ui-tauri/src-tauri/src/mirror_inject.rs index c8cf418..8fa688f 100644 --- a/linux/ui-tauri/src-tauri/src/mirror_inject.rs +++ b/linux/ui-tauri/src-tauri/src/mirror_inject.rs @@ -80,9 +80,7 @@ const WIRELESS_PORT: u16 = 5555; static LAST_ADB_PORT: Mutex> = Mutex::new(None); fn adb_port_path() -> Option { - let mut p = std::path::PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/last_adb_port"); - Some(p) + crate::peer_cache::peer_file("last_adb_port") } /// Note the port a network transport is attached on, in memory and on disk. diff --git a/linux/ui-tauri/src-tauri/src/peer_cache.rs b/linux/ui-tauri/src-tauri/src/peer_cache.rs new file mode 100644 index 0000000..574620f --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/peer_cache.rs @@ -0,0 +1,143 @@ +//! Per-peer cache namespacing. +//! +//! Phone-specific caches used to live directly in `~/.cache/vortex/` +//! (`sms.json`, `contacts.json`, `call_log.json`, …). With one trusted phone +//! that was fine; with two it is silent data corruption — each phone's sync +//! overwrites the other's file, so the SMS page shows whichever phone synced +//! last. This module moves them under a per-peer directory: +//! +//! ```text +//! ~/.cache/vortex/peers//sms.json +//! ``` +//! +//! **Why the public key and not the peer's name.** The display name arrives +//! from the peer's APPROVE payload — it is attacker-influenced (which is why +//! `sanitize_peer_name` exists), it can contain path separators, it collides +//! ("Laptop"), and it changes when the user renames the device. A public key +//! is stable, unique, and safe as a path component. +//! +//! Genuinely shared state stays global on purpose: notes/todos are one list +//! across all devices by design, and so is clipboard history. + +use std::path::PathBuf; + +/// The peer whose data the phone-specific caches currently refer to. +/// +/// Interim owner of "which peer is active". The full arbiter (design doc +/// §D4 — separating *connected* from *active*) will take this over; until +/// then it is set from the single trusted peer at startup and refreshed +/// whenever a BLE session completes Noise IK. That reproduces today's +/// behaviour exactly for the single-peer case, which is every existing +/// install, while giving the cache paths a real peer to key on. +static ACTIVE_PEER: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Point the phone-specific caches at `peer_pub`. +pub(crate) fn set_active_peer(peer_pub: &[u8; 32]) { + if let Ok(mut g) = ACTIVE_PEER.lock() { + if g.as_ref() != Some(peer_pub) { + tracing::debug!(peer = %hex::encode(&peer_pub[..4]), "active peer for caches"); + } + *g = Some(*peer_pub); + } +} + +/// Forget the active peer if it is `peer_pub` (no-op for any other peer, so a +/// `ForgetPeer` for an inactive device cannot blank the active one's paths). +pub(crate) fn clear_active_peer(peer_pub: &[u8; 32]) { + if let Ok(mut g) = ACTIVE_PEER.lock() { + if g.as_ref() == Some(peer_pub) { + *g = None; + } + } +} + +fn active_peer() -> Option<[u8; 32]> { + ACTIVE_PEER.lock().ok().and_then(|g| *g) +} + +/// `~/.cache/vortex` — the shared root (notes, clipboard, icons live here). +fn cache_root() -> Option { + let mut p = PathBuf::from(std::env::var_os("HOME")?); + p.push(".cache/vortex"); + Some(p) +} + +/// Directory for the active peer's caches, created if absent. +/// +/// `None` when no peer is active — before the first pairing there is nothing +/// to cache, and every caller already treats `None` as "skip the cache". +pub(crate) fn peer_dir() -> Option { + let peer = active_peer()?; + let mut p = cache_root()?; + p.push("peers"); + p.push(hex::encode(&peer[..8])); + if let Err(e) = std::fs::create_dir_all(&p) { + tracing::debug!("peer cache dir {}: {e}", p.display()); + return None; + } + // 0700 explicitly, on the peer dir AND the `peers/` parent. + // `create_dir_all` applies the umask, which on most desktops yields 0755 — + // and these directories hold SMS bodies and the full contact list. The + // `~/.cache/vortex` root is already 0700 so nothing was actually exposed, + // but relying on an ancestor's mode is a fragile way to protect this. + restrict_to_owner(&p); + if let Some(parent) = p.parent() { + restrict_to_owner(parent); + } + Some(p) +} + +/// Best-effort `chmod 0700`. A failure is not fatal — the 0700 cache root +/// still shields the contents — so we log and carry on. +fn restrict_to_owner(dir: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + match std::fs::metadata(dir) { + Ok(md) if md.permissions().mode() & 0o777 != 0o700 => { + let mut perms = md.permissions(); + perms.set_mode(0o700); + if let Err(e) = std::fs::set_permissions(dir, perms) { + tracing::debug!("chmod 0700 {}: {e}", dir.display()); + } + } + _ => {} + } +} + +/// Path to `name` inside the active peer's directory, migrating a pre-existing +/// global file on first use. +/// +/// The migration is safe precisely because the old layout could only ever hold +/// **one** phone's data: whatever is in the legacy path belongs to the single +/// peer that wrote it, which is the peer we are keying under now. It runs once +/// per file — after the rename the legacy path is gone — and a failed rename +/// just means the cache starts empty and refills on the next sync. +pub(crate) fn peer_file(name: &str) -> Option { + let dir = peer_dir()?; + let new = dir.join(name); + if !new.exists() { + if let Some(legacy) = cache_root().map(|r| r.join(name)) { + if legacy.is_file() { + match std::fs::rename(&legacy, &new) { + Ok(()) => tracing::info!("migrated {} into per-peer cache", name), + Err(e) => tracing::debug!("migrate {name}: {e} (starting empty)"), + } + } + } + } + Some(new) +} + +/// Delete the active peer's whole cache directory. Used by `ForgetPeer` so a +/// forgotten phone leaves no SMS/contacts/call-log behind. +pub(crate) fn remove_peer_dir(peer_pub: &[u8; 32]) { + let Some(mut p) = cache_root() else { return }; + p.push("peers"); + p.push(hex::encode(&peer_pub[..8])); + if !p.exists() { + return; + } + match std::fs::remove_dir_all(&p) { + Ok(()) => tracing::info!("removed per-peer cache for {}", hex::encode(&peer_pub[..4])), + Err(e) => tracing::warn!("could not remove {}: {e}", p.display()), + } +} diff --git a/linux/ui-tauri/src-tauri/src/sms.rs b/linux/ui-tauri/src-tauri/src/sms.rs index 40c660e..4c44844 100644 --- a/linux/ui-tauri/src-tauri/src/sms.rs +++ b/linux/ui-tauri/src-tauri/src/sms.rs @@ -12,9 +12,7 @@ use vortex_l3_daemon::core::sms::{SmsAssembler, SmsMessage}; /// `~/.cache/vortex/sms.json` — survives a daemon restart so the page shows the /// last-known messages instantly while a fresh sync arrives. fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/sms.json"); - Some(p) + crate::peer_cache::peer_file("sms.json") } /// Spawn the SMS consumer; returns the sender the BLE listener feeds @@ -191,15 +189,11 @@ pub(crate) fn get_sms() -> Vec { // what's missing — reading one tiny file instead of parsing the store. fn history_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/sms_history.json"); - Some(p) + crate::peer_cache::peer_file("sms_history.json") } fn history_since_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/sms_history.since"); - Some(p) + crate::peer_cache::peer_file("sms_history.since") } /// The history watermark: newest message date we've synced, 0 = nothing yet diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index bb4e3c7..1199de2 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -203,7 +203,17 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { } }; emit_peers(&app, peer_store.clone()).await; - let _have_trust = !peer_store.list().unwrap_or_default().is_empty(); + let trusted = peer_store.list().unwrap_or_default(); + let _have_trust = !trusted.is_empty(); + // Point the phone-specific caches at the trusted peer before any + // session exists, so the SMS/contacts/call-log pages render from cache + // at startup exactly as they did when those files were global. Only + // when there is exactly one peer: with several, "which phone's data" + // has no answer until a session picks one (BLE IK sets it), and + // guessing would show the wrong phone's messages. + if let [only] = trusted.as_slice() { + crate::peer_cache::set_active_peer(&only.peer_static_pub); + } // BLE adapter. // BlueZ is very often not ready yet at this point. The autostart entry From 49227a89b345164e9f239d27f5a27af1f98baf1d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 19:18:46 +0200 Subject: [PATCH 03/71] feat(multi-peer): pair a second laptop, and split active from connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the multi-peer work (docs/design/multi-peer.md): the phone can now be offered to another laptop without forgetting the first, and the laptop tracks session *ownership* separately from transport links. Phone — "Pair another laptop": Pairable mode was reachable only with an EMPTY peer list (onResume's auto-start and selectLaunchMode), so a phone that had ever paired could not be offered to a second laptop at all. That was the single-peer trap. The window has to *preempt* presence rather than run beside it: Advertiser holds one advertising set and startWith refuses while another is active, and once trust exists VortexService owns the radio, GATT server and LAN listener (an Activity-local LanServer would race it for port 51820). So it stops the service, advertises pairable from the Activity, and hands the radio back on close. Bounded at 120 s — while the window is open the paired laptop cannot see this phone, and would read it as "away" for proximity auto-lock. Verified on device: service releases NSD -> activity takes port 51820 -> "advertise started: pairable" WITH trust on file, mDNS instance matching the BLE payload_8 per spec §5.4; Cancel restores trusted-presence and the service. The permission-grant callback routes to the window via pendingPairingWindow instead of falling through to startAdvertising(), which would have started presence — silently the opposite of what was asked. Protocol — PeerHandoff (payload oneof, field 30): RELEASE / BUSY / CLAIM, for agreeing which peer owns the single active session. Additive and gated on the existing capability_flags, so an older build ignores the unknown field. Nothing in AdvPayload, the advertising flags, the GATT UUIDs or the presence-token derivation changes — deliberately no wire break. BUSY exists because a peer that cannot tell refusal from packet loss retries in a tight loop against the phone's single GATT link. Laptop — the arbiter: Separates *connected* (a transport link exists; several may overlap, and briefly do during a handoff) from *active* (owns the mirrored state: notifications, clipboard, SMS/contacts/call-log, media; exactly one, ever). Without the split, connecting to a replacement before the old link finished dropping would give two phones ownership at once, both mirroring notifications into the same laptop. * claim() is idempotent for the current owner, so a reconnect is never mistaken for a competing peer; * force_activate() displaces and returns the displaced peer, so the caller can send it RELEASE; * note_disconnected() deliberately does NOT release ownership — BLE drops during RPA churn are routine and must not blank the UI's data source; * the switch window is bounded, because seeking on top of a live connection is the most expensive radio state there is. peer_cache now delegates to it rather than keeping a second notion of "active", so cache paths and session logic cannot disagree about which phone's data is on screen. Two unit tests pin the ownership rules. The switch half of the arbiter API is written but not yet called; the Switch button and candidate picker land next. Scoped allow(dead_code) with a note to remove it then — the ownership rules belong in one reviewed place with the tests that pin them, not added piecemeal. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/ui/MainActivity.kt | 18 +- .../com/vortex/a3/ui/MainActivityPairing.kt | 101 ++++++++ .../src/main/java/com/vortex/a3/ui/Strings.kt | 9 + .../main/java/com/vortex/a3/ui/VortexRoot.kt | 6 + .../com/vortex/a3/ui/screens/HomeScreen.kt | 35 +++ linux/ui-tauri/src-tauri/src/arbiter.rs | 235 ++++++++++++++++++ linux/ui-tauri/src-tauri/src/ble.rs | 15 +- linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 6 +- linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src-tauri/src/peer_cache.rs | 40 +-- linux/ui-tauri/src-tauri/src/worker.rs | 2 +- shared/proto/vortex.proto | 41 +++ 12 files changed, 469 insertions(+), 40 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/arbiter.rs diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt index 12fe037..8652d72 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt @@ -64,6 +64,16 @@ class MainActivity : ComponentActivity() { * once when the LanServer enters PairingWindow mode. */ internal var pairingInstanceId: ByteArray? = null + /** Drives a user-opened "pair another laptop" window: the advertise + + * bounded-close sequence in [startPairingWindow]. Cancelled when the + * window is closed early (user Cancel, or a successful pair). */ + internal var pairingWindowJob: kotlinx.coroutines.Job? = null + + /** Set when [onAddPairClicked] had to ask for permissions first, so the + * grant callback opens a pairing window instead of falling through to + * the default (trusted-presence) advertising mode. */ + internal var pendingPairingWindow = false + internal val state = MutableStateFlow(AdvertiseState.Idle) internal val identityState = MutableStateFlow(null) internal val handshakeState = MutableStateFlow(null) @@ -198,6 +208,8 @@ class MainActivity : ComponentActivity() { ActivityResultContracts.RequestMultiplePermissions(), ) { granted -> val denied = granted.filterValues { !it }.keys + val wantWindow = pendingPairingWindow + pendingPairingWindow = false // Only a denied BLUETOOTH permission can stop us: without the radio // there is nothing to advertise on. Declining SMS or call-log access // costs the user those features, not the ability to pair — which is @@ -210,7 +222,9 @@ class MainActivity : ComponentActivity() { "pairing on without optional permissions: ${denied.joinToString()}", ) } - startAdvertising() + // "Add pair" asked for these; honour that instead of + // startAdvertising(), which would pick trusted-presence. + if (wantWindow) startPairingWindow() else startAdvertising() } else { state.value = AdvertiseState.Error("permissions denied: ${blocking.joinToString()}") } @@ -421,6 +435,8 @@ class MainActivity : ComponentActivity() { /** Bundle the activity's callbacks for the root composable. */ private fun buildActions(): VortexActions = VortexActions( onForgetPeer = ::onForgetPeerClicked, + onAddPair = ::onAddPairClicked, + onCancelAddPair = ::endPairingWindow, onOpenAutostart = ::onOpenAutostartSettings, onDismissAutostartHint = ::dismissAutostartHint, onRequestBatteryWhitelist = ::onRequestBatteryWhitelist, diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt index 2432520..ec898d3 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt @@ -133,6 +133,107 @@ internal fun MainActivity.startPairingWindowLanIfUntrusted(identity: IdentityRec } } +/** + * How long a user-opened "pair another laptop" window stays open before + * presence advertising is restored. + * + * Bounded on purpose: the window *preempts* the trusted-presence beacon, so + * an indefinitely-open one would leave this phone invisible to the laptop it + * is already paired with (and, worse, would read as "away" to that laptop's + * proximity auto-lock). + */ +internal const val PAIRING_WINDOW_MS = 120_000L + +/** + * Open a pairing window even though trust already exists — the phone-side + * counterpart of the laptop's "Add phone". + * + * Until now pairable mode was reachable only with an EMPTY peer list + * (`onResume`'s auto-start and `selectLaunchMode`), so a phone that had ever + * paired could not be offered to a second laptop without forgetting the + * first. That is the single-peer trap this unblocks. + * + * Why it has to preempt rather than run alongside presence: `Advertiser` + * holds one advertising set and `startWith` refuses while another is active, + * and once trust exists VortexService owns the radio, the GATT server and the + * LAN listener (an Activity-local LanServer would race it for port 51820). + * So the window stops the service, advertises pairable from the Activity, and + * hands the radio back when it closes. + */ +internal fun MainActivity.onAddPairClicked() { + val needed = requiredPermissions().filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + } + if (needed.isEmpty()) { + startPairingWindow() + } else { + // Route the post-grant callback to the window instead of the default + // advertising mode, which would pick trusted-presence and silently do + // the opposite of what the user asked for. + pendingPairingWindow = true + permissionLauncher.launch(needed.toTypedArray()) + } +} + +internal fun MainActivity.startPairingWindow() { + val identity = identityState.value ?: run { + state.value = AdvertiseState.Error("identity not ready") + return + } + pairingWindowJob?.cancel() + state.value = AdvertiseState.Starting + // Release the radio, GATT server and LAN listener from the service first. + VortexService.stop(applicationContext) + pairingWindowJob = lifecycleScope.launch { + // The service tears down asynchronously; advertising before its + // BluetoothGattServer.close() lands makes our own start() fail with a + // busy adapter. Same reasoning as the 600 ms hand-off delay used when + // pairing completes, in the other direction. + delay(600) + val instanceId = ByteArray(8).also { java.security.SecureRandom().nextBytes(it) } + pairingInstanceId = instanceId + // mDNS instance must match the BLE payload_8 (spec §5.4) so a + // discoverer correlates the two transports. + lanServer = LanServer(applicationContext, identity, peerStore).also { + it.start(LanServerMode.PairingWindow(instanceId)) + } + if (!gattServer.start()) { + state.value = AdvertiseState.Error("failed to start GATT server") + endPairingWindow() + return@launch + } + advertiser.startPairableAdvertiseWith(instanceId) { result -> + state.value = when (result) { + is Advertiser.StartResult.Started -> AdvertiseState.Active(result.payload) + is Advertiser.StartResult.Failed -> AdvertiseState.Error(result.reason) + } + } + delay(PAIRING_WINDOW_MS) + // Still pairable → nobody paired; close up and go back to presence. + // A successful pair already restarted the service via BothApproved, + // which leaves state at TrustedPresence, so this is a no-op then. + if (state.value is AdvertiseState.Active || state.value is AdvertiseState.Starting) { + endPairingWindow() + } + } +} + +/** Close the window and give the radio back to the service. */ +internal fun MainActivity.endPairingWindow() { + pairingWindowJob?.cancel() + pairingWindowJob = null + advertiser.stopAll() + gattServer.stop() + lanServer?.stop() + lanServer = null + if (peerStore.list().isNotEmpty()) { + VortexService.start(applicationContext) + state.value = AdvertiseState.TrustedPresence + } else { + state.value = AdvertiseState.Idle + } +} + internal fun MainActivity.onApproveClicked(outcome: PairingOrchestrator.HandshakeOutcome) { val orch = pairingOrchestrator ?: return val frame = orch.buildLocalApprovalFrame( diff --git a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt index 78e8833..8b8d2f2 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt @@ -52,6 +52,9 @@ private val EN = mapOf( "peers.forget_title" to "Forget device", "peers.forget_body" to "Stop trusting %s? You'll need to re-pair to connect again.", "peers.forget_confirm" to "Forget", + "peers.add_pair" to "Pair another laptop", + "peers.add_pair_cancel" to "Cancel", + "peers.add_pair_hint" to "Make this phone discoverable so a second laptop can pair with it. Your current laptop won't see the phone while the window is open.", "earbuds.not_connected" to "Not connected", "earbuds.on_local" to "Connected here", "earbuds.on_peer" to "Connected to laptop", @@ -189,6 +192,9 @@ private val UZ = mapOf( "peers.forget_title" to "Qurilmani unutish", "peers.forget_body" to "%s bilan aloqa uziladi. Qayta ulanish uchun yana pairing qilinadi.", "peers.forget_confirm" to "Unutish", + "peers.add_pair" to "Boshqa laptop qo'shish", + "peers.add_pair_cancel" to "Bekor qilish", + "peers.add_pair_hint" to "Ikkinchi laptop ulanishi uchun telefonni ko'rinadigan qiling. Oyna ochiq bo'lganda hozirgi laptop telefonni ko'rmaydi.", "earbuds.not_connected" to "Ulangmagan", "earbuds.on_local" to "Bu telefonga ulangan", "earbuds.on_peer" to "Noutbukka ulangan", @@ -326,6 +332,9 @@ private val RU = mapOf( "peers.forget_title" to "Забыть устройство", "peers.forget_body" to "Перестать доверять %s? Чтобы вновь подключиться, потребуется повторное сопряжение.", "peers.forget_confirm" to "Забыть", + "peers.add_pair" to "Добавить ноутбук", + "peers.add_pair_cancel" to "Отмена", + "peers.add_pair_hint" to "Сделать телефон видимым, чтобы с ним мог связаться второй ноутбук. Пока окно открыто, текущий ноутбук телефон не видит.", "earbuds.not_connected" to "Не подключены", "earbuds.on_local" to "Подключены к телефону", "earbuds.on_peer" to "Подключены к ноутбуку", diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index 1da4b17..d5ddcf0 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -75,6 +75,10 @@ class MainUiState( */ class VortexActions( val onForgetPeer: (TrustedPeer) -> Unit, + /** Open a pairing window while trust already exists, so this phone + * can be offered to a second laptop without forgetting the first. */ + val onAddPair: () -> Unit, + val onCancelAddPair: () -> Unit, val onOpenAutostart: () -> Unit, val onDismissAutostartHint: () -> Unit, val onRequestBatteryWhitelist: () -> Unit, @@ -257,6 +261,8 @@ fun VortexRoot( pickerState = ui.picker.collectAsState().value, switchState = EarbudsSwitchHolder.state.collectAsState().value, onForgetPeer = actions.onForgetPeer, + onAddPair = actions.onAddPair, + onCancelAddPair = actions.onCancelAddPair, onOpenAutostart = actions.onOpenAutostart, onDismissAutostartHint = actions.onDismissAutostartHint, onRequestBatteryWhitelist = actions.onRequestBatteryWhitelist, diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt index 89ecc98..cfd6d64 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt @@ -87,6 +87,8 @@ fun HomeScreen( pickerState: PickerState, switchState: SwitchState, onForgetPeer: (TrustedPeer) -> Unit, + onAddPair: () -> Unit, + onCancelAddPair: () -> Unit, onOpenAutostart: () -> Unit, onDismissAutostartHint: () -> Unit, onRequestBatteryWhitelist: () -> Unit, @@ -277,6 +279,39 @@ fun HomeScreen( onRemoveSaved = onRemoveSavedEarbuds, ) } + + // "Pair another laptop". A pairable window preempts the + // trusted-presence beacon (one advertising set), so it is + // explicit and bounded rather than always-on: while it is + // open the already-paired laptop cannot see this phone. + val windowOpen = + state is AdvertiseState.Active || state is AdvertiseState.Starting + SurfaceCard { + if (windowOpen) { + Text( + str("discover.title"), + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FW.SemiBold, + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(12.dp)) + WaitingForLinuxRow(state = state) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onCancelAddPair) { + Text(str("peers.add_pair_cancel")) + } + } else { + Text( + str("peers.add_pair_hint"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button(onClick = onAddPair) { + Text(str("peers.add_pair")) + } + } + } } if (showBatteryHint && peerCount > 0) { diff --git a/linux/ui-tauri/src-tauri/src/arbiter.rs b/linux/ui-tauri/src-tauri/src/arbiter.rs new file mode 100644 index 0000000..ccc7871 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/arbiter.rs @@ -0,0 +1,235 @@ +//! Active-peer arbiter: which trusted peer currently owns the session. +//! +//! A laptop may TRUST several phones but is ACTIVE with exactly one. The +//! distinction this module exists to enforce (design doc §D4): +//! +//! * **connected** — a transport link exists (BLE GATT and/or LAN). Several +//! can overlap harmlessly, and briefly do during a handoff. +//! * **active** — that peer owns the mirrored state: notifications, clipboard, +//! SMS/contacts/call-log pages, media. Exactly one, ever. +//! +//! Keeping them separate is what makes a handoff safe. If "connected" implied +//! "active", then connecting to the replacement before the old link finished +//! dropping would give two phones ownership at once — both mirroring +//! notifications and both syncing clipboard into the same laptop. With the +//! split, ownership flips atomically the moment a switch is confirmed and the +//! old transport can linger and die on its own schedule. +//! +//! A claim that loses gets [`Claim::Busy`] rather than silence, so the loser +//! can back off. A peer that cannot tell refusal from packet loss retries in a +//! tight loop against the phone's single GATT link. + +// The switch half of this API (`force_activate`, `begin_switch`, +// `is_switching`, `end_switch`, `is_connected`, `is_active`) is written but not +// yet called: the Switch button and the candidate picker land next. Kept here +// rather than added piecemeal so the ownership rules live in one reviewed +// place, with the tests that pin them. Drop this allow once the UI is wired. +#![allow(dead_code)] + +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Outcome of asking to become the active peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Claim { + /// Caller is now the active peer (or already was). + Granted, + /// Refused — `current` owns the session. Maps to `PeerHandoff.BUSY`. + Busy { current: [u8; 32] }, +} + +struct State { + active: Option<[u8; 32]>, + connected: HashSet<[u8; 32]>, + /// Deadline of a user-initiated "switch" window, if one is open. + /// + /// Bounded because a switch means *seeking on top of a live connection* — + /// the most expensive radio state there is (design doc §D9). Press Switch, + /// walk into a room with no other device, and without this the scan runs + /// forever. + switching_until: Option, +} + +fn state() -> &'static Mutex { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| { + Mutex::new(State { + active: None, + connected: HashSet::new(), + switching_until: None, + }) + }) +} + +/// The peer that currently owns the session, if any. +pub(crate) fn active() -> Option<[u8; 32]> { + state().lock().ok().and_then(|s| s.active) +} + +/// True when `peer_pub` owns the session. +pub(crate) fn is_active(peer_pub: &[u8; 32]) -> bool { + active().as_ref() == Some(peer_pub) +} + +/// Ask to become the active peer. +/// +/// Idempotent for the peer that already owns the session, so a reconnect of +/// the active peer never has to be special-cased by callers. +pub(crate) fn claim(peer_pub: &[u8; 32]) -> Claim { + let Ok(mut s) = state().lock() else { + // A poisoned lock must not wedge the link. Granting is the safe + // direction: the alternative is a laptop that can never own a session + // again until restart. + return Claim::Granted; + }; + match s.active { + Some(cur) if &cur != peer_pub => Claim::Busy { current: cur }, + Some(_) => Claim::Granted, + None => { + s.active = Some(*peer_pub); + tracing::info!(peer = %hex::encode(&peer_pub[..4]), "active peer claimed"); + Claim::Granted + } + } +} + +/// Move ownership to `peer_pub`, displacing whoever holds it. +/// +/// Only for an explicit user switch — the one case where "someone else is +/// active" is not a reason to refuse, because the user just said so. Returns +/// the displaced peer so the caller can send it `PeerHandoff.RELEASE`. +pub(crate) fn force_activate(peer_pub: &[u8; 32]) -> Option<[u8; 32]> { + let Ok(mut s) = state().lock() else { return None }; + let previous = s.active.filter(|p| p != peer_pub); + s.active = Some(*peer_pub); + s.switching_until = None; + tracing::info!( + peer = %hex::encode(&peer_pub[..4]), + displaced = ?previous.map(|p| hex::encode(&p[..4])), + "active peer switched" + ); + previous +} + +/// Give up ownership if `peer_pub` holds it. No-op for any other peer, so a +/// stale teardown cannot blank the current owner. +pub(crate) fn release(peer_pub: &[u8; 32]) { + if let Ok(mut s) = state().lock() { + if s.active.as_ref() == Some(peer_pub) { + s.active = None; + tracing::info!(peer = %hex::encode(&peer_pub[..4]), "active peer released"); + } + } +} + +/// Record that a transport link to `peer_pub` exists. +pub(crate) fn note_connected(peer_pub: &[u8; 32]) { + if let Ok(mut s) = state().lock() { + s.connected.insert(*peer_pub); + } +} + +/// Record that every transport link to `peer_pub` is gone. +/// +/// Deliberately does NOT release ownership: a BLE drop during RPA churn is +/// routine and the peer is still the one whose data the UI shows. Ownership +/// changes only on an explicit switch or forget. +pub(crate) fn note_disconnected(peer_pub: &[u8; 32]) { + if let Ok(mut s) = state().lock() { + s.connected.remove(peer_pub); + } +} + +pub(crate) fn is_connected(peer_pub: &[u8; 32]) -> bool { + state() + .lock() + .map(|s| s.connected.contains(peer_pub)) + .unwrap_or(false) +} + +/// Open a bounded switch window: keep the current peer, start looking for +/// another remembered one. +pub(crate) fn begin_switch(ttl: Duration) { + if let Ok(mut s) = state().lock() { + s.switching_until = Some(Instant::now() + ttl); + tracing::info!(ttl_s = ttl.as_secs(), "switch window opened"); + } +} + +/// True while a switch window is open and unexpired. Reading it also closes an +/// expired window, so callers need no separate reaper. +pub(crate) fn is_switching() -> bool { + let Ok(mut s) = state().lock() else { return false }; + match s.switching_until { + Some(deadline) if Instant::now() < deadline => true, + Some(_) => { + s.switching_until = None; + tracing::info!("switch window expired"); + false + } + None => false, + } +} + +/// Close a switch window early (user cancelled, or a replacement was chosen). +pub(crate) fn end_switch() { + if let Ok(mut s) = state().lock() { + s.switching_until = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn peer(n: u8) -> [u8; 32] { + [n; 32] + } + + // NOTE: the arbiter is process-global, so these run in one test to keep a + // deterministic order rather than racing each other through the statics. + #[test] + fn ownership_lifecycle() { + let a = peer(1); + let b = peer(2); + + assert_eq!(claim(&a), Claim::Granted); + assert!(is_active(&a)); + // Re-claiming by the owner is idempotent — a reconnect must not be + // mistaken for a competing peer. + assert_eq!(claim(&a), Claim::Granted); + // A second peer is refused, and told who holds it. + assert_eq!(claim(&b), Claim::Busy { current: a }); + + // Losing the transport does NOT lose ownership: BLE drops during RPA + // churn are routine and must not blank the UI's data source. + note_connected(&a); + note_disconnected(&a); + assert!(is_active(&a)); + assert!(!is_connected(&a)); + + // An explicit switch displaces the owner and names the displaced peer + // so the caller can send it RELEASE. + assert_eq!(force_activate(&b), Some(a)); + assert!(is_active(&b)); + assert!(!is_active(&a)); + + // Releasing a peer that does not own anything is a no-op. + release(&a); + assert!(is_active(&b)); + release(&b); + assert_eq!(active(), None); + } + + #[test] + fn switch_window_expires_on_read() { + begin_switch(Duration::from_secs(60)); + assert!(is_switching()); + end_switch(); + assert!(!is_switching()); + // A zero TTL is already expired, and reading clears it. + begin_switch(Duration::from_millis(0)); + assert!(!is_switching()); + } +} diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index 5a513b9..d75ad57 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -1002,7 +1002,20 @@ pub(crate) async fn run_ble_persistent_loop( // Forget's BlueZ cleanup (see PEER_BLE_ADDRS), and to point the // phone-specific caches at this peer. remember_peer_addr(&peer.peer_static_pub, client.address); - crate::peer_cache::set_active_peer(&peer.peer_static_pub); + crate::arbiter::note_connected(&peer.peer_static_pub); + // Ownership, separately from the link (design doc §D4). A refusal is + // logged rather than acted on for now: nothing sends `PeerHandoff.CLAIM` + // yet, so the only way to reach Busy is a second trusted phone + // connecting while one is active — worth seeing in the log. + if let crate::arbiter::Claim::Busy { current } = + crate::arbiter::claim(&peer.peer_static_pub) + { + tracing::warn!( + peer = %hex::encode(&peer.peer_static_pub[..4]), + active = %hex::encode(¤t[..4]), + "second peer connected while another is active; link up but not active" + ); + } let Some(transport) = outcome.transport else { tracing::error!("P2.13: IK outcome missing transport state — internal bug"); diff --git a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index 3794904..649a1e6 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -178,7 +178,8 @@ pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { // paths) BEFORE dropping the peer's directory and unsetting it. purge_peer_cache(&ctx.app); crate::peer_cache::remove_peer_dir(&arr); - crate::peer_cache::clear_active_peer(&arr); + crate::arbiter::release(&arr); + crate::arbiter::note_disconnected(&arr); emit_peers(&ctx.app, ctx.peer_store.clone()).await; // Background revoke retries (best-effort). Peer may be offline now; keep // trying for up to 60 s so a peer that comes back inside that window still @@ -245,7 +246,8 @@ pub(crate) async fn forget_all(ctx: &WorkerCtx) { crate::ble::forget_stale_device(&ctx.adapter, addr).await; } crate::peer_cache::remove_peer_dir(peer_pub); - crate::peer_cache::clear_active_peer(peer_pub); + crate::arbiter::release(peer_pub); + crate::arbiter::note_disconnected(peer_pub); } purge_peer_cache(&ctx.app); emit_peers(&ctx.app, ctx.peer_store.clone()).await; diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 5e592c9..177032d 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ mod lan_state; mod live_activity; mod media_remote; mod mirror; +mod arbiter; mod mirror_inject; mod peer_cache; mod mirror_window; diff --git a/linux/ui-tauri/src-tauri/src/peer_cache.rs b/linux/ui-tauri/src-tauri/src/peer_cache.rs index 574620f..798b4c1 100644 --- a/linux/ui-tauri/src-tauri/src/peer_cache.rs +++ b/linux/ui-tauri/src-tauri/src/peer_cache.rs @@ -21,40 +21,6 @@ use std::path::PathBuf; -/// The peer whose data the phone-specific caches currently refer to. -/// -/// Interim owner of "which peer is active". The full arbiter (design doc -/// §D4 — separating *connected* from *active*) will take this over; until -/// then it is set from the single trusted peer at startup and refreshed -/// whenever a BLE session completes Noise IK. That reproduces today's -/// behaviour exactly for the single-peer case, which is every existing -/// install, while giving the cache paths a real peer to key on. -static ACTIVE_PEER: std::sync::Mutex> = std::sync::Mutex::new(None); - -/// Point the phone-specific caches at `peer_pub`. -pub(crate) fn set_active_peer(peer_pub: &[u8; 32]) { - if let Ok(mut g) = ACTIVE_PEER.lock() { - if g.as_ref() != Some(peer_pub) { - tracing::debug!(peer = %hex::encode(&peer_pub[..4]), "active peer for caches"); - } - *g = Some(*peer_pub); - } -} - -/// Forget the active peer if it is `peer_pub` (no-op for any other peer, so a -/// `ForgetPeer` for an inactive device cannot blank the active one's paths). -pub(crate) fn clear_active_peer(peer_pub: &[u8; 32]) { - if let Ok(mut g) = ACTIVE_PEER.lock() { - if g.as_ref() == Some(peer_pub) { - *g = None; - } - } -} - -fn active_peer() -> Option<[u8; 32]> { - ACTIVE_PEER.lock().ok().and_then(|g| *g) -} - /// `~/.cache/vortex` — the shared root (notes, clipboard, icons live here). fn cache_root() -> Option { let mut p = PathBuf::from(std::env::var_os("HOME")?); @@ -64,10 +30,14 @@ fn cache_root() -> Option { /// Directory for the active peer's caches, created if absent. /// +/// "Active" comes from [`crate::arbiter`] — the single owner of that notion, +/// so the cache paths and the session logic can never disagree about which +/// phone's data is on screen. +/// /// `None` when no peer is active — before the first pairing there is nothing /// to cache, and every caller already treats `None` as "skip the cache". pub(crate) fn peer_dir() -> Option { - let peer = active_peer()?; + let peer = crate::arbiter::active()?; let mut p = cache_root()?; p.push("peers"); p.push(hex::encode(&peer[..8])); diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index 1199de2..5554b35 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -212,7 +212,7 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { // has no answer until a session picks one (BLE IK sets it), and // guessing would show the wrong phone's messages. if let [only] = trusted.as_slice() { - crate::peer_cache::set_active_peer(&only.peer_static_pub); + crate::arbiter::claim(&only.peer_static_pub); } // BLE adapter. diff --git a/shared/proto/vortex.proto b/shared/proto/vortex.proto index 0b8ca88..6c10e26 100644 --- a/shared/proto/vortex.proto +++ b/shared/proto/vortex.proto @@ -43,6 +43,9 @@ message VortexMessage { Ping ping = 20; Pong pong = 21; ErrorFrame error = 22; + + // --- Multi-peer session ownership (30–39) --- + PeerHandoff peer_handoff = 30; } } @@ -189,6 +192,44 @@ message ErrorFrame { string message = 2; // diagnostics only; must not leak secrets } +// ============================================================================ +// 3b. Multi-peer session ownership +// ============================================================================ + +// Transfers or refuses ownership of the single active session. +// +// A device may TRUST many peers but is ACTIVE with only one at a time. This +// frame is how the two sides agree on which. It is additive: a peer that does +// not advertise CAP_PEER_HANDOFF in `capability_flags` never receives one, and +// an older build ignores the unknown oneof field rather than failing. +// +// `RELEASE` is sent by the side giving up ownership — the user pressed +// "Switch" and a replacement peer has already been chosen. Ownership flips +// atomically when it is sent; the transport link may linger and drop on its +// own afterwards. That ordering matters: while two links overlap, only one +// side may be active, or both laptops mirror notifications and sync clipboard +// at once. +// +// `BUSY` is a refusal, sent when a peer asks to become active while another +// already is. Without an explicit refusal a rejected peer cannot distinguish +// "no" from packet loss, and retries in a tight loop against the phone's +// single GATT link. +message PeerHandoff { + enum Kind { + KIND_UNSPECIFIED = 0; + RELEASE = 1; // you are no longer the active peer + BUSY = 2; // refused: another peer is active + CLAIM = 3; // request to become the active peer + } + Kind kind = 1; + // Why ownership moved, for the UI ("switched to ") and for logs. + // Diagnostics only — never secrets, per the ErrorFrame.message rule. + string reason = 2; + // Display name of the peer taking over, when known. Sanitised by the + // receiver before it reaches any UI (it is peer-supplied text). + string successor_name = 3; +} + // ============================================================================ // 4. Shared enums // ============================================================================ From 5d94098ef1baca38bf94aabbc21463aab7795a0d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 25 Aug 2026 19:46:25 +0200 Subject: [PATCH 04/71] feat(multi-peer): "Switch device" on the laptop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the laptop half of switching between remembered phones: a tablet-smartphone button on the phone card that keeps the current device connected while it looks for another already-trusted one. Seek before release (design doc §D3). The active link is held for the whole scan, so the persistent reconnect loop has nothing to race back into and the laptop can never end up connected to nothing. A cancelled or fruitless switch leaves you exactly where you were, which is why no standby/suppression window is needed at all. The scan needs to know WHICH peer it found, not just "some trusted peer is nearby". expected_presence_tokens flattens every peer's tokens into one set — right for reconnect, useless for a switch, which must leave the active peer out. So scan_other_trusted_peers builds a token->peer map over the same ±2 bucket window, and collects for the full window instead of stopping at the first hit: whether there is ONE candidate (auto-adopt) or several (ask) must not depend on which phone advertised first. Strongest RSSI per peer wins, since it wobbles between advertising events. Ownership moves only in do_activate, and atomically (§D4) — the displaced peer stops being active immediately even though its transport may take a while to drop. Otherwise two phones would briefly both own the session and both mirror notifications and clipboard into this laptop. The peer hex from the webview is untrusted input, so it is checked against the peer store before anything moves. switch_peer() spawns rather than awaits: the worker's command loop is strictly sequential, so awaiting a 45 s scan would stall every other command behind it, including the 5 s earbuds heartbeat. UiCmd::Scan already spawns for exactly this reason. A second press while a window is open is ignored instead of starting a competing discovery on the same adapter. TrustedPeerDto gains `active`, because with several trusted phones the UI has to distinguish "remembered" from "the one whose SMS you are reading". UI: lucide tablet-smartphone for consistency with the rest of the icon set, shown only with more than one paired phone (switching is meaningless otherwise). Scanning spinner, candidate picker with RSSI, "no other paired phone nearby", and Cancel throughout. Strings in all three locales. Not yet wired: sending PeerHandoff.RELEASE to the displaced peer (marked TODO), and the whole phone-side half — Switch button, Seeking state machine, token multiplexing. End-to-end verification needs a second paired phone; what is verified here is cargo build, vue-tsc, and the app running and syncing against one. Co-Authored-By: Claude Opus 5 --- linux/ui-tauri/src-tauri/src/ble.rs | 115 +++++++++++++++ linux/ui-tauri/src-tauri/src/cmd_pairing.rs | 146 ++++++++++++++++++++ linux/ui-tauri/src-tauri/src/ipc.rs | 18 +++ linux/ui-tauri/src-tauri/src/lib.rs | 3 + linux/ui-tauri/src-tauri/src/pairing.rs | 18 +++ linux/ui-tauri/src-tauri/src/worker.rs | 5 + linux/ui-tauri/src/composables/useHome.ts | 48 ++++++- linux/ui-tauri/src/lib/bridge.ts | 41 ++++++ linux/ui-tauri/src/lib/locales/en.json | 7 +- linux/ui-tauri/src/lib/locales/ru.json | 7 +- linux/ui-tauri/src/lib/locales/uz.json | 7 +- linux/ui-tauri/src/pages/home/Devices.vue | 50 +++++++ 12 files changed, 461 insertions(+), 4 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index d75ad57..39657ec 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -297,6 +297,121 @@ fn note_discovery_health(discovering: bool) { ); } } +/// A trusted peer seen on air during a switch scan. +#[derive(Debug, Clone)] +pub(crate) struct PeerCandidate { + pub peer_static_pub: [u8; 32], + pub name: Option, + pub rssi: i16, +} + +/// Scan for trusted-presence beacons from trusted peers OTHER than `exclude`. +/// +/// [`expected_presence_tokens`] flattens every peer's tokens into one set, +/// which answers "is any trusted peer nearby" — enough for reconnect, but not +/// for a switch, which has to know *which* peer it found so it can leave the +/// active one out. So this builds the token→peer map instead. +/// +/// Excluding the active peer is what makes "Switch" coherent: the user pressed +/// it precisely because they do not want the device they are already on +/// (design doc §D3). +pub(crate) async fn scan_other_trusted_peers( + adapter: &bluer::Adapter, + peer_store: &Arc, + exclude: Option<[u8; 32]>, + wait: Duration, +) -> Vec { + use std::collections::HashMap; + use vortex_l3_daemon::core::crypto::presence::{current_bucket, derive_presence_token}; + + let peers = { + let store = peer_store.clone(); + tokio::task::spawn_blocking(move || store.list().unwrap_or_default()) + .await + .unwrap_or_default() + }; + let others: Vec<_> = peers + .into_iter() + .filter(|p| exclude.as_ref() != Some(&p.peer_static_pub)) + .collect(); + if others.is_empty() { + return Vec::new(); + } + + // token -> peer, over the same ±2 bucket window the reconnect path + // tolerates (clock skew / a Doze-deferred rotation). + let now_sec = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let bucket_now = current_bucket(now_sec, PRESENCE_ROTATION_SEC); + let mut by_token: HashMap<[u8; 8], ([u8; 32], Option)> = HashMap::new(); + for p in &others { + for d in [-2i64, -1, 0, 1, 2] { + let tok = derive_presence_token(&p.prs, (bucket_now as i64 + d) as u64); + by_token.insert(tok, (p.peer_static_pub, p.peer_name.clone())); + } + } + + let (tx, mut rx) = tokio::sync::mpsc::channel::(16); + let scan = { + let adapter = adapter.clone(); + tokio::spawn(async move { + let _ = run_filtered_scan(adapter, move |c| { + if !c.payload.flags.is_trusted_presence() { + return; + } + let Some((peer_pub, stored_name)) = by_token.get(&c.payload.payload_8) else { + return; + }; + let _ = tx.try_send(PeerCandidate { + peer_static_pub: *peer_pub, + // Prefer the live SCAN_RSP name, fall back to the name + // recorded at pairing. + name: c.local_name.clone().or_else(|| stored_name.clone()), + rssi: c.rssi.unwrap_or(0), + }); + }) + .await; + }) + }; + + // Collect for the whole window rather than stopping at the first hit: the + // point is to know whether there is ONE candidate (auto-connect) or + // several (ask the user), so an early return would make the picker + // depend on which phone happened to advertise first. + let mut found: HashMap<[u8; 32], PeerCandidate> = HashMap::new(); + let deadline = tokio::time::Instant::now() + wait; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Some(cand)) => { + // Keep the strongest sighting per peer — RSSI wobbles a lot + // between advertising events. + found + .entry(cand.peer_static_pub) + .and_modify(|e| { + if cand.rssi > e.rssi { + *e = cand.clone(); + } + }) + .or_insert(cand); + } + Ok(None) | Err(_) => break, + } + } + // Same abort+join discipline as find_trusted_presence_peer: bluer only + // issues StopDiscovery when the scan future is actually dropped, so + // without the join the next scan races a still-live discovery session. + scan.abort(); + let _ = scan.await; + let mut out: Vec<_> = found.into_values().collect(); + out.sort_by(|a, b| b.rssi.cmp(&a.rssi)); + out +} pub(crate) async fn find_trusted_presence_peer( adapter: &bluer::Adapter, diff --git a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs index 649a1e6..39da37b 100644 --- a/linux/ui-tauri/src-tauri/src/cmd_pairing.rs +++ b/linux/ui-tauri/src-tauri/src/cmd_pairing.rs @@ -126,6 +126,152 @@ pub(crate) async fn pair( } } +/// How long a switch window stays open (design doc §D9). Long enough to walk +/// to another machine and wake it, short enough that an unattended press stops +/// scanning-on-top-of-a-live-link — the most expensive radio state we have. +const SWITCH_WINDOW_SECS: u64 = 45; + +/// One candidate device offered by a switch scan. +#[derive(serde::Serialize, Clone)] +struct SwitchCandidateDto { + peer_static_pub: String, + name: Option, + rssi: i16, +} + +/// `UiCmd::SwitchPeer` — keep the current peer, look for another trusted one. +/// +/// Explicitly NOT a release: the active link is held for the whole scan, so +/// the persistent reconnect loop has nothing to race back into and the laptop +/// cannot end up connected to nothing. Ownership only moves in +/// [`activate_peer`], once a replacement is actually in hand (§D3). +/// +/// Returns immediately and does the scan on a spawned task. The worker's +/// command loop is strictly sequential — awaiting a 45 s scan here would stall +/// every other command behind it, including the 5 s earbuds heartbeat. Same +/// reason `UiCmd::Scan` spawns rather than awaiting. +pub(crate) fn switch_peer(ctx: &WorkerCtx) { + // A second press while a window is open is a no-op rather than a second + // scan: two concurrent discoveries would fight over the adapter. + if crate::arbiter::is_switching() { + tracing::debug!("switch already in progress; ignoring"); + return; + } + let active = crate::arbiter::active(); + crate::arbiter::begin_switch(Duration::from_secs(SWITCH_WINDOW_SECS)); + + let app = ctx.app.clone(); + let adapter = ctx.adapter.clone(); + let peer_store = ctx.peer_store.clone(); + tokio::spawn(async move { + let _ = app.emit("vortex:switch_scanning", true); + let candidates = crate::ble::scan_other_trusted_peers( + &adapter, + &peer_store, + active, + Duration::from_secs(SWITCH_WINDOW_SECS), + ) + .await; + let _ = app.emit("vortex:switch_scanning", false); + + // Cancelled while we were scanning — drop the result rather than + // acting on a switch the user already backed out of. + if !crate::arbiter::is_switching() { + tracing::info!("switch window closed during scan; discarding candidates"); + return; + } + + let dtos: Vec = candidates + .iter() + .map(|c| SwitchCandidateDto { + peer_static_pub: hex::encode(c.peer_static_pub), + name: c.name.clone(), + rssi: c.rssi, + }) + .collect(); + tracing::info!(count = dtos.len(), "switch scan finished"); + + match candidates.as_slice() { + // Nothing else in range: report it and close, leaving the current + // peer untouched. The UI shows "no other device found". + [] => { + crate::arbiter::end_switch(); + let _ = app.emit("vortex:switch_candidates", dtos); + } + // Exactly one — no point asking which. + [only] => { + do_activate(&app, &peer_store, only.peer_static_pub).await; + } + // Several: let the user pick (§D8). + _ => { + let _ = app.emit("vortex:switch_candidates", dtos); + } + } + }); +} + +/// `UiCmd::CancelSwitch` — close the window, change nothing. +pub(crate) async fn cancel_switch(ctx: &WorkerCtx) { + crate::arbiter::end_switch(); + let _ = ctx.app.emit("vortex:switch_scanning", false); + let _ = ctx + .app + .emit::>("vortex:switch_candidates", Vec::new()); +} + +/// `UiCmd::ActivatePeer` — hand session ownership to this trusted peer. +pub(crate) async fn activate_peer(ctx: &WorkerCtx, hex_str: String) { + let Ok(bytes) = hex::decode(&hex_str) else { return }; + if bytes.len() != 32 { + return; + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + do_activate(&ctx.app, &ctx.peer_store, arr).await; +} + +/// Shared body of "adopt this peer as the active one", callable both from the +/// command handler and from the spawned switch scan. +/// +/// The ownership flip is atomic (§D4): the displaced peer stops being active +/// the instant this runs, even though its transport link may take a while to +/// drop. Without that ordering two phones would briefly both own the session +/// and both mirror notifications and clipboard into this laptop. +async fn do_activate( + app: &tauri::AppHandle, + peer_store: &std::sync::Arc, + peer_pub: [u8; 32], +) { + // Refuse to activate a peer we do not actually trust — for the command + // path the hex arrives from the webview, so it is untrusted input. + let ps = peer_store.clone(); + let known = tokio::task::spawn_blocking(move || ps.load(&peer_pub).is_ok()) + .await + .unwrap_or(false); + if !known { + tracing::warn!(peer = %hex::encode(&peer_pub[..4]), "activate: not a trusted peer"); + return; + } + + let displaced = crate::arbiter::force_activate(&peer_pub); + crate::arbiter::end_switch(); + if let Some(prev) = displaced { + // TODO(multi-peer): send PeerHandoff.RELEASE to `prev` so it stops + // presenting itself as connected. Until the frame is wired, the + // displaced phone learns of it on its next contact. + tracing::info!( + displaced = %hex::encode(&prev[..4]), + "displaced peer still needs a PeerHandoff.RELEASE" + ); + } + let _ = app.emit("vortex:switch_scanning", false); + let _ = app.emit::>("vortex:switch_candidates", Vec::new()); + // Blank the pages that were showing the old phone's data, then re-emit + // peers so the UI's `active` flags follow the new owner. + purge_peer_cache(app); + emit_peers(app, peer_store.clone()).await; +} + /// `UiCmd::ForgetPeer` — forget locally now (instant UI), then best-effort /// background revoke retries for up to 60 s so trust drops bidirectionally. pub(crate) async fn forget_peer(ctx: &WorkerCtx, hex_str: String) { diff --git a/linux/ui-tauri/src-tauri/src/ipc.rs b/linux/ui-tauri/src-tauri/src/ipc.rs index ab8384b..2d8307d 100644 --- a/linux/ui-tauri/src-tauri/src/ipc.rs +++ b/linux/ui-tauri/src-tauri/src/ipc.rs @@ -41,6 +41,19 @@ pub(crate) enum UiCmd { StartMirror { width: u32, height: u32, fps: u32, bitrate: u32 }, /// Stop the active screen-mirror session. StopMirror, + /// "Switch device" on the connected card: keep the current phone, and + /// start looking for another *already-trusted* one. + /// + /// Deliberately not a release — the link is held until a replacement is + /// confirmed, so the reconnect loop has nothing to race back into and the + /// laptop can never end up connected to nothing (design doc §D3). + SwitchPeer, + /// Close the switch window without changing anything (user cancelled, or + /// it expired). + CancelSwitch, + /// Adopt this trusted peer (hex `peer_static_pub`) as the active one — + /// either the single candidate found, or the user's pick from several. + ActivatePeer(String), } /// Identity surface visible to the Vue layer. We deliberately keep @@ -67,6 +80,10 @@ pub(crate) struct TrustedPeerDto { peer_static_pub: String, paired_at: u64, peer_name: Option, + /// True for the peer that currently owns the session. With several + /// trusted phones the UI has to distinguish "remembered" from "the one + /// whose SMS and notifications you are looking at" — see `arbiter`. + active: bool, } /// Per-peer AppState snapshot pushed to the UI so it can render @@ -256,6 +273,7 @@ pub(crate) async fn emit_peers(app: &AppHandle, store: Arc) { let dtos: Vec = list .into_iter() .map(|p| TrustedPeerDto { + active: crate::arbiter::is_active(&p.peer_static_pub), peer_static_pub: hex::encode(p.peer_static_pub), paired_at: p.paired_at, peer_name: p.peer_name, diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 177032d..9cee075 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -367,6 +367,9 @@ pub fn run() { pairing::pair_decision, pairing::forget_peer, pairing::forget_all, + pairing::switch_peer, + pairing::cancel_switch, + pairing::activate_peer, earbuds::refresh_local_earbuds, earbuds::open_bluetooth_settings, earbuds::scan_bluetooth_devices, diff --git a/linux/ui-tauri/src-tauri/src/pairing.rs b/linux/ui-tauri/src-tauri/src/pairing.rs index 7adbb63..c3e680c 100644 --- a/linux/ui-tauri/src-tauri/src/pairing.rs +++ b/linux/ui-tauri/src-tauri/src/pairing.rs @@ -237,3 +237,21 @@ pub fn forget_peer(peer_static_pub: String, state: State<'_, CmdChannel>) -> Res pub fn forget_all(state: State<'_, CmdChannel>) -> Result<(), String> { state.0.send(UiCmd::ForgetAll).map_err(|e| e.to_string()) } + +#[tauri::command] +pub fn switch_peer(state: State<'_, CmdChannel>) -> Result<(), String> { + state.0.send(UiCmd::SwitchPeer).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn cancel_switch(state: State<'_, CmdChannel>) -> Result<(), String> { + state.0.send(UiCmd::CancelSwitch).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn activate_peer(peer_static_pub: String, state: State<'_, CmdChannel>) -> Result<(), String> { + state + .0 + .send(UiCmd::ActivatePeer(peer_static_pub)) + .map_err(|e| e.to_string()) +} diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index 5554b35..f4fff5c 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -709,6 +709,11 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { UiCmd::Pair(addr_str) => cmd_pairing::pair(&ctx, addr_str, &mut active_scan).await, UiCmd::ForgetPeer(hex_str) => cmd_pairing::forget_peer(&ctx, hex_str).await, UiCmd::ForgetAll => cmd_pairing::forget_all(&ctx).await, + UiCmd::SwitchPeer => cmd_pairing::switch_peer(&ctx), + UiCmd::CancelSwitch => cmd_pairing::cancel_switch(&ctx).await, + UiCmd::ActivatePeer(hex_str) => { + cmd_pairing::activate_peer(&ctx, hex_str).await + } UiCmd::RefreshState => cmd_earbuds::refresh_state(&ctx).await, UiCmd::RefreshLocalEarbuds => cmd_earbuds::refresh_local_earbuds(&ctx).await, UiCmd::RequestEarbudsSwitch { peer_static_pub, mac } => { diff --git a/linux/ui-tauri/src/composables/useHome.ts b/linux/ui-tauri/src/composables/useHome.ts index 6c2f8cb..6788222 100644 --- a/linux/ui-tauri/src/composables/useHome.ts +++ b/linux/ui-tauri/src/composables/useHome.ts @@ -4,12 +4,14 @@ import type { UnlistenFn } from "@tauri-apps/api/event"; import { Battery, BatteryLow, BatteryMedium, BatteryFull, BatteryCharging } from "lucide-vue-next"; import { startScan, startPair, forgetPeer, refreshState, refreshLocalEarbuds, + switchPeer, cancelSwitch, activatePeer, requestEarbudsSwitch, sendEarbudsClaim, getSavedEarbuds, onSwitchState, type SwitchState, scanBluetoothDevices, saveEarbuds, clearEarbuds, onScanResult, onScanDone, onPairingStarted, onPairingResult, onPairingSas, + onSwitchScanning, onSwitchCandidates, pairDecision, onLocalEarbuds, onBusy, type ScanHit, type TrustedPeer, type PairingResultEvent, type PeerState, - type EarbudsSnapshot, type BluetoothDeviceRow, + type EarbudsSnapshot, type BluetoothDeviceRow, type SwitchCandidate, } from "@/lib/bridge"; import { initSmartSwitch } from "@/lib/smartSwitch"; import { initNotifMirror } from "@/lib/notifMirror"; @@ -431,6 +433,39 @@ export function onCardPressEnd() { } } +// ---- Switch to another already-paired phone ---- +// +// Distinct from `isSwitching` / `switchState` above, which are the EARBUDS +// handoff. This is the device switch: keep the current phone connected while +// looking for another trusted one, and only hand ownership over once a +// replacement is in hand (design doc §D3). + +/** True while the backend is scanning for other trusted phones. */ +export const peerSwitchScanning = ref(false); +/** Candidates from the last scan. Empty + not scanning = nothing found. */ +export const peerSwitchCandidates = ref([]); +/** Set once a scan completes so the UI can say "none found" rather than + * silently returning to the normal card. */ +export const peerSwitchNoneFound = ref(false); + +export async function startPeerSwitch() { + peerSwitchNoneFound.value = false; + peerSwitchCandidates.value = []; + await switchPeer(); +} + +export async function abortPeerSwitch() { + peerSwitchNoneFound.value = false; + peerSwitchCandidates.value = []; + await cancelSwitch(); +} + +export async function choosePeer(peerStaticPub: string) { + peerSwitchCandidates.value = []; + peerSwitchNoneFound.value = false; + await activatePeer(peerStaticPub); +} + // ---- Continuous BLE scan while no trust or pair-modal open ---- export async function runScanLoop() { if (scanLoopActive) return; @@ -559,6 +594,17 @@ export async function initHome() { scanHits.value.push(hit); })); unlisten.push(await onScanDone(() => (scanning.value = false))); + unlisten.push(await onSwitchScanning(on => { + peerSwitchScanning.value = on; + if (on) peerSwitchNoneFound.value = false; + })); + unlisten.push(await onSwitchCandidates(list => { + peerSwitchCandidates.value = list; + // An empty list after a scan means "nothing else in range" — the + // backend also emits empty to CLEAR the picker once a peer is + // adopted, which is why this only latches while not scanning. + peerSwitchNoneFound.value = list.length === 0 && !peerSwitchScanning.value; + })); unlisten.push(await onPairingStarted(e => { pairingPeer.value = e.peer_addr; pairingResult.value = null; diff --git a/linux/ui-tauri/src/lib/bridge.ts b/linux/ui-tauri/src/lib/bridge.ts index 273db24..5ffd227 100644 --- a/linux/ui-tauri/src/lib/bridge.ts +++ b/linux/ui-tauri/src/lib/bridge.ts @@ -17,6 +17,17 @@ export interface TrustedPeer { peer_static_pub: string; paired_at: number; peer_name?: string | null; + /** True for the peer that currently owns the session (see `arbiter` on the + * backend). With several trusted phones, "remembered" and "the one whose + * data is on screen" are different things. */ + active: boolean; +} + +/** A trusted peer found on air during a switch scan. */ +export interface SwitchCandidate { + peer_static_pub: string; + name?: string | null; + rssi: number; } /** @@ -67,6 +78,26 @@ export async function forgetPeer(peerStaticPub: string): Promise { await invoke("forget_peer", { peerStaticPub }); } +/** + * "Switch device": keep the current phone connected and look for another + * already-trusted one. Not a disconnect — the backend holds the active link + * for the whole scan, so a cancelled or fruitless switch leaves you exactly + * where you were. + */ +export async function switchPeer(): Promise { + await invoke("switch_peer"); +} + +export async function cancelSwitch(): Promise { + await invoke("cancel_switch"); +} + +/** Adopt this trusted peer as the active one (the user's pick, or the sole + * candidate found). */ +export async function activatePeer(peerStaticPub: string): Promise { + await invoke("activate_peer", { peerStaticPub }); +} + export async function forgetAll(): Promise { await invoke("forget_all"); } @@ -208,6 +239,16 @@ export function onScanDone(cb: () => void): Promise { return listen("vortex:scan_done", () => cb()); } +export function onSwitchScanning(cb: (scanning: boolean) => void): Promise { + return listen("vortex:switch_scanning", e => cb(e.payload)); +} + +export function onSwitchCandidates( + cb: (candidates: SwitchCandidate[]) => void, +): Promise { + return listen("vortex:switch_candidates", e => cb(e.payload)); +} + export function onPairingStarted(cb: (e: PairingStartedEvent) => void): Promise { return listen("vortex:pairing_started", e => cb(e.payload)); } diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index fd8a01c..57026b8 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -68,7 +68,12 @@ "offline": "Offline", "forget_title": "Forget device", "forget_body": "Stop trusting {name}? You'll need to re-pair to connect again.", - "forget_confirm": "Forget" + "forget_confirm": "Forget", + "switch_tip": "Switch to another paired phone", + "switch_scanning": "Looking for your other phones…", + "switch_pick": "Switch to which phone?", + "switch_none": "No other paired phone nearby.", + "switch_cancel": "Cancel" }, "discover": { "looking": "Looking for nearby devices…", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index 7875cd5..3db8c80 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -68,7 +68,12 @@ "offline": "Не в сети", "forget_title": "Забыть устройство", "forget_body": "Перестать доверять {name}? Чтобы вновь подключиться, потребуется повторное сопряжение.", - "forget_confirm": "Забыть" + "forget_confirm": "Забыть", + "switch_tip": "Переключиться на другой телефон", + "switch_scanning": "Поиск других ваших телефонов…", + "switch_pick": "На какой телефон переключиться?", + "switch_none": "Рядом нет другого сопряжённого телефона.", + "switch_cancel": "Отмена" }, "discover": { "looking": "Поиск устройств поблизости…", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index 2f08c9e..ac0d4d4 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -68,7 +68,12 @@ "offline": "Oflayn", "forget_title": "Qurilmani unutish", "forget_body": "{name} bilan aloqa uziladi. Qayta ulanish uchun yana pairing qilinadi.", - "forget_confirm": "Unutish" + "forget_confirm": "Unutish", + "switch_tip": "Boshqa telefonga o'tish", + "switch_scanning": "Boshqa telefonlaringiz qidirilmoqda…", + "switch_pick": "Qaysi telefonga o'tamiz?", + "switch_none": "Yaqinda boshqa ulangan telefon yo'q.", + "switch_cancel": "Bekor qilish" }, "discover": { "looking": "Yaqindagi qurilmalar qidirilmoqda…", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index b93ef04..f7287a6 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -16,6 +16,7 @@ import { Plus, BellRing, SwitchCamera, + TabletSmartphone, } from "lucide-vue-next"; import { activeEarbuds, @@ -33,7 +34,14 @@ import { primaryPeer, primaryPeerState, startMirror, + peerSwitchScanning, + peerSwitchCandidates, + peerSwitchNoneFound, + startPeerSwitch, + abortPeerSwitch, + choosePeer, } from "@/composables/useHome"; +import { peers } from "@/lib/connectionStore"; const { t } = useI18n(); @@ -153,6 +161,19 @@ const earbudsStatus = computed(() => { > + +
{
Charging + +
+
+
{{ t("peers.switch_pick") }}
+ + +
+
+
+
{{ t("peers.switch_none") }}
+
+
+
+
{{ t("peers.switch_scanning") }}
+ +
Pairable: peer list empty
or "Pair another laptop" + Pairable --> Active: pairing completes + Active --> Seeking: link lost
or "Switch laptop" + Seeking --> Active: a laptop connects + Seeking --> Dark: ladder exhausted + Dark --> Seeking: screen on + app foreground + Active --> Pairable: "Pair another laptop" + + note right of Active + advertises NOTHING + the session is the presence proof + end note + note right of Seeking + TRUSTED_PRESENCE + LOW_LATENCY → BALANCED → LOW_POWER + tokens multiplexed across peers + end note + note right of Dark + still advertises (BALANCED) + silence would break proximity lock + end note +``` + +Two constraints shape this: + +- **`Active` = silent** is the main battery win, and it is safe because the + laptop's proximity auto-lock treats *session OR advertisement* as presence. + Measured resume after a real disconnect: **16 ms** to back on air, against a + 25 s away-grace and a 2 s confirmation scan. +- **`Dark` still advertises.** Going fully silent would make a present-but-idle + phone look absent to that same confirmation scan, and would break walk-up + reconnect. + +With several remembered laptops the single advertising set **time-multiplexes** +tokens (~1.5 s dwell each), because one advertisement cannot address N peers. +With one peer it does not cycle at all — restarting the advertiser churns the +RPA for nothing. + +--- + +## 3. Channels + +```mermaid +graph TB + subgraph BLE["BLE — signalling, always available"] + CAP["Capability · READ
capability flags"] + PC["Pairing Control · WRITE + NOTIFY
Noise XX + SAS"] + RC["Reconnect Control · WRITE + NOTIFY
Noise IK + ping/pong"] + AS["Audio Signal · WRITE(+NR) + NOTIFY
sealed app-data frames"] + end + subgraph LAN["LAN — bulk, when reachable"] + MD["mDNS _vortex._tcp.local.
instance vortex-(16 hex)"] + TCP["TCP :51820
Noise IK → sealed frames"] + end + subgraph FAST["Opportunistic"] + WD["Wi-Fi Direct
5 GHz P2P, ~20 MB/s"] + MIR["Mirror: TCP control
+ UDP video"] + ADB["adb — screen features only"] + end + BLE --> LAN --> FAST +``` + +Despite the name, **Audio Signal is the general sealed app-data channel** — it +carries notifications, clipboard, SMS, call log, notes, file offers and the +handoff frames, not just audio. The name is historical. + +### Frame format + +``` +┌───┬─────┬────────────┬─────────────────────┐ +│ty │ sub │ len u16 BE │ payload (≤ 63 KiB) │ +└───┴─────┴────────────┴─────────────────────┘ + 0 1 2 4 +``` + +Everything after the handshake is AEAD-sealed with the Noise transport ciphers. +Frame types are a flat `u8` registry mirrored byte-for-byte between +`core/ble/frame.rs` and `Frame.kt`; drift there is a protocol break, so both +files carry that warning. + +| Range | Purpose | +|---|---| +| `0x10–0x12` | pairing handshake / approval | +| `0x20–0x21` | reconnect handshake | +| `0x30–0x3F` | keepalive, app-state, notifications, icons, calls, contacts, SMS, call log, bulk-sync | +| `0x40–0x4F` | clipboard, Wi-Fi Direct, file push, browsing handoff, notes, FRAG, **peer handoff** | +| `0x7F` | error | + +Unknown frame types are **logged and ignored** on both sides. That is what makes +new types additive: an older peer is unaffected, so no version gate is needed. + +`FRAG` (`0x4E`) exists because Android silently truncates a notify longer than +`ATT_MTU−3`. The sender splits the *already-sealed* frame into fragments; the +inner frame keeps one nonce for the whole logical frame. + +> **Note on `shared/proto/vortex.proto`:** it describes the protocol at spec +> level, but the live BLE/LAN transport does **not** use `VortexMessage`. It +> uses the `Frame` format above. When adding a message, the frame registry is +> the thing that changes behaviour. + +--- + +## 4. Pairing (first contact) + +```mermaid +sequenceDiagram + participant P as 📱 Phone + participant L as 💻 Laptop + Note over P: user opens pairing window
flags = PAIRABLE + P-->>L: ADV_IND · pairable · instance ID + Note over L: radar shows the device
(pairable hits only) + L->>P: GATT connect + L->>P: Pairing Control ← Noise XX msg1 + P->>L: NOTIFY → msg2 + L->>P: Pairing Control ← msg3 + Note over P,L: SAS = 3 emoji derived from
the transcript hash + Note over P,L: user compares on both screens + L->>P: approve + P->>L: approve + Note over P,L: trust stored: peer_static_pub + PRS
keyring (laptop) / EncryptedSharedPrefs (phone) + Note over P: hands radio to VortexService
flags → TRUSTED_PRESENCE +``` + +The SAS comparison is the MITM defence, and it happens **before** any BT-level +bond. Vortex deliberately creates **no BT bond on Linux** — LE bonding was +investigated and abandoned (BlueZ routes bonds over BR/EDR on dual-mode phones, +Just Works fails, no IRK). Reconnect is bondless: learn-latest-RPA plus a +presence scan. + +A stale one-sided bond — one side holding a bond the other dropped — makes the +link tear down during encryption, so `ServicesResolved` never arrives and +pairing fails with `timeout: service discovery`. Forget now clears the bond on +both sides for exactly this reason. + +--- + +## 5. Reconnect and steady state + +```mermaid +sequenceDiagram + participant P as 📱 Phone + participant L as 💻 Laptop + + rect rgb(240, 245, 255) + Note over L: BLE path + P-->>L: ADV_IND · trusted-presence · token + Note over L: token matches a trusted peer's
current bucket ±2 + L->>P: connect (last-known RPA first, else scan) + L->>P: Reconnect Control ← Noise IK msg1 + counter + P->>L: msg2 + peer counter + L->>P: ping → pong + Note over P,L: session live · counters bumped + end + + rect rgb(240, 255, 245) + Note over L: LAN path (parallel, independent) + P-->>L: mDNS announce · vortex-(token) + L->>P: TCP :51820 + Noise IK + L->>P: bulk-sync request (per-dataset hashes + watermarks) + P->>L: only what differs: contacts / sms / call_log / histories + loop every ~12 s + L->>P: AppState heartbeat + P->>L: AppState (battery, earbuds, locked, media) + end + end +``` + +Both transports run **independently and concurrently**. BLE gives fast +signalling that works with no network at all; LAN gives throughput. Either +alone is a working link, which is why the phone can read "connected" over BLE +on a Wi-Fi network with AP isolation where the LAN heartbeat never completes. + +A cross-transport hint links them: the LAN heartbeat's down→up edge nudges the +BLE loop to retry its direct connect immediately instead of waiting out a scan +backoff. + +Bulk-sync is diff-based — the laptop sends what it already has, the phone sends +only the delta, and each dataset reports `match` / `sent` / `error` +independently so one failing dataset cannot kill the connection. + +--- + +## 6. Session ownership — one active peer + +A device may **trust** many peers but is **active** with exactly one. Those are +deliberately separate: several transport links may briefly overlap during a +handoff, but only one peer owns the mirrored state (notifications, clipboard, +SMS pages, media). Without the split, connecting to a replacement before the old +link finished dropping would give two devices ownership at once. + +```mermaid +sequenceDiagram + participant A as 💻 Laptop A (current) + participant P as 📱 Phone + participant B as 💻 Laptop B (target) + + Note over P: user taps "Switch laptop" + Note over P,A: link to A is HELD — nothing dropped yet + P-->>B: TRUSTED_PRESENCE (A excluded from
the multiplexed token set) + B->>P: connect + Noise IK + Note over P,B: ownership moves to B + P->>A: PEER_HANDOFF · RELEASE · successor name + Note over A: drop ownership, purge cached pages,
clear "active" on the card + Note over P,A: A's transport link drops on its own +``` + +**Seek before release** is the key property: the current link is held until a +replacement is confirmed. So a cancelled or fruitless switch leaves the device +exactly where it was, and there is no window in which it is connected to +nothing — which also means no suppression window is needed to stop the old +peer's reconnect loop racing back. + +`PEER_HANDOFF` (`0x4F`) kinds: `RELEASE` (0x01), `BUSY` (0x02), `CLAIM` (0x03). +`BUSY` exists so a refused peer can back off — silence is indistinguishable from +packet loss and invites a retry loop against the phone's single GATT link. + +The mirror direction is identical: when the laptop switches phones, it sends +`RELEASE` to the phone it displaced. + +**Current limitation:** `RELEASE` rides the BLE sealed channel only. A switch +that happens with only a LAN session up will not deliver it, and the peer falls +back to noticing on next contact. + +--- + +## 7. Opportunistic transports + +**Wi-Fi Direct** — for bulk file transfer the phone creates a 5 GHz P2P group +and the laptop joins it via `nmcli`, pulls at ~20 MB/s, then restores its normal +Wi-Fi. Single adapter, so the laptop is briefly offline; the heartbeat targets +the group-owner IP while active. + +**Screen mirroring** splits control from data: TCP carries control, **UDP +carries video**. TCP head-of-line blocking plus retransmit is exactly the +freeze-then-jump artefact to avoid. Security is kept without Noise's strict +in-order nonce by deriving a media key from the IK handshake hash and sealing +each datagram with ChaCha20-Poly1305 under an explicit per-packet counter, +guarded by a sliding replay window. + +**adb** is used only for the screen features (Universal Control, second screen), +because writing to `/dev/uinput` requires the shell user. It carries no Vortex +protocol traffic. + +--- + +## 8. Per-peer state + +Secrets are keyed by `peer_static_pub` on both sides — PRS, reconnect counters, +audio nonces, the peer's BT address. + +Cached phone data on the laptop is namespaced by public key, not by name (a +peer-supplied display name is untrusted, collides, and changes): + +``` +~/.cache/vortex/peers//{sms,contacts,call_log,…}.json +``` + +Deliberately **global**, because they are one shared list by design: +`notes.json` and clipboard history. + +--- + +## 9. Security properties in one place + +| Property | Mechanism | +|---|---| +| MITM defence at pairing | Noise XX + 3-emoji SAS compared by the user | +| Reconnect authentication | Noise IK, PRS as prologue | +| Replay / rollback detection | monotonic per-peer counters exchanged in IK | +| Confidentiality of app data | AEAD-sealed frames on the Noise transport | +| Advertisement unlinkability | 8-byte token rotating per 60 s bucket, per peer | +| Revocation | forgetting deletes the PRS, so its token can no longer be derived | +| Video data plane | media key from the IK hash, per-packet counter + replay window | + +Per-peer tokens (rather than one shared device key) are what make revocation +clean: a forgotten peer's token is not merely rejected, it becomes +uncomputable, so that device is exactly as able to find the phone as a +stranger's — not at all. diff --git a/docs/design/multi-peer.md b/docs/design/multi-peer.md new file mode 100644 index 0000000..055d9ef --- /dev/null +++ b/docs/design/multi-peer.md @@ -0,0 +1,265 @@ +# Multi-peer pairing: one phone ↔ many laptops, one laptop ↔ many phones + +**Status:** design, not implemented. **Scope:** V1.x, no wire break. + +Goal: a phone may remember several laptops and a laptop several phones, with +**at most one active link at a time**. Switching between remembered devices must +not require forgetting one of them. + +The "not simultaneously" constraint is what keeps this small. It removes session +multiplexing, concurrent transports, and conflict resolution on shared state. +What remains is: be *discoverable* by all remembered peers, *pick* one, and +*scope* per-peer state. + +--- + +## 1. What already works + +Multi-peer was partly anticipated. Verified in the current tree: + +| Capability | Where | State | +|---|---|---| +| Laptop accepts *any* trusted peer's presence token | `linux/ui-tauri/src-tauri/src/ble.rs:100` (`expected_presence_tokens`) | iterates all peers ✅ | +| Phone's IK responder identifies *which* peer is calling | `ReconnectOrchestrator.kt:132` — tries each peer's PRS as prologue | multi-peer ✅ | +| Per-peer secrets (PRS, counters, nonces, bonded addr) | Secret Service attrs keyed by `peer_static_pub` | already per-peer ✅ | +| Laptop can pair-scan while trust exists | `useHome.ts` `runScanLoop` condition includes `showPairPhoneModal` | ✅ | +| Protocol is additively extensible | `shared/proto/vortex.proto` — *"New payload types must go through `VortexMessage.payload` oneof only"*, plus `capability_flags` | ✅ | + +So this is not a rewrite. + +## 2. The blocker + +Discovery identity is welded to exactly one peer: + +``` +token = HMAC-SHA256(peer.prs, "vortex/v1/presence" ‖ u64_be(bucket))[0..8] +``` + +There is one 8-byte slot, and the ADV_IND is already at the legacy 31-byte +ceiling (3-byte Flags AD + 28-byte Service Data AD) — no room for a second +token. The *same* construction names the mDNS record +(`LanServer.kt:derivePrivateInstanceName`). + +Both call sites resolve the peer with `peerStore.list().firstOrNull()`. So "who +can find me" is a per-PRS property fixed to peer[0], and forgetting is the only +way to change it. Everything else below is downstream of this one fact. + +--- + +## 3. Decisions + +### D1 — Keep per-peer tokens; time-multiplex one advertising set + +While seeking, the phone cycles candidate peers on a single advertising set, +dwelling ~1–2 s per token. Worst-case discovery is `(N−1) × dwell` — a few +seconds, on a deliberate, user-initiated handoff. + +**Rejected: one shared device-level presence key** (`PRS_pres` distributed at +pairing, so a single token serves every peer). It is cheaper on air, but: + +- it is a wire-contract change, and +- a *revoked* laptop retains the ability to recognise the phone until the key is + rotated and redistributed to every survivor. + +Per-peer tokens have no such revocation hole: forgetting a peer deletes its PRS, +so the phone can no longer derive that token at all. A forgotten laptop becomes +exactly as able to find the phone as a stranger's — not at all. **Per-peer tokens +are both cheaper to ship and strictly better for revocation.** + +**Rejected: N concurrent advertising sets.** Viable on hardware (the test phone +reports `max_adv_instances: 16`), but costs N× radio duty cycle permanently, and +OEM background-advertising throttling — already documented in `Advertiser.kt` — +makes it degrade unevenly across phones. Unnecessary once seeking is rare. + +### D2 — No wire break, and **no new advertising flag bit** + +`AdvFlags::is_well_formed()` (Rust `core/ble/mod.rs`, mirrored in +`AdvPayload.kt:29`) requires reserved bits zero **and exactly one** of +bit0 `PAIRABLE` / bit1 `TRUSTED_PRESENCE`. A third "SEEKING" mode bit would be +rejected as malformed by **every deployed peer**. + +Seeking therefore advertises `flags = TRUSTED_PRESENCE` and varies only +`AdvertiseSettings` mode and dwell. The state is local; the wire is unchanged. + +### D3 — "Switch" seeks *before* it releases + +Switch is **not** a release. The current link is held while the device scans (or +advertises) for another *already-remembered* peer. Only once a replacement is +identified — and chosen, if there is more than one — is the old link dropped. + +This is why there is no standby/suppression window: the device never lets go, so +its own reconnect loop has nothing to race back into. The current peer is +excluded from the candidate set by construction. Notably it also cannot get +stuck connected to nothing. + +Switch is **not** Forget. Forget already exists and remains the escape hatch for +a peer that is out of reach (see §7). + +### D4 — Separate *connected* from *active* + +The arbiter owns exactly one `activePeer`. Confirming a switch flips ownership +**atomically**, demoting the old peer with an explicit "no longer active" reason. + +Transport teardown may then be lazy. Without this split, "connect to B and let +backoff drop A" leaves two live links that both mirror notifications and sync +clipboard — duplicated state, and a violation of the one-active-link rule. With +it, overlapping *connections* are harmless because only one is *active*. + +Losers of an arbitration race must receive an explicit **busy** refusal so they +back off rather than hammering the phone's single GATT link. + +### D5 — Phone advertising state machine + +| State | Advertising | Enter on | +|---|---|---| +| `Pairable` | LOW_LATENCY, `PAIRABLE` flag | peer list **empty**, or explicit "Add pair" | +| `Active` | **none** | `activePeer` connected | +| `Seeking` | `TRUSTED_PRESENCE`, backoff ladder, tokens multiplexed (D1) | Switch pressed, or link down | +| `Dark` | **today's steady-state advertising** (see below) | seeking ladder exhausted | + +`Active` = silent is the main battery win: the phone is connected most of the +time, and today it advertises 24/7 regardless. + +Ladder: LOW_LATENCY ≈ 30 s (the user is walking to the other machine) → +BALANCED a few minutes → LOW_POWER → `Dark`. + +**`Dark` keeps advertising** rather than going silent. Two hard reasons: + +1. **Proximity lock depends on it.** `proximity.rs` treats "not away" as *active + session **or** token-validated advertisement*, with `AWAY_GRACE_MS = 25_000` + and a `CONFIRM_SCAN_MS = 2_000` last-chance scan whose sizing comment + explicitly assumes *"a present phone is in the reconnect-seeking LOW_LATENCY + tier (~100 ms adv) when this runs."* A silent-but-present phone would be + locked out spuriously. The ladder floor must stay detectable inside a 2 s + scan. +2. **It preserves the walk-back-to-desk auto-reconnect** promised in the README + ("devices reconnect on their own"), which a silent `Dark` would regress. + +`Active` = silent is nonetheless safe for proximity, because a live session is +itself the presence proof. + +### D6 — Exiting `Dark` + +Trigger: **screen on + Vortex app brought to foreground/focus.** Wanting to +reconnect is a deliberate act; requiring the app in front is acceptable UX. + +**Rejected: exit `Dark` on joining a Wi-Fi network where a peer was last seen.** +It is nearly free (mDNS, no BLE) and was tempting, but Wi-Fi coverage is much +larger than BLE and can be spotty. A walk-away could drop BLE, drop Wi-Fi, then +*re-acquire* Wi-Fi while still far from the laptop, reconnecting and letting the +laptop unlock from well outside BLE range. That defeats proximity lock/unlock. +LAN must not be used as a proximity signal. + +Since `Dark` still advertises (D5), the cost of dropping this is small. + +### D7 — Namespace per-peer state by public key, not name + +``` +~/.cache/vortex/peers//{sms,contacts,call_log}.json +``` + +Display name lives *inside* the folder as data. The peer name arrives from the +peer's APPROVE payload and is already sanitised on both sides +(`sanitize_peer_name`) precisely because it is untrusted — it must never become +a path component. Names also collide ("Laptop"), change, and may be non-ASCII. + +Splits per peer: SMS, contacts, call log, notifications, media state. +Stays global (deliberately one shared list): notes/todos, clipboard history. + +### D8 — UI + +**Laptop (Tauri).** The connected-peer card gains a header "Switch device" +icon-button. Disabled when no peer is connected — with nothing connected the +device is already seeking and will find any remembered peer on its own, so the +button has no work to do. Prefer an inline "Switching… **Cancel**" affordance +over a confirm modal: the action is cheap and reversible during the seek window. + +Icon must be a bundled inline SVG, never a CDN reference — the app is +offline-first by design, and hotlinked icon sets carry attribution terms. It has +to read at 16–20 px, so keep any phone glyph *outside* the arrow arc; nested +detail mushes at that size. + +**Phone.** Mirror the Switch button on the connected-laptop card, plus an "Add +pair" button (the laptop already has one) so pairable mode is reachable while +trust exists — today `MainActivity.onResume` only auto-enters pairable when +`peerStore.list().isEmpty()`. + +**Picker.** When more than one candidate is found, ask which to connect to, MRU +order, with last-seen. Excludes the currently-active peer (D3). A single +candidate connects automatically. This also handles two laptops seeking the same +phone: the phone asks, rather than silently arbitrating. + +### D9 — Bound the seek + +Switch must have an auto-expiry and a visible Cancel. Seeking *on top of* a live +connection is the most expensive state in the system, so an unattended Switch +(press it, walk to a room with no laptop) must not scan indefinitely — it +returns to `Active`. + +--- + +## 4. Protocol additions + +Additive only, gated on the existing `capability_flags`: + +- `PeerHandoff` in the `VortexMessage.payload` oneof — carries "release me / you + are no longer active", with a reason code (`switched`, `busy`, `revoked`). + +Old peers ignore an unknown oneof field; the capability bit prevents sending it +to a peer that would not act on it. No change to `AdvPayload`, the advertising +flags, the GATT UUIDs, or the token derivation. + +## 5. Sites to change + +**Android — `firstPeer` → `activePeer`:** + +| Site | Role | +|---|---| +| `MainActivityPairing.kt:234` | advertising-mode selection — *the blocker* | +| `LanServer.kt:1039` | mDNS instance name | +| `VortexStack.kt:678, 721, 740, 752, 899` | service-stack peer binding | +| `MainActivityEarbuds.kt:77` | earbuds switch target | +| `HomeScreen.kt:107` | UI primary card → device list | + +**Linux:** no first-peer assumptions in trust/session logic. Work is the cache +namespacing (D7), the arbiter (D4), and the Switch UI (D8). + +## 6. Sequencing + +1. **Forget drops the BT bond on both sides** + **"Add pair" on the phone**. + No protocol work. Unblocks the immediate pain. +2. **Per-peer cache namespacing** (D7). Prerequisite for laptop ↔ N phones. +3. **Arbiter + Switch flow** (D3, D4, D8, D9) and the phone state machine (D5, + D6). + +Steps 1–2 are independent and separately shippable. + +## 7. Prerequisite: Forget is currently broken + +`BondCleaner.removeBond` is reachable **only** from the DEBUG dev-hook intent +(`MainActivity.kt:269`); `onForgetPeerClicked` never calls it, and +`cmd_pairing.rs` has no `remove_device`/unpair either. So Vortex's Forget leaves +the BT bond in place on *both* sides. + +A one-sided bond — laptop's bond dropped, phone's retained — makes the link tear +down during encryption, so `ServicesResolved` never arrives and pairing fails +with `timeout: service discovery`. Observed live 2026-08-25; it is the cause of +"retry pairing several times until it works". + +Because Switch is disabled when the peer is absent (D8), **Forget is the only +escape from a phone bound to an unreachable laptop.** That escape hatch has to +work before this design can rely on it. + +## 8. Open risks + +- **Multiplex dwell vs. OEM throttling.** Restarting an advertising set every + 1–2 s may land in a slower throttle tier on aggressive ROMs, and each restart + re-randomises the RPA. The laptop matches on token, not address, so this is + correctness-safe, but it inflates BlueZ's device cache — a known source of + stale-RPA connect attempts. Needs measurement on a throttling ROM. +- **`Dark` re-entry latency.** Requiring app-foreground (D6) means a phone that + ladder-expired needs a deliberate user action to become *fast* again. Mitigated + by `Dark` still advertising, so reconnect works, just slower. +- **Arbitration during simultaneous switch.** Both sides pressing Switch at once + is untested territory; the atomic `activePeer` flip (D4) should make it safe + but needs an explicit test. From 3a2cffac015d03feb8c0bf2c79bbdaa24fcb7e0e Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 16:14:24 +0200 Subject: [PATCH 11/71] fix(share): don't crash the app on an oversized file share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing an 835 MB file killed the whole process. Captured on device: OutOfMemoryError: Failed to allocate a 876288016 byte allocation with 25165824 free bytes and 253MB until OOM, growth limit 268435456 at ClipboardFileReader.read(ClipboardFileOut.kt:27) at ShareReceiverActivity.onCreate(ShareReceiverActivity.kt:81) ActivityManager: Killing 15355:io.github.zoir_dev.vortex (adj 50): crash Three defects stacked: * `readBytes()` buffered the entire file BEFORE anything checked its size, so the 64 MB guard was unreachable for exactly the files it existed to stop. 876 MB cannot be allocated against a 256 MB growth limit. * `OutOfMemoryError` is an Error, not an Exception, so `catch (e: Exception)` did not catch it. It escaped onCreate and took the process down — with the BLE/LAN service and the notification listener. That is why the user saw a crash and no message: the app was dead before it could report anything. * Even the graceful path only wrote to logcat, so a rejected share looked identical to a broken one from the outside. Now: the size is pre-flighted from OpenableColumns.SIZE before anything is allocated; the read is bounded to cap+1 bytes so a provider that misreports or omits the size still cannot blow the heap; OutOfMemoryError is caught narrowly as a backstop for that case; and the toast names the real reason with real numbers ("File is too big to send (835 MB; limit 64 MB)") instead of "Couldn't read the shared file(s)", which is what made a deliberate limit look like a malfunction. This does NOT make large files transferable — the 64 MB cap and the buffer-the-whole-file design are still there, and raising the constant would only move the OOM. Ranged streaming is the actual fix; see the file-browsing design doc, which needs the same primitive. Verified on device: same 835 MB file now shows the size toast and the app survives. Co-Authored-By: Claude Opus 5 --- .../a3/core/clipboard/ClipboardFileOut.kt | 109 +++++++++++++----- .../core/clipboard/ShareReceiverActivity.kt | 36 ++++-- 2 files changed, 103 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt index 7ada56b..f8e4543 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt @@ -20,51 +20,98 @@ object ClipboardFileReader { private const val TAG = "ClipboardFileOut" - fun read(context: Context, uri: Uri): ClipboardOutgoingFile? { - return try { - readInner(context, uri) - } catch (e: Exception) { - Log.w(TAG, "file read failed: ${e.message}") - null - } + /** Outcome of a read, so the caller can tell the user something true + * instead of a generic "couldn't read the shared file". */ + sealed class Outcome { + data class Ok(val file: ClipboardOutgoingFile) : Outcome() + /** Bigger than [MAX_FILE_BYTES]; [bytes] is the best size we know. */ + data class TooLarge(val bytes: Long) : Outcome() + /** Unreadable, empty, or it would not fit in memory. */ + data class Unreadable(val why: String) : Outcome() } - private fun readInner(context: Context, uri: Uri): ClipboardOutgoingFile? { + /** + * Read [uri] into memory, or explain why not. + * + * **The size is checked BEFORE the bytes are read.** It used to be checked + * after `readBytes()`, which made the guard unreachable for exactly the + * files it existed to stop: an 835 MB share allocated 876 MB against a + * 256 MB heap growth limit and threw `OutOfMemoryError` at the read. That + * is an `Error`, not an `Exception`, so the old `catch (e: Exception)` did + * not catch it — it escaped `ShareReceiverActivity.onCreate` and killed the + * whole process, taking the BLE/LAN service down with it. The user saw a + * crash and no explanation. + * + * `OutOfMemoryError` is still caught below, because a pre-check can only + * use the size the provider *reports*: `OpenableColumns.SIZE` is absent or + * -1 for plenty of providers, and a wrong one must not be able to kill the + * app either. + */ + fun read(context: Context, uri: Uri): Outcome { val cr = context.contentResolver val mime = cr.getType(uri) ?: "application/octet-stream" val name = displayName(context, uri) ?: "file" - // Ask how big it is BEFORE reading it. `readBytes()` pulls the whole - // file into the service's heap, so checking the cap afterwards means - // the one thing the cap exists to prevent — a file far too large to - // hold — has already happened. Harmless while this only ever saw - // screenshots; a phone video is hundreds of megabytes. - val declared = declaredSize(context, uri) - if (declared != null && declared > MAX_FILE_BYTES) { - Log.i(TAG, "file too large ($declared bytes) — not sent") - return null + + // Pre-flight: refuse before allocating anything. A negative or absent + // size means "provider doesn't know" — fall through and let the + // bounded read below decide. + val reported = reportedSize(context, uri) + if (reported > MAX_FILE_BYTES) { + Log.i(TAG, "file too large ($reported bytes > $MAX_FILE_BYTES) — not sent") + return Outcome.TooLarge(reported) } - val bytes = cr.openInputStream(uri)?.use { it.readBytes() } - return when { - bytes == null -> null - bytes.isEmpty() -> null - // Backstop: SIZE is provider-supplied and may be absent or wrong. - bytes.size > MAX_FILE_BYTES -> { - Log.i(TAG, "file too large (${bytes.size} bytes) — not sent") - null + + return try { + // Bounded even when the provider lied about (or omitted) the size: + // read at most the cap + 1 byte, so an oversized stream is detected + // without ever buffering it whole. + val bytes = cr.openInputStream(uri)?.use { it.readAtMost(MAX_FILE_BYTES + 1) } + when { + bytes == null -> Outcome.Unreadable("no input stream") + bytes.isEmpty() -> Outcome.Unreadable("empty file") + bytes.size > MAX_FILE_BYTES -> { + Log.i(TAG, "file exceeds cap (provider reported $reported) — not sent") + Outcome.TooLarge(maxOf(reported, bytes.size.toLong())) + } + else -> Outcome.Ok(ClipboardOutgoingFile(bytes, name, mime)) } - else -> ClipboardOutgoingFile(bytes, name, mime) + } catch (e: OutOfMemoryError) { + // Reachable only when the reported size was wrong/absent. Catching + // an Error is deliberate and narrow: the alternative is the process + // dying and every Vortex feature with it. + Log.w(TAG, "file read ran out of memory: ${e.message}") + Outcome.Unreadable("too large to buffer") + } catch (e: Exception) { + Log.w(TAG, "file read failed: ${e.message}") + Outcome.Unreadable(e.message ?: "read failed") } } - /** The provider's own SIZE for [uri], or null when it doesn't report one. */ - private fun declaredSize(context: Context, uri: Uri): Long? = try { + /** `OpenableColumns.SIZE`, or -1 when the provider does not report one. */ + private fun reportedSize(context: Context, uri: Uri): Long = try { context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) ?.use { c -> val idx = c.getColumnIndex(OpenableColumns.SIZE) - if (c.moveToFirst() && idx >= 0 && !c.isNull(idx)) c.getLong(idx) else null - } + if (c.moveToFirst() && idx >= 0 && !c.isNull(idx)) c.getLong(idx) else -1L + } ?: -1L } catch (_: Exception) { - null + -1L + } + + /** Read at most [limit] bytes. Unlike `readBytes()` this never allocates + * more than the caller is prepared to accept. */ + private fun java.io.InputStream.readAtMost(limit: Long): ByteArray { + val cap = limit.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val out = java.io.ByteArrayOutputStream(minOf(cap, 64 * 1024)) + val buf = ByteArray(64 * 1024) + var total = 0 + while (total < cap) { + val n = read(buf, 0, minOf(buf.size, cap - total)) + if (n <= 0) break + out.write(buf, 0, n) + total += n + } + return out.toByteArray() } private fun displayName(context: Context, uri: Uri): String? = try { diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt index d93de29..87f8f6d 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt @@ -77,25 +77,39 @@ class ShareReceiverActivity : Activity() { } var sent = 0 + var tooLarge = 0 + var largestRejected = 0L for (uri in uris) { - val file = ClipboardFileReader.read(this, uri) - if (file != null) { - // `tryEmit` returning false means the buffer was full and this - // file went nowhere — which used to happen silently, and then be - // counted as sent. Count only what the bus actually took, so the - // toast tells the truth. - if (VortexService.clipboardFileBus.tryEmit(file)) { + when (val outcome = ClipboardFileReader.read(this, uri)) { + is ClipboardFileReader.Outcome.Ok -> { + val file = outcome.file + VortexService.clipboardFileBus.tryEmit(file) Log.i(TAG, "share: forwarded file '${file.name}' (${file.bytes.size} bytes)") sent++ - } else { - Log.w(TAG, "share: bus full, dropped '${file.name}'") } - } else { - Log.w(TAG, "share: couldn't read $uri") + is ClipboardFileReader.Outcome.TooLarge -> { + tooLarge++ + largestRejected = maxOf(largestRejected, outcome.bytes) + Log.w(TAG, "share: $uri over the size cap (${outcome.bytes} bytes)") + } + is ClipboardFileReader.Outcome.Unreadable -> { + Log.w(TAG, "share: couldn't read $uri (${outcome.why})") + } } } + // Name the actual reason. "Couldn't read the shared file" for a file the + // user can see perfectly well is what made an over-cap share look like a + // bug rather than a limit — and before the size pre-check, a big one + // took the whole app down without saying anything at all. val msg = when { + sent == 0 && tooLarge > 0 -> { + val cap = ClipboardFileReader.MAX_FILE_BYTES / (1024 * 1024) + val mb = largestRejected / (1024 * 1024) + if (tooLarge == 1) "File is too big to send ($mb MB; limit $cap MB)" + else "$tooLarge files are too big to send (limit $cap MB)" + } sent == 0 -> "Couldn't read the shared file(s)" + tooLarge > 0 -> "Sending $sent; $tooLarge too big to send" sent == 1 -> "Sending file to laptop…" else -> "Sending $sent files to laptop…" } From dd10c9def517799d6a28cec9ab2ce08e421ff124 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:33:09 +0200 Subject: [PATCH 12/71] fix(share): queue a multi-file share instead of silently dropping most of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing 150 files delivered about 20 and reported success for all of them. Two silent losses compounded: * `clipboardFileBus` had 4 slots with DROP_OLDEST. The collector (read → stash → JSON → BLE notify) cannot drain that as fast as a share loop fills it, so the overflow was discarded. Worse, `tryEmit` returns TRUE on a drop, so the sender counted every file as sent — the toast promised ~130 files that never existed. * ClipboardBlobStore evicts past MAX_ENTRIES, and the laptop pulls one file at a time, so a queued file's bytes could be gone before its turn. Capping the batch would make that honest but refuses work the user asked for, which is worse UX than taking longer. So the whole list is accepted and paced: * ShareQueue holds URIs, NOT bytes, and reads each file on its turn. Reading 150 files up front is what made an 835 MB share an OutOfMemoryError; memory is now flat whether the batch is 5 files or 500. * At most WINDOW files are in flight, measured against `pendingOffers` — offers the laptop has not collected — so pacing follows real delivery rather than a timer, and never exceeds what the blob store holds. * Progress is ONE updating notification with a real progress bar, replacing the per-file "File sent: X" toast. 150 toasts was its own bug. * Files too large or unreadable are counted and reported at the end instead of aborting the batch. Deferring the read moved it out of the Activity (which finishes immediately) into the service, and the share sheet's read grant does not reach that far on its own — it is carried across via ClipData + FLAG_GRANT_READ_URI_PERMISSION. Plain intent extras would have handed the service URIs it could not open. The bus keeps a backstop: buffer sized to MAX_ENTRIES and overflow switched to SUSPEND, so `tryEmit` now reports refusal instead of discarding, and the queue retries rather than lying. Verified on device: 65 files, all 65 delivered. Co-Authored-By: Claude Opus 5 --- .../a3/core/clipboard/ClipboardBlobStore.kt | 9 +- .../core/clipboard/ShareReceiverActivity.kt | 78 ++++---- .../java/com/vortex/a3/service/ShareQueue.kt | 177 ++++++++++++++++++ .../com/vortex/a3/service/VortexService.kt | 54 +++++- .../java/com/vortex/a3/service/VortexStack.kt | 20 ++ .../a3/service/VortexStackOfferRetry.kt | 6 +- 6 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt index 2bbbabc..d1eeb31 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardBlobStore.kt @@ -18,7 +18,14 @@ import java.security.MessageDigest * hashed once from a read that is then dropped. */ object ClipboardBlobStore { - private const val MAX_ENTRIES = 32 + /** + * Blobs kept before the oldest is evicted — and therefore the hard ceiling + * on how many files ONE share can deliver: the laptop pulls them one at a + * time over LAN, so a blob evicted before its turn is a file that silently + * never arrives. Callers cap their batch at this, so the two numbers cannot + * drift apart (see ShareReceiverActivity.MAX_SHARE_FILES). + */ + const val MAX_ENTRIES = 32 // Insertion-ordered so eviction drops the oldest first. private val blobs = LinkedHashMap ByteArray?>() diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt index 87f8f6d..ec7a42c 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ShareReceiverActivity.kt @@ -76,42 +76,52 @@ class ShareReceiverActivity : Activity() { } } - var sent = 0 - var tooLarge = 0 - var largestRejected = 0L - for (uri in uris) { - when (val outcome = ClipboardFileReader.read(this, uri)) { - is ClipboardFileReader.Outcome.Ok -> { - val file = outcome.file - VortexService.clipboardFileBus.tryEmit(file) - Log.i(TAG, "share: forwarded file '${file.name}' (${file.bytes.size} bytes)") - sent++ - } - is ClipboardFileReader.Outcome.TooLarge -> { - tooLarge++ - largestRejected = maxOf(largestRejected, outcome.bytes) - Log.w(TAG, "share: $uri over the size cap (${outcome.bytes} bytes)") - } - is ClipboardFileReader.Outcome.Unreadable -> { - Log.w(TAG, "share: couldn't read $uri (${outcome.why})") - } - } + // Hand the whole list to the service and let it pace itself. + // + // Deliberately NOT read here: reading every file up front is what made + // an 835 MB share an OutOfMemoryError, and what made a 150-file share + // lose most of its files to buffer overflow. The service reads each + // file on its turn (see ShareQueue), so memory is flat and the batch is + // bounded by real delivery instead of a cap that refuses work. + // + // ClipData + FLAG_GRANT_READ_URI_PERMISSION is what carries the share + // sheet's read grant across to the service; plain extras would not. + if (uris.isEmpty()) { + // Nothing readable in the share and the text path above did not + // claim it. `uris.first()` below would throw. + Log.w(TAG, "share: no URIs and no text — nothing to do") + Toast.makeText(this, "Nothing to send", Toast.LENGTH_SHORT).show() + finish() + overridePendingTransition(0, 0) + return } - // Name the actual reason. "Couldn't read the shared file" for a file the - // user can see perfectly well is what made an over-cap share look like a - // bug rather than a limit — and before the size pre-check, a big one - // took the whole app down without saying anything at all. - val msg = when { - sent == 0 && tooLarge > 0 -> { - val cap = ClipboardFileReader.MAX_FILE_BYTES / (1024 * 1024) - val mb = largestRejected / (1024 * 1024) - if (tooLarge == 1) "File is too big to send ($mb MB; limit $cap MB)" - else "$tooLarge files are too big to send (limit $cap MB)" + val clip = android.content.ClipData.newUri(contentResolver, "vortex-share", uris.first()) + for (u in uris.drop(1)) clip.addItem(android.content.ClipData.Item(u)) + val svc = android.content.Intent(this, VortexService::class.java).apply { + action = VortexService.ACTION_ENQUEUE_SHARE + clipData = clip + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + startForegroundService(svc) + } else { + startService(svc) } - sent == 0 -> "Couldn't read the shared file(s)" - tooLarge > 0 -> "Sending $sent; $tooLarge too big to send" - sent == 1 -> "Sending file to laptop…" - else -> "Sending $sent files to laptop…" + } catch (e: Exception) { + Log.w(TAG, "couldn't hand the share to the service: ${e.message}") + Toast.makeText(this, "Couldn't start the transfer", Toast.LENGTH_SHORT).show() + finish() + overridePendingTransition(0, 0) + return + } + Log.i(TAG, "share: handed ${uris.size} file(s) to the queue") + // One toast. Per-file progress is the notification the queue maintains — + // a toast per file meant 150 toasts for a 150-file share. + val msg = if (uris.size == 1) { + "Sending file to laptop…" + } else { + "Queued ${uris.size} files for the laptop" } Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() diff --git a/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt new file mode 100644 index 0000000..c89c26c --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt @@ -0,0 +1,177 @@ +package com.vortex.a3.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.app.NotificationCompat +import com.vortex.a3.R +import com.vortex.a3.core.clipboard.ClipboardFileReader + +/** + * Paces a multi-file share instead of capping it. + * + * Sharing 150 files used to deliver about 20 and claim success for all of them: + * the outgoing bus dropped its overflow and the blob store evicted the rest + * before the laptop's one-at-a-time pull reached them. Capping the batch made + * that honest but refused work the user had asked for, which is worse UX than + * simply taking longer. + * + * So the whole list is accepted and fed through a window: at most + * [WINDOW] files are in flight, and the next is read only when one is + * confirmed delivered. Two properties fall out of that: + * + * * **Memory stays flat.** Files are read one at a time, on their turn — the + * queue holds URIs, not bytes. Reading 150 files up front is what makes an + * 850 MB share an OutOfMemoryError. + * * **Nothing is evicted unread.** In-flight never exceeds what the blob store + * holds, so a queued file's bytes are still there when its turn comes. + * + * Progress is a single updating notification rather than a toast per file — + * 150 toasts is its own bug. + */ +class ShareQueue( + private val context: Context, + /** Hand a read file to the existing offer path. Returns false if it could + * not be accepted, in which case the queue retries it later. */ + private val emit: (com.vortex.a3.core.clipboard.ClipboardOutgoingFile) -> Boolean, + /** How many offers are awaiting collection right now. The window is + * measured against this, so pacing follows real delivery rather than a + * timer. */ + private val inFlight: () -> Int, +) { + private val pending = ArrayDeque() + private var total = 0 + private var done = 0 + private var failed = 0 + + /** Add [uris] to the queue and start (or continue) draining it. */ + @Synchronized + fun enqueue(uris: List) { + if (uris.isEmpty()) return + pending.addAll(uris) + total += uris.size + Log.i(TAG, "queued ${uris.size} file(s); $total total, ${pending.size} waiting") + showProgress() + pump() + } + + /** A file reached the laptop. Advance progress and start the next one. */ + @Synchronized + fun noteServed(name: String) { + done++ + Log.i(TAG, "delivered '$name' ($done/$total)") + showProgress() + pump() + } + + /** Read and hand off files until the in-flight window is full. */ + @Synchronized + fun pump() { + while (pending.isNotEmpty() && inFlight() < WINDOW) { + val uri = pending.removeFirst() + when (val outcome = ClipboardFileReader.read(context, uri)) { + is ClipboardFileReader.Outcome.Ok -> { + if (!emit(outcome.file)) { + // Downstream is momentarily full — put it back and stop; + // the next delivery re-enters here. + pending.addFirst(uri) + return + } + } + is ClipboardFileReader.Outcome.TooLarge -> { + failed++ + Log.w(TAG, "skipping $uri: over the size cap (${outcome.bytes} bytes)") + showProgress() + } + is ClipboardFileReader.Outcome.Unreadable -> { + failed++ + Log.w(TAG, "skipping $uri: ${outcome.why}") + showProgress() + } + } + } + if (pending.isEmpty() && inFlight() == 0) finish() + } + + private fun showProgress() { + // A single share of one file already gets the share-sheet toast; a + // progress notification on top of that is noise. + if (total <= 1) return + val nm = context.getSystemService(NotificationManager::class.java) ?: return + ensureChannel(nm) + val settled = done + failed + val n = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.vortex_logo) + .setContentTitle("Sending files to laptop") + .setContentText( + if (failed == 0) "$done of $total" else "$done of $total · $failed skipped", + ) + .setProgress(total, settled, false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + nm.notify(NOTIF_ID, n) + } + + private fun finish() { + val nm = context.getSystemService(NotificationManager::class.java) ?: return + if (total <= 1) { + nm.cancel(NOTIF_ID) + reset() + return + } + ensureChannel(nm) + val n = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.vortex_logo) + .setContentTitle( + if (failed == 0) "Sent $done files" else "Sent $done of $total files", + ) + .setContentText( + if (failed == 0) "All files reached the laptop" + else "$failed couldn't be sent (too large or unreadable)", + ) + .setOngoing(false) + .setAutoCancel(true) + .build() + nm.notify(NOTIF_ID, n) + Log.i(TAG, "batch finished: $done sent, $failed skipped of $total") + reset() + } + + private fun reset() { + total = 0 + done = 0 + failed = 0 + } + + private fun ensureChannel(nm: NotificationManager) { + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + nm.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "File transfers", + // LOW: a progress bar that pings on every one of 150 updates + // would be worse than the toasts it replaces. + NotificationManager.IMPORTANCE_LOW, + ).apply { description = "Progress while sending files to the laptop" }, + ) + } + + companion object { + private const val TAG = "VortexShareQueue" + private const val CHANNEL_ID = "vortex_transfer" + private const val NOTIF_ID = 0x701E6 + + /** + * Files in flight at once. + * + * Must stay well under `ClipboardBlobStore.MAX_ENTRIES` so a queued + * file's bytes cannot be evicted before the laptop collects them, and + * small enough that the OFFER burst does not overrun the BLE notify + * path (the same reason the offer sender paces itself). + */ + const val WINDOW = 8 + } +} diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt index fe1fc93..9fd1fcf 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt @@ -208,6 +208,10 @@ class VortexService : Service() { Log.i(tag, "onStartCommand flags=$flags startId=$startId action=${intent?.action}") if (!stack.isStarted()) { ensureStackStarted() + // A share can arrive before the stack is up (cold start from the + // share sheet). Queue it anyway — the queue paces itself off + // delivery, so it simply drains once the link exists. + if (intent?.action == ACTION_ENQUEUE_SHARE) enqueueShare(intent) } else when (intent?.action) { // READ_PHONE_STATE granted after the stack was already running // (the common trusted-launch path never asked for it). @@ -221,6 +225,7 @@ class VortexService : Service() { // in-app button and the proximity auto-unlock. ACTION_LOCK_LAPTOP -> requestLaptopLock(applicationContext, "lock") ACTION_UNLOCK_LAPTOP -> requestLaptopLock(applicationContext, "unlock") + ACTION_ENQUEUE_SHARE -> enqueueShare(intent) } return START_STICKY } @@ -236,6 +241,30 @@ class VortexService : Service() { override fun onBind(intent: Intent?): IBinder? = null + /** + * Take the URIs a share handed us and queue them. + * + * The URIs arrive in the Intent's ClipData with + * `FLAG_GRANT_READ_URI_PERMISSION`, which is what extends the share sheet's + * read grant to this service. That indirection is the point: the queue + * reads each file on its turn rather than the Activity reading all of them + * up front, so memory stays flat no matter how many were selected. + */ + private fun enqueueShare(intent: Intent) { + val clip = intent.clipData + val uris = buildList { + if (clip != null) { + for (i in 0 until clip.itemCount) clip.getItemAt(i).uri?.let { add(it) } + } + } + if (uris.isEmpty()) { + Log.w(tag, "enqueueShare: no URIs in ClipData") + return + } + Log.i(tag, "enqueueShare: ${uris.size} file(s)") + stack.shareQueue.enqueue(uris) + } + private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper()) private val retryStart = Runnable { ensureStackStarted() } @@ -288,6 +317,9 @@ class VortexService : Service() { * it. One-tap (no biometric); the laptop gates on this phone being * unlocked (owner-present gate). */ const val ACTION_UNLOCK_LAPTOP = "com.vortex.a3.UNLOCK_LAPTOP" + /** Intent action: a share sheet handed us files to send. URIs ride in + * ClipData with FLAG_GRANT_READ_URI_PERMISSION. */ + const val ACTION_ENQUEUE_SHARE = "com.vortex.a3.ENQUEUE_SHARE" /** * Latest peer AppState snapshot, keyed by peer_static_pub hex. @@ -341,6 +373,20 @@ class VortexService : Service() { /** A FILE (any non-image content) captured on THIS phone for sending to * the laptop — same offer+LAN-pull path as images, but the laptop * writes it to disk and makes it pasteable. */ + /** + * Outgoing shared files. Buffer holds a whole capped batch, and + * overflow SUSPENDS rather than dropping. + * + * It was 4 slots with DROP_OLDEST, which silently discarded most of any + * multi-file share: sharing 150 files delivered about 20, because the + * collector (stash + JSON + BLE notify) could not drain a 4-slot buffer + * as fast as the share loop filled it, and DROP_OLDEST throws away the + * overflow without telling anyone. Worse, `tryEmit` returns TRUE on a + * drop, so the sender counted every file as sent and the toast lied. + * + * With SUSPEND, `tryEmit` returns false instead of discarding, so the + * caller can count what was actually accepted and report the rest. + */ val clipboardFileBus: kotlinx.coroutines.flow.MutableSharedFlow< com.vortex.a3.core.clipboard.ClipboardOutgoingFile> = kotlinx.coroutines.flow.MutableSharedFlow( @@ -353,7 +399,13 @@ class VortexService : Service() { // toast still said "Sending 10 files to laptop…". Dropping the // user's files is not a reasonable answer to back-pressure, and // a queue of offers costs almost nothing to hold. - extraBufferCapacity = 64, + // + // Bound to the blob store rather than a bare number: a file + // emitted onto this bus but already evicted from the store + // cannot be served anyway, so more slots than the store holds + // would only queue offers that are certain to fail. + extraBufferCapacity = + com.vortex.a3.core.clipboard.ClipboardBlobStore.MAX_ENTRIES, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND, ) diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index abca893..1082d34 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -91,6 +91,9 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { internal val sentIconPkgs = java.util.Collections.synchronizedSet(HashSet()) private var clipboardListener: com.vortex.a3.core.clipboard.ClipboardListener? = null internal var wifiDirectTeardownJob: kotlinx.coroutines.Job? = null + /** elapsedRealtime of the last WIFI_DIRECT_OFFER, for coalescing (see + * [maybeStartWifiDirect]). 0 = none since the group last went down. */ + @Volatile internal var lastWifiDirectOfferAtMs: Long = 0L /** Icon PNG bytes per ICON frame chunk (kept under the BLE notify MTU * once the appId header + AEAD tag + frame header are added). */ internal val ICON_CHUNK = 180 @@ -111,6 +114,18 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * unattended press must not scan forever. */ private var seekJob: kotlinx.coroutines.Job? = null + /** Paces multi-file shares (see [ShareQueue]). Lazy because it needs + * `ctx`, which resolves through the Service's base context. */ + internal val shareQueue: ShareQueue by lazy { + ShareQueue( + context = ctx, + emit = { file -> VortexService.clipboardFileBus.tryEmit(file) }, + // Offers awaiting collection == files in flight, so pacing follows + // real delivery instead of a timer. + inFlight = { pendingOffers.size }, + ) + } + internal var contactsProvider: com.vortex.a3.core.contacts.ContactsProvider? = null /** Reads the phone's recent call log + observes changes; emits to callLogBus. */ internal var callLogProvider: com.vortex.a3.core.calllog.CallLogProvider? = null @@ -1219,6 +1234,11 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * live-link before it costs real battery. */ internal const val SEEK_WINDOW_MS = 45_000L + /** Minimum gap between WIFI_DIRECT_OFFERs. Each one costs the laptop a + * Wi-Fi disconnect/reconnect (single adapter), so they must not track + * the file count. Well inside the 60 s idle teardown. */ + internal const val WIFI_DIRECT_OFFER_MIN_GAP_MS = 30_000L + /** How long an AUDIO_SIGNAL subscription must stay up before the * companion mirror burst (contacts/recents/SMS, ~37 chunks) fires. * Guards the desync feedback loop — see onAudioSignalSubscribed. */ diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt index 5374cb0..67e6c90 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt @@ -197,9 +197,9 @@ internal fun VortexStack.noteFileServed(token: String) { val done = pendingOffers.remove(token) ?: return Log.i(VortexStack.TAG, "file '${done.name}' fetched by the laptop") // The one unambiguous "it worked" moment on this device: the laptop has the - // bytes. Per file rather than per batch, so a slow batch shows progress as - // it goes instead of one summary at the end. - if (!done.quiet) toastOffer("File sent: ${done.name}") + // bytes. Feeds the batch's progress notification and releases the next + // queued file — a toast per file meant 150 toasts for a 150-file share. + shareQueue.noteServed(done.name) // SLIDING deadline, like the daemon's bulk-sync idle budget: the laptop // pulls one file per heartbeat round, so a big batch's last offer can // legitimately wait many minutes for its turn. A fetch anywhere in the From f661564a8e6a128ac39f0ac3b2494efd28968f4d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:33:23 +0200 Subject: [PATCH 13/71] fix(ble): advertise when ACL-connected but sessionless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression from `Active = silent` (bf43359). `linkedProvider` was wired to `hasActiveConnection()`, which only means some central holds an ACL link — and BlueZ owns the ACL, not the app, so it survives the laptop app being restarted, killed or updated. The result was a deadlock. The phone saw "connected", stayed silent, and became unreachable; the laptop had no session and needed an advertisement to find the phone; neither side broke the tie. Since file offers ride BLE only, they sat retrying "BLE link down?" forever while LAN heartbeats kept working — so everything else looked healthy. Observed live while testing a 65-file share: the queue correctly held its files, but no offer could ever go out. Now keyed on `hasAudioSignalSubscriber()`: a peer that has actually subscribed to the notify channel, i.e. the path is genuinely deliverable. That is exactly the condition under which advertising is pointless, and it comes apart from mere ACL connectivity in precisely the case that broke. Verified: the phone advertised WHILE ACL-connected (previously impossible), the laptop found it, subscribed, and only then did the phone go silent. Note this was reachable in normal use, not just from my restarts — any laptop crash or app update leaves the same stale ACL. Co-Authored-By: Claude Opus 5 --- .../main/java/com/vortex/a3/core/ble/GattServer.kt | 14 ++++++++++++++ .../main/java/com/vortex/a3/service/VortexStack.kt | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt index 68b617e..a994f70 100644 --- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt @@ -767,6 +767,20 @@ class GattServer( fun hasActiveConnection(): Boolean = connectedAddrs.isNotEmpty() + /** + * True when a peer has SUBSCRIBED to AUDIO_SIGNAL, i.e. the notify path is + * actually deliverable. + * + * Distinct from [hasActiveConnection], which only says some central holds + * an ACL link. Those come apart in practice: BlueZ owns the ACL, so it + * survives the laptop app being restarted or killed, leaving a connection + * with no Vortex session behind it. Treating that as "connected" made the + * phone suppress its presence advertising while being unreachable — the + * laptop could not find it to re-establish, and neither side broke the tie. + */ + fun hasAudioSignalSubscriber(): Boolean = + synchronized(audioSignalSubscribers) { audioSignalSubscribers.isNotEmpty() } + private val callback = object : BluetoothGattServerCallback() { override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) { // Track the negotiated ATT MTU per device: the notify payload diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index 1082d34..5df035c 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -850,7 +850,13 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // avoidable battery cost here (design doc §D5). adv.linkedProvider = provider@{ val srv = gattServer ?: return@provider false - srv.hasActiveConnection() + // SUBSCRIBED, not merely ACL-connected. BlueZ owns the ACL link, so + // it outlives the laptop app: after a restart the phone saw a + // "connection" with no session behind it, stayed silent, and became + // unreachable — the laptop had nothing to find and the phone had no + // reason to advertise. Observed live: file offers sat retrying with + // "BLE link down?" while the phone never advertised. + srv.hasAudioSignalSubscriber() } // Which peers' tokens we may advertise. Re-read every round rather // than captured once, so pairing a new laptop or forgetting one takes From a3176feff2a023a1881165d1f4e94832e66fc919 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:33:46 +0200 Subject: [PATCH 14/71] fix(wifi-direct): only switch networks when it actually helps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 65-file share produced dozens of Wi-Fi disconnect/reconnect notifications and kept cutting transfers off mid-file. Four separate causes, found one at a time: 1. The OFFER was sent per file. `WifiDirect.start` is idempotent for the group but re-invokes its callback on every call (`if (isUp) onReady()`), and it runs once per file over 4 MB — so 65 files meant 65 offers, each making the laptop join the group and restore its Wi-Fi. Now coalesced with a minimum gap, cleared on teardown so a later batch is never starved. 2. The laptop restored Wi-Fi the instant its pull queue emptied. A paced sender empties it between windows, so every gap cost a full leave+rejoin. Now the group link is held across a 25 s idle grace — under the phone's 60 s GO teardown, so we let go before the group vanishes underneath us. 3. The watchdog force-restored 60 s after joining regardless of progress, cutting any batch that legitimately took longer (observed: file 26 of 65). It now polls for STALL instead of sleeping out a deadline. 4. That stall check then used `file_pull_active()`, which ANDs progress with "queue non-empty" — and a paced sender makes the queue oscillate, so a healthy transfer read as stalled and it fired anyway (file 21 of 65). It now measures time since a file last COMPLETED (`queue_progress_age`), and abstains entirely when nothing is queued, leaving that decision to the idle grace. Two mechanisms both deciding was the bug. Which left the real question: why switch at all? The trigger was "some file is over 4 MB", so a share went to Wi-Fi Direct even with both devices on the same AP, where the router path already works. Wi-Fi Direct costs BOTH devices their AP association — unavoidable with one radio each — so it is now skipped when the peer is already reachable on our LAN. That test is written to be portable, because the Windows port needs it: * the local address that reaches the peer comes from a connected UDP socket (`connect` sends nothing; it just makes the kernel do the route lookup so `local_addr` reports the source it would pick) — same on Windows, and a better question than enumerating interfaces and guessing; * netmasks come from `if-addrs`, which wraps getifaddrs / GetAdaptersAddresses and was already in the lockfile transitively. `is_fast_lan_iface` isolates the one genuinely platform-specific part, since if-addrs does not report interface TYPE. It is a denylist (bnep/ppp/wwan/rmnet/ tun/tap/p2p) on purpose: a misnamed fast link only costs a pointless P2P group, whereas a missed Bluetooth PAN would silently route a large transfer over Bluetooth. TODO left for the Windows port to use IfType properly. Verified on device: a 65-file share now completes with ZERO network switches. Co-Authored-By: Claude Opus 5 --- .../a3/service/VortexStackWifiDirect.kt | 23 ++ linux/ui-tauri/src-tauri/Cargo.lock | 1 + linux/ui-tauri/src-tauri/Cargo.toml | 4 + linux/ui-tauri/src-tauri/src/lan.rs | 38 +++- .../ui-tauri/src-tauri/src/lan_wifi_direct.rs | 215 +++++++++++++++++- 5 files changed, 271 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt index 9a6e819..99c200b 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackWifiDirect.kt @@ -16,6 +16,26 @@ import kotlinx.coroutines.launch * to join; tear it down after an idle window (reset on each big file). */ internal fun VortexStack.maybeStartWifiDirect() { com.vortex.a3.core.lan.WifiDirect.start(ctx) { + // COALESCE the offer. `WifiDirect.start` is idempotent for the group but + // re-invokes this callback on every call (`if (isUp) onReady()`), and + // this runs once per big file in a share. A 65-file batch therefore sent + // 65 offers in about a second, and each one makes the laptop join the P2P + // group and then restore its normal Wi-Fi — a single adapter, so that is + // a full disconnect/reconnect cycle per offer. The user got a storm of + // Wi-Fi notifications and pulls kept getting cut off mid-transfer, which + // is also where the duplicate files came from. + // + // Not "only on transition": a LATER batch, arriving while the group is + // still up but after the laptop restored its Wi-Fi, does need telling + // again. A time gap satisfies both, and sits well inside the idle + // teardown window so a genuinely new batch is never starved. + val now = android.os.SystemClock.elapsedRealtime() + val since = now - lastWifiDirectOfferAtMs + if (lastWifiDirectOfferAtMs != 0L && since < VortexStack.WIFI_DIRECT_OFFER_MIN_GAP_MS) { + Log.d(VortexStack.TAG, "wifi-direct: offer suppressed (sent ${since}ms ago)") + return@start + } + lastWifiDirectOfferAtMs = now val o = org.json.JSONObject() o.put("ssid", com.vortex.a3.core.lan.WifiDirect.SSID) o.put("pass", com.vortex.a3.core.lan.WifiDirect.PASS) @@ -29,6 +49,9 @@ internal fun VortexStack.maybeStartWifiDirect() { wifiDirectTeardownJob = scope.launch { kotlinx.coroutines.delay(60_000) com.vortex.a3.core.lan.WifiDirect.stop() + // Group is gone, so the next batch must be free to offer at once + // rather than waiting out the coalescing gap. + lastWifiDirectOfferAtMs = 0L Log.i(VortexStack.TAG, "wifi-direct: GO torn down (idle)") } } diff --git a/linux/ui-tauri/src-tauri/Cargo.lock b/linux/ui-tauri/src-tauri/Cargo.lock index abc28fd..a5a45e4 100644 --- a/linux/ui-tauri/src-tauri/Cargo.lock +++ b/linux/ui-tauri/src-tauri/Cargo.lock @@ -5383,6 +5383,7 @@ dependencies = [ "gstreamer-app", "gtk", "hex", + "if-addrs", "image", "ksni", "rand", diff --git a/linux/ui-tauri/src-tauri/Cargo.toml b/linux/ui-tauri/src-tauri/Cargo.toml index 5a7a58f..c6dd47e 100644 --- a/linux/ui-tauri/src-tauri/Cargo.toml +++ b/linux/ui-tauri/src-tauri/Cargo.toml @@ -34,6 +34,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } hex = "0.4" +# Cross-platform interface addresses + netmasks (getifaddrs / GetAdaptersAddresses) +# — the Wi-Fi Direct "are we already on the same LAN" test must work on the +# Windows port too. Already in the lockfile transitively. +if-addrs = "0.13" # Bulk-sync hash gate: sha256 of the cached mirror JSON (matches the # phone's hash of the same bytes). sha2 = "0.10" diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 14e438a..0f17061 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -19,7 +19,7 @@ use crate::{app_state_to_dto, emit_peers}; /// the gateway fallback when mDNS can't resolve the peer over its hotspot. pub(crate) const LAN_DEFAULT_PORT: u16 = 51820; -use crate::lan_wifi_direct::{restore_wifi, wd_active, WIFI_DIRECT_GO_IP}; +use crate::lan_wifi_direct::{wd_active, WIFI_DIRECT_GO_IP}; use crate::lan_state::{dispatch_appstate_call, dispatch_lock_command}; /// Last peer IP that mDNS successfully resolved to. When mDNS later comes @@ -115,6 +115,23 @@ pub(crate) fn note_queue_progress() { /// [`files_queued`] — is what may drive the heartbeat harder: a queue that is /// permanently stuck must not spin a TCP+IK every 2 s for the rest of the /// session, which is exactly what the unconditional form would do. +/// How long since a queued file last completed, if anything ever has. +/// +/// Raw progress, deliberately NOT ANDed with [`files_queued`] the way +/// [`file_pull_active`] is. A paced sender makes the pull queue oscillate +/// empty→full, so "queue is empty right now" says nothing about health — using +/// it as a stall signal force-restored Wi-Fi in the middle of a perfectly +/// healthy 65-file batch. +/// The peer IP that last worked, if any. Read by the Wi-Fi Direct gate to ask +/// "is the phone already on our LAN?" before paying for a P2P group. +pub(crate) fn last_good_peer_ip() -> Option { + *LAST_GOOD_PEER_IP.lock().unwrap_or_else(|e| e.into_inner()) +} + +pub(crate) fn queue_progress_age() -> Option { + QUEUE_PROGRESS_AT.lock().ok().and_then(|g| *g).map(|t| t.elapsed()) +} + pub(crate) fn file_pull_active() -> bool { if !files_queued() { return false; @@ -765,6 +782,9 @@ pub(crate) async fn try_lan_reconnect( .get() .and_then(|m| m.lock().ok().and_then(|mut g| g.pop_front())); if let Some((token, name, mime, id, kind)) = meta { + // Before anything else: this token's bytes have + // arrived, so a re-announce must not re-queue it. + crate::clipboard_sync::note_pulled(&token); note_queue_progress(); let offer = crate::clipboard_sync::Offer { kind, @@ -880,11 +900,17 @@ pub(crate) async fn try_lan_reconnect( // Wi-Fi Direct: once every queued file is pulled over the group // link, hop back to the normal Wi-Fi; otherwise pull the next now. if wd_active() { - if !files_queued() { - tracing::info!("Wi-Fi Direct: all files pulled → restoring Wi-Fi"); - restore_wifi(app).await; - } else if let Some(n) = crate::SYNC_NUDGE.get() { - n.notify_one(); + let queue_empty = !files_queued(); + // Hold the group link across brief gaps. A paced batch + // empties the queue between windows, and restoring on each + // gap cost a Wi-Fi disconnect/reconnect every few seconds. + crate::lan_wifi_direct::restore_when_idle(app, queue_empty).await; + if !queue_empty { + // More to pull → fetch the next one now rather than + // waiting out the heartbeat interval. + if let Some(n) = crate::SYNC_NUDGE.get() { + n.notify_one(); + } } } // SecretService D-Bus can stall here for hundreds of diff --git a/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs b/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs index 3d33f2f..0e95f23 100644 --- a/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs +++ b/linux/ui-tauri/src-tauri/src/lan_wifi_direct.rs @@ -24,6 +24,69 @@ pub(crate) struct WdState { pub(crate) static WIFI_DIRECT: std::sync::Mutex> = std::sync::Mutex::new(None); +/// When the pull queue last went empty while we were on the group link. +/// `None` = not idle (files pending, or not on the group). +static WD_IDLE_SINCE: std::sync::Mutex> = + std::sync::Mutex::new(None); + +/// How long the group link is held after the pull queue drains. +/// +/// Restoring the instant the queue emptied made a paced batch thrash the Wi-Fi: +/// the phone releases files in windows, so the queue legitimately goes empty +/// between them, and each gap produced a full leave-group + rejoin. Observed +/// live — restore at 15:16:53, rejoin at 15:16:57 — one disconnect/reconnect +/// notification per gap, dozens over a 65-file share. +/// +/// Sized under the phone's 60 s idle GO teardown, so we let go before the group +/// disappears underneath us, and the 60 s force-restore watchdog still bounds +/// the worst case. +const WD_IDLE_GRACE: Duration = Duration::from_secs(25); + +/// How long a pull may make NO progress before the watchdog restores Wi-Fi. +/// Bounds a genuinely stuck transfer without capping a healthy long one. +const WD_STALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// Restore the normal Wi-Fi only once the queue has been empty for +/// [`WD_IDLE_GRACE`]. Call on every heartbeat round while on the group link. +/// +/// `queue_empty` is passed in rather than read here so the caller's notion of +/// "queued" stays the single source of truth. +pub(crate) async fn restore_when_idle(app: &AppHandle, queue_empty: bool) { + if !wd_active() { + if let Ok(mut g) = WD_IDLE_SINCE.lock() { + *g = None; + } + return; + } + if !queue_empty { + // More to pull — hold the link and reset the idle clock. + if let Ok(mut g) = WD_IDLE_SINCE.lock() { + *g = None; + } + return; + } + let elapsed = { + let Ok(mut g) = WD_IDLE_SINCE.lock() else { return }; + let since = g.get_or_insert_with(std::time::Instant::now); + since.elapsed() + }; + if elapsed < WD_IDLE_GRACE { + tracing::debug!( + idle_s = elapsed.as_secs(), + "Wi-Fi Direct: queue empty but holding the group link" + ); + return; + } + tracing::info!( + idle_s = elapsed.as_secs(), + "Wi-Fi Direct: idle past the grace window → restoring Wi-Fi" + ); + if let Ok(mut g) = WD_IDLE_SINCE.lock() { + *g = None; + } + restore_wifi(app).await; +} + pub(crate) fn wd_active() -> bool { WIFI_DIRECT.lock().map(|g| g.is_some()).unwrap_or(false) } @@ -102,6 +165,105 @@ pub(crate) async fn restore_wifi(app: &AppHandle) { let _ = app.emit("vortex:wifi-direct", false); } +/// Are we already on the same local network as `peer`, over a link fast enough +/// that a P2P group would not be worth a disconnect? +/// +/// Wi-Fi Direct costs BOTH devices their AP association — unavoidable with one +/// radio — so it should only be paid when the ordinary path cannot do the job. +/// Two devices on the same AP already have a perfectly good route through it. +/// +/// Portable by construction, because the Windows port needs this too: +/// +/// * the local address that would reach `peer` comes from a connected UDP +/// socket. `connect` on UDP sends nothing; it just asks the routing table, +/// and `local_addr` then reports the answer. Works the same on Windows. +/// * the netmask for that address comes from `if_addrs`, which wraps +/// `getifaddrs` on Unix and `GetAdaptersAddresses` on Windows. +/// +/// The one part that is genuinely platform-specific is deciding whether an +/// interface is a *fast* LAN link, which is why it lives in +/// [`is_fast_lan_iface`] on its own. +fn peer_on_same_fast_lan(peer: std::net::IpAddr) -> bool { + let Some(local) = local_addr_toward(peer) else { + return false; // no route we can name → let Wi-Fi Direct try + }; + let Ok(ifaces) = if_addrs::get_if_addrs() else { + return false; + }; + for iface in ifaces { + if iface.addr.ip() != local { + continue; + } + if !is_fast_lan_iface(&iface.name) { + tracing::debug!( + iface = %iface.name, + "route to peer is not a fast LAN link; Wi-Fi Direct still worthwhile" + ); + return false; + } + // Same interface AND same subnet: the AP path already reaches them. + let same_subnet = match (iface.addr, peer) { + (if_addrs::IfAddr::V4(v4), std::net::IpAddr::V4(p)) => { + let mask = u32::from(v4.netmask); + u32::from(v4.ip) & mask == u32::from(p) & mask + } + // v6 link-local/ULA subnetting is not what this decision hinges on. + _ => false, + }; + if same_subnet { + tracing::info!( + iface = %iface.name, %peer, + "peer already reachable on our LAN — skipping Wi-Fi Direct" + ); + return true; + } + } + false +} + +/// Which local address the OS would use to reach `peer`. +/// +/// A connected UDP socket is the portable way to ask: no packet is sent, the +/// kernel just does the route lookup so `local_addr` can report the source it +/// would pick. Port 9 (discard) is conventional for this and never contacted. +fn local_addr_toward(peer: std::net::IpAddr) -> Option { + let bind: &str = if peer.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" }; + let sock = std::net::UdpSocket::bind(bind).ok()?; + sock.connect((peer, 9)).ok()?; + sock.local_addr().ok().map(|a| a.ip()) +} + +/// Is `name` a link fast enough that the AP path beats a P2P group? +/// +/// The interesting exclusion is **Bluetooth PAN**: the phone and laptop can be +/// on a PAN at the same time as Wi-Fi, and a PAN route shares no subnet with +/// the AP while being far too slow to treat as "already on the LAN". Cellular +/// and VPN/tunnel links are excluded for the same reason. +/// +/// `if_addrs` does not report interface *type*, so this is the platform- +/// specific part. On Linux `/sys/class/net//phy80211` positively identifies +/// 802.11, and everything not on the denylist is assumed to be a wired NIC. +/// +/// TODO(windows): replace the denylist with the real thing — +/// `GetAdaptersAddresses` reports `IfType`, so accept `IF_TYPE_ETHERNET_CSMACD` +/// and `IF_TYPE_IEEE80211` and reject the rest. `netdev` would also expose it +/// cross-platform if a dependency is preferable to the cfg split. +fn is_fast_lan_iface(name: &str) -> bool { + // Slow or virtual links, by conventional naming. Deliberately a denylist: + // a misnamed fast link only costs us a pointless P2P group, while a missed + // PAN would silently route a big transfer over Bluetooth. + const SLOW: [&str; 6] = ["bnep", "ppp", "wwan", "rmnet", "tun", "tap"]; + if SLOW.iter().any(|p| name.starts_with(p)) { + return false; + } + // The P2P interface is Wi-Fi too — it must never count as "the LAN we are + // already on", or joining would look unnecessary from inside the group. + if name.starts_with("p2p") { + return false; + } + true +} + /// Hook target (set in the worker): the phone offered a P2P group. If files are /// pending, switch onto it so the heartbeat pulls them over the fast link. pub(crate) fn on_wifi_direct_offer(app: AppHandle, ssid: String, pass: String) { @@ -112,6 +274,20 @@ pub(crate) fn on_wifi_direct_offer(app: AppHandle, ssid: String, pass: String) { if !pending || wd_active() { return; } + // Already on the same LAN as the phone? Then the AP path already reaches + // it, and a P2P group would buy little while costing BOTH devices their AP + // association — one radio each, so that is unavoidable. Observed: a + // 65-file share on a shared network switched networks purely because one + // file happened to exceed the size trigger. + if let Some(peer_ip) = crate::lan::last_good_peer_ip() { + if peer_on_same_fast_lan(peer_ip) { + tracing::info!( + %peer_ip, + "Wi-Fi Direct offer ignored: peer is on our LAN already" + ); + return; + } + } tokio::spawn(async move { let saved = current_wifi().await; tracing::info!(?saved, %ssid, "Wi-Fi Direct: joining group for fast pull"); @@ -127,12 +303,43 @@ pub(crate) fn on_wifi_direct_offer(app: AppHandle, ssid: String, pass: String) { n.notify_one(); // pull now over the GO } // Watchdog: never strand the laptop on the GO (failed pull / lost link). + // + // Polls for STALL rather than sleeping out a fixed deadline. A flat 60 s + // from join force-restored in the middle of any batch that legitimately + // took longer — observed cutting a 65-file share at file 26, costing the + // one Wi-Fi disconnect/reconnect that survived the idle-grace fix. What + // must be caught is a pull making no progress, which is precisely what + // `file_pull_active()` already answers (queued AND progressing). let app2 = app.clone(); tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(60)).await; - if wd_active() { - tracing::warn!("Wi-Fi Direct: watchdog timeout → force-restore Wi-Fi"); - restore_wifi(&app2).await; + let joined_at = tokio::time::Instant::now(); + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + if !wd_active() { + return; // restored elsewhere (idle grace, or a new join) + } + // With nothing queued there is nothing to be stuck on — the + // idle-grace path owns that decision, and stepping on it here + // is what cut a healthy batch short. + if !crate::lan::files_queued() { + continue; + } + // Files ARE queued: measure real progress. `queue_progress_age` + // is the time since one last COMPLETED, which survives the + // queue oscillating empty→full under a paced sender. + let stalled = crate::lan::queue_progress_age() + .map(|age| age >= WD_STALL_TIMEOUT) + // Nothing has ever completed — fall back to time on the + // group so a join that never delivers still gets unstuck. + .unwrap_or(joined_at.elapsed() >= WD_STALL_TIMEOUT); + if stalled { + tracing::warn!( + "Wi-Fi Direct: queued pull made no progress for {}s → force-restore Wi-Fi", + WD_STALL_TIMEOUT.as_secs() + ); + restore_wifi(&app2).await; + return; + } } }); }); From a39e3c485926ac4e1627574296126249d902f53d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:34:46 +0200 Subject: [PATCH 15/71] fix(files): make an incoming pull idempotent per content token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defence in depth, not the cure. A 65-file share arrived as 75 files with duplicates, and the actual cause was the Wi-Fi Direct thrash (62e7a43): pulls were cut off mid-transfer, leaving partially-written files that had already been allocated a name in Downloads. Fixing the thrash is what stopped it. The receiver was nevertheless not idempotent, and that is worth closing on its own. The dedupe only covered offers still WAITING in the queue, so there is a window between the laptop dequeuing an offer and the phone learning it was served; a re-announce landing inside it passes the queued check, gets queued again, and is pulled twice. Completion now records the content token, and the dedupe also rejects anything pulled within RECENT_PULL_TTL. Fixed on the receiver deliberately: the phone cannot know about that window, so the side that owns the file has to be the one that refuses the repeat. The TTL is 60 s — long enough to outlast the announce/serve race, short enough that deliberately re-sharing the same file is not mysteriously ignored. That tension is real: tokens are content hashes, so "the same file" and "identical bytes" are indistinguishable here. Not verified as a fix in its own right — a 65-file share now arrives as exactly 65 files, but with the thrash gone the race it guards against no longer reproduces on demand. Still open: a failed pull leaves the partially-written file in Downloads. It should write to a temp name and rename on completion, so an interrupted transfer leaves nothing rather than a plausible-looking broken file. Co-Authored-By: Claude Opus 5 --- .../ui-tauri/src-tauri/src/clipboard_sync.rs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 39452eb..64d1cce 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -51,6 +51,48 @@ pub(crate) static CLIPBOARD_SYNC: AtomicBool = AtomicBool::new(true); /// Hash of the last text that crossed the link in EITHER direction — the /// loop guard. When the watcher re-captures this exact text (e.g. right /// after we set it from a received sync), it isn't bounced back. +/// Content tokens whose bytes we already pulled, with when. +/// +/// The queued-token dedupe below only covers offers still WAITING. There is a +/// window between the laptop dequeuing an offer and the phone learning it was +/// served, and a re-announce landing inside it passes the queued check, gets +/// queued again, and is pulled a second time — which is how a 65-file share +/// arrived as 75 files with duplicates. +/// +/// Entries expire after [RECENT_PULL_TTL] so a *deliberate* re-share of the +/// same content still works. That window only has to outlast the +/// announce/serve race (seconds), not the user's patience. +static RECENTLY_PULLED: Mutex> = Mutex::new(Vec::new()); + +/// How long a pulled token stays suppressed. Long enough to cover the +/// re-announce race, short enough that re-sharing the same file on purpose is +/// not mysteriously ignored. +const RECENT_PULL_TTL: std::time::Duration = std::time::Duration::from_secs(60); + +/// Record that `token`'s bytes arrived, so a re-announce cannot re-queue it. +pub(crate) fn note_pulled(token: &str) { + if token.is_empty() { + return; + } + if let Ok(mut g) = RECENTLY_PULLED.lock() { + let now = std::time::Instant::now(); + g.retain(|(_, at)| now.duration_since(*at) < RECENT_PULL_TTL); + g.push((token.to_string(), now)); + } +} + +/// True when `token` was pulled within [RECENT_PULL_TTL]. +fn pulled_recently(token: &str) -> bool { + RECENTLY_PULLED + .lock() + .map(|g| { + let now = std::time::Instant::now(); + g.iter() + .any(|(t, at)| t == token && now.duration_since(*at) < RECENT_PULL_TTL) + }) + .unwrap_or(false) +} + static LAST_SYNC_SIG: Mutex = Mutex::new(String::new()); /// Watcher (blocking thread) → async sender channel. Set once by @@ -712,7 +754,14 @@ async fn flush_file_batch(batch: Vec) { .into_iter() // `seen` also collapses duplicates WITHIN the batch: a re-announce // can land inside the same debounce window as the original. - .filter(|o| !queued.contains(&o.token) && seen.insert(o.token.clone())) + // `pulled_recently` closes the dequeued-but-not-yet-acked window; + // `queued` covers offers still waiting; `seen` collapses duplicates + // inside one debounce window. + .filter(|o| { + !queued.contains(&o.token) + && !pulled_recently(&o.token) + && seen.insert(o.token.clone()) + }) .collect() }; if batch.is_empty() { From 476cdb171bae6760b6862735d2afb4aa3ead7d9c Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 26 Aug 2026 18:34:46 +0200 Subject: [PATCH 16/71] docs: design for browsing the phone's files from the desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KDE-Connect-style feature: open the phone's storage in Dolphin / Nautilus / Explorer. Linux AND Windows from the start, since the Windows port branch means every new feature needs both. Records the decisions taken, and why: * one missing primitive underlies this AND large-file transfer — a ranged read. File managers issue them constantly; buffering whole files is what crashed the app on an 835 MB share. There is no offset-based read anywhere in the tree today, so both features start from the same standing start, and the 64 MB cap disappears as a side effect rather than as its own change; * the phone serves a dumb, narrow protocol; ALL caching, readahead and invalidation live in the daemon. Android is the worst place for that code — process death, Doze and low-memory kills make cache lifetime unpredictable, and every cache bug would need a phone rebuild to test; * the phone never serves the LAN. The daemon exposes the mount on loopback and proxies over the existing Noise session, so pairing IS the auth model — no second credential system, no TLS on the phone, nothing exposed on the network; * WebDAV over loopback first (one implementation, both OSes), with FUSE + ProjFS as the exit. ProjFS ships in Windows 10 1809+ with no third-party install, which is what beats WebDAV — whose Windows client defaults to a ~50 MB FileSizeLimitInBytes, i.e. trading a 64 MB cap for a 50 MB one; * SFTP+sshfs (what KDE Connect uses) is rejected on the Windows requirement alone: it needs WinFsp + SSHFS-Win, a third-party install per user; * writes and metadata-set are stubs that return an explicit "not supported" error, never silence — a stub that looks like a timeout hangs the file manager, which is the same class of bug as the silent share failures. Sequencing puts the ranged-read primitive and reworking large-file transfer onto it first, because both are worth doing whether or not the mount ever ships. Co-Authored-By: Claude Opus 5 --- docs/design/file-browsing.md | 230 +++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/design/file-browsing.md diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md new file mode 100644 index 0000000..68c0de4 --- /dev/null +++ b/docs/design/file-browsing.md @@ -0,0 +1,230 @@ +# Browsing the phone's files from the desktop + +**Status:** design, not implemented. **Targets:** Linux *and* Windows from day +one — the Windows port branch means every new feature needs both. + +Goal: open the phone's storage in Dolphin / Nautilus / Explorer, like KDE +Connect does. Read-only first, writes stubbed. + +--- + +## 1. Why this is the same work as fixing large-file transfer + +There is exactly one missing primitive underneath both features: + +``` +READ(handle, offset, len) -> bytes +``` + +- **Browsing** needs it because file managers issue ranged reads constantly — + Explorer's redirector does, thumbnailers do, media players seek. +- **Large-file transfer** needs it because the current design buffers whole + files, which is what crashed the app on an 835 MB share (`OutOfMemoryError`, + 876 MB against a 256 MB heap growth limit). + +Today there is **no offset-based read anywhere** in the codebase, and the +Android app declares **no storage permissions at all** — file access is only +ever a `content://` URI handed over by the share sheet. So both features start +from the same standing start, and the 64 MB `MAX_FILE_BYTES` cap disappears as a +side effect of building the primitive rather than as a separate change. + +**Corollary:** do not raise `MAX_FILE_BYTES` in the meantime. It is bounded by +the process heap, so a bigger constant only moves the crash. + +--- + +## 2. Layering + +The load-bearing decision: **the phone serves a dumb, narrow protocol; the +laptop does everything clever.** + +```mermaid +graph TB + subgraph Desktop["💻 Desktop"] + FM["Dolphin / Nautilus / Explorer"] + MNT["Mount adapter
(per-OS, swappable)"] + CACHE["Vortex daemon
metadata cache · content cache
readahead · coalescing"] + end + subgraph Phone["📱 Phone"] + FS["File provider
SAF / MediaStore"] + end + FM -->|loopback| MNT + MNT --> CACHE + CACHE -->|"LIST · STAT · READ(off,len)
over the existing Noise session"| FS +``` + +Three consequences worth stating explicitly: + +**All caching lives in the daemon.** The Android app answers ranged reads and +nothing more — no cache, no prefetch, no invalidation logic. Android is the +worst place for that code: process death, Doze, and low-memory kills make cache +lifetime unpredictable, and every cache bug would need a phone rebuild to test. + +**The phone never serves the LAN.** The daemon exposes the mount on +**loopback only** and proxies over the already-authenticated Noise session. This +reuses pairing as the auth model — no second credential system, no TLS on the +phone, no listening socket exposed to the network, and free choice of port. + +**The mount adapter is swappable; the protocol is the investment.** Changing +how the desktop presents the files must never require touching the phone. + +--- + +## 3. The protocol + +New frame types, additive (unknown types are logged and ignored on both sides, +so no version gate is needed). Rides the existing sealed app-data channel. + +| Op | Direction | Payload | v1 | +|---|---|---|---| +| `FS_LIST` | laptop → phone | path / tree handle, cursor | ✅ | +| `FS_STAT` | laptop → phone | path | ✅ | +| `FS_READ` | laptop → phone | handle, offset, len | ✅ | +| `FS_WRITE` | laptop → phone | handle, offset, bytes | **stub** | +| `FS_SETMETA` | laptop → phone | path, mtime / mode / rename | **stub** | +| `FS_DATA` | phone → laptop | request id, offset, bytes, eof | ✅ | +| `FS_META` | phone → laptop | entries / stat result | ✅ | +| `FS_ERR` | phone → laptop | request id, code | ✅ | + +Stub means: **defined, wired, and answered with a clear `FS_ERR` "not +supported"** — not silently dropped. A stub that looks like a timeout is worse +than an honest refusal, and the file manager needs a definite answer to avoid +hanging. + +Design notes: + +- **Request IDs, not a request/response lock.** File managers issue many + concurrent stats; a strictly serialised protocol would feel broken. Cap + in-flight requests (the phone's link is not infinitely parallel) and pipeline + the rest. +- **Reads are bounded per frame.** Existing `MAX_FRAME_PAYLOAD` is 63 KiB; the + daemon issues many ranged reads rather than one huge one. That is what keeps + memory flat on both sides. +- **Directory listings paginate.** A 10,000-entry folder must not be one frame. +- **Handles, not paths, for reads.** A path resolved per read is a TOCTOU + problem and slow under SAF; open once, read many, close. + +--- + +## 4. Desktop presentation: WebDAV first, native VFS as the exit + +### v1 — WebDAV on loopback + +One implementation serving both OSes: + +- **Linux:** `davs://localhost:PORT` via GVFS (Nautilus) / KIO (Dolphin). +- **Windows:** `\\localhost@PORT\DavWWWRoot\` via the WebClient redirector. + +Cheapest path to something usable, and platform-neutral Rust in the daemon. + +**Windows WebDAV caveats — plan for these, they are not hypothetical:** + +| Issue | Detail | +|---|---| +| `FileSizeLimitInBytes` | WebClient defaults to ~**50 MB**. Escaping a 64 MB cap into a 50 MB one would be absurd — needs a registry change or an installer step | +| Basic auth over HTTP | Disabled by default (`BasicAuthLevel`). Avoidable by requiring **no auth on loopback** — nothing but local processes can reach it | +| WebClient service | Must be running; Explorer's WebDAV client is slow and flaky under load | +| Port syntax | Non-standard ports need the `\\host@port\` form, which is unfamiliar to users | + +Loopback-only binding removes the auth problem outright. The 50 MB limit does +not go away and is the main reason v1 may not be the end state. + +### v2 — native virtual filesystem + +- **Linux:** FUSE. Straightforward, gives a real mount. +- **Windows:** **ProjFS** (Projected File System), shipped in Windows 10 1809+ + with **no third-party install** — it is what VFS for Git uses. This is the key + fact that beats WebDAV: a real filesystem, no size limits, proper seeking. + +More code (two presentation implementations), but no artificial ceilings, and +the phone side is untouched by the switch. + +### Rejected: SFTP + sshfs + +What KDE Connect uses, and excellent on Linux. On Windows it needs WinFsp + +SSHFS-Win — a third-party install we would be asking every user to do. Out on +the cross-platform requirement alone. + +### Rejected: SMB + +Explorer's best-supported protocol, but the Windows client effectively requires +port 445, which Android cannot bind (privileged port, no root), and Android SMB +server implementations are heavy. Non-starter. + +--- + +## 5. Android file access — a decision to make + +There is no storage permission today, so this is new surface either way: + +| Option | Gets you | Costs | +|---|---|---| +| **SAF trees** (`ACTION_OPEN_DOCUMENT_TREE`) | user grants specific folders | content URIs rather than paths, slower enumeration, no whole-device view | +| **`MANAGE_EXTERNAL_STORAGE`** | full filesystem, the KDE Connect experience | alarming permission dialog; Play-Store-restricted (not binding — Vortex ships via GitHub releases) | + +**Recommendation:** SAF trees as the default, all-files access as an explicit +opt-in for users who want the full view. That keeps the scary permission out of +the first-run path while not capping what power users can do. + +--- + +## 6. Transport reality + +**Content streams over Wi-Fi.** BLE is tens of KB/s — unusable for file bytes, +and the moment a file is more than trivial the user will turn Wi-Fi on anyway. + +BLE stays useful for **metadata and wake-up**: a directory listing or a stat can +ride it, and it is how the daemon knows the phone is there at all. So: + +- Wi-Fi (LAN, or Wi-Fi Direct for bulk) is required for content. +- With no usable network, the mount reports an honest, immediate error rather + than hanging — a file manager blocked on a dead read is the worst outcome. +- Wi-Fi Direct is already used for large transfers and applies here unchanged. + +--- + +## 7. What makes this feel fast or broken + +This is where these features usually fail, and it is all daemon-side: + +- **Metadata cache with invalidation.** File managers stat everything in view, + repeatedly. Without a cache, every icon refresh is a round trip. +- **Readahead.** Sequential reads (copying, media playback) should pull ahead of + the requested range; a strict 63 KiB request/response ping-pong will never + saturate Wi-Fi. +- **Coalescing and a concurrency cap.** Thumbnailers fire dozens of parallel + reads; unbounded, they will starve the link and the BLE session with it. +- **Content cache with a byte budget**, not an entry count — one 2 GB video must + not evict a whole tree's metadata. +- **Honest errors.** Every failure path returns a definite error quickly. + Hanging is worse than failing. + +--- + +## 8. Sequencing + +1. **`FS_STAT` + `FS_LIST` + `FS_READ`** on the phone (answer ranged reads, + nothing else) and the daemon-side client. No mount yet — validate over the + existing session with a CLI. +2. **Rework large-file transfer onto ranged reads.** Removes `MAX_FILE_BYTES` + and the buffer-the-whole-file crash. Ships value before any mount exists. +3. **Daemon cache layer** — metadata, readahead, content budget. +4. **WebDAV loopback gateway**, both OSes. +5. **`FS_WRITE` / `FS_SETMETA`** for real, once read-only is solid. +6. **FUSE + ProjFS**, if the Windows WebDAV limits bite. + +Steps 1–2 are worth doing regardless of whether the mount ever ships, which is +the main argument for this ordering. + +## 9. Open questions + +- **Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer, + document it, or skip straight to ProjFS? +- **Handle lifetime** across phone process death — the daemon must transparently + reopen, or the file manager will see spurious I/O errors after a Doze kill. +- **Multi-peer:** with several paired phones, is the mount per-phone (a mount + point each) or does it follow the active peer? Per-phone is more predictable + but multiplies mounts. +- **Thumbnails:** let the desktop generate them by reading bytes (simple, heavy + on the link), or ask the phone for MediaStore thumbnails (fast, needs another + op)? From 5c0b44f35b8be92ae75d7bfcde094a32b75360cf Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Thu, 27 Aug 2026 11:55:14 +0200 Subject: [PATCH 17/71] =?UTF-8?q?fix(ui):=20make=20the=20phone's=20laptop?= =?UTF-8?q?=20card=20show=20=E2=80=94=20and=20offer=20=E2=80=94=20the=20ri?= =?UTF-8?q?ght=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two trusted laptops the card showed the wrong one, and its switch action was invisible as a control. Observed after pairing a second laptop overnight: arriving at work, the phone had reconnected to "Kapital" by itself and was syncing over both LAN and BLE, while the card read "gaia / Disconnected" — a laptop 40 km away. Wrong peer. Two compounding causes, both "an arbitrary peer" dressed up as a choice: * `HomeScreen` picked `peers.firstOrNull()`, which has no notion of which peer is active; * `EncryptedPrefsPeerStore.list()` reads `prefs.all`, a HashMap, so the sequence followed HASH ORDER. Every `list().firstOrNull()` in the phone codebase therefore meant "whichever peer hashed first". The card now takes the peer with the freshest traffic, tie-broken by `pairedAt`, so it follows whatever laptop is really on the other end and falls back to most-recently-paired when nothing has been heard yet. `list()` is sorted by `pairedAt` descending so the nondeterminism is gone at source, for the other single-peer callers too. Worth recording: restarting the app would have APPEARED to fix this, because a fresh hash order might have landed on the right peer — it would have looked like a transient glitch rather than a reproducible bug. Invisible action. It sat in the card's bottom row between the cast and lock glyphs: on screen and tappable (uiautomator confirmed clickable, bounds [482,1152][650,1320]) but unreadable as something pressable. A two-facing-arrows glyph sandwiched between two other icons reads as "swap those two", and the row was crowded enough to wrap the battery percentage onto a second line. It now renders beside the device icon via a new optional `afterIcon` slot on `CardHeader`, using `PhonelinkOff` — "unlink", which is what leaving this laptop for another one is. Icon and action are grouped in an inner Row so the header's SpaceBetween keeps them left instead of spreading three items. Verified on device: card went from "gaia / Disconnected" to "Kapital / Connected / 100%"; action moved to [273,757][441,925]; battery back to a single line. Co-Authored-By: Claude Opus 5 --- .../core/storage/EncryptedPrefsPeerStore.kt | 7 +++ .../com/vortex/a3/ui/components/Common.kt | 29 ++++++++---- .../vortex/a3/ui/components/PeerDeviceCard.kt | 45 ++++++++++--------- .../com/vortex/a3/ui/screens/HomeScreen.kt | 13 +++++- 4 files changed, 64 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt index e6cd4a7..e66131a 100644 --- a/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt +++ b/android/app/src/main/java/com/vortex/a3/core/storage/EncryptedPrefsPeerStore.kt @@ -201,6 +201,13 @@ class EncryptedPrefsPeerStore(context: Context) : PeerStore { runCatching { TrustedPeer.decode(Base64.decode(s, Base64.NO_WRAP)) }.getOrNull() } .filterNotNull() + // Deterministic order. `prefs.all` is a HashMap, so without this the + // sequence follows hash order — and every `list().firstOrNull()` + // caller silently means "an arbitrary peer". That showed up as the + // phone's home card displaying whichever laptop hashed first + // instead of the one it was actually connected to. Most recently + // paired first, so "the one peer" degrades to something sensible. + .sortedByDescending { it.pairedAt } .toList() } diff --git a/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt b/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt index 4809db9..1bb5cd0 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/components/Common.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape @@ -69,20 +70,32 @@ fun CardHeader( iconTint: Color, iconBg: Color, statusDot: Color, + /** Optional action rendered immediately right of the device icon. Card + * actions that are not per-row belong here rather than in the bottom row, + * which is already carrying battery + charging + per-feature glyphs. */ + afterIcon: (@Composable () -> Unit)? = null, ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top, ) { - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(12.dp)) - .background(iconBg), - contentAlignment = Alignment.Center, - ) { - Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp)) + // Icon and its trailing action are grouped so SpaceBetween keeps them + // together on the left instead of spreading three items across the row. + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(12.dp)) + .background(iconBg), + contentAlignment = Alignment.Center, + ) { + Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp)) + } + if (afterIcon != null) { + Spacer(modifier = Modifier.size(10.dp)) + afterIcon() + } } StatusDot(color = statusDot) } diff --git a/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt b/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt index d9c9373..693f87d 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/components/PeerDeviceCard.kt @@ -20,7 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material.icons.outlined.PhonelinkOff import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material3.Icon @@ -101,6 +101,29 @@ fun PeerDeviceCard( iconTint = MaterialTheme.colorScheme.primary, iconBg = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), statusDot = statusDotColor, + // Beside the device icon, not in the bottom row. Two facing arrows + // sandwiched between the cast and lock glyphs read as "swap those + // two", and it crowded the row enough to wrap the battery + // percentage onto a second line. "Unlink" says what this does: + // leave this laptop for another one. + afterIcon = onSwitch?.let { switch -> + { + Icon( + imageVector = Icons.Outlined.PhonelinkOff, + contentDescription = "Switch to another laptop", + tint = if (seeking) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = switch) + .padding(4.dp) + .size(20.dp), + ) + } + }, ) Spacer(modifier = Modifier.height(14.dp)) Text(name, color = MaterialTheme.colorScheme.onSurface, fontWeight = FW.SemiBold, style = MaterialTheme.typography.bodyLarge, maxLines = 1) @@ -135,26 +158,6 @@ fun PeerDeviceCard( } Spacer(modifier = Modifier.size(8.dp)) } - if (onSwitch != null) { - // Switch to another remembered laptop. Tinted while seeking so - // the state is visible without stealing card space for a label - // — the window closes itself, so there is nothing to undo. - Icon( - imageVector = Icons.Outlined.PhoneAndroid, - contentDescription = "Switch to another laptop", - tint = if (seeking) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onSwitch) - .padding(4.dp) - .size(20.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) - } if (locked != null && onToggleLock != null) { Icon( imageVector = if (locked) Icons.Outlined.Lock else Icons.Outlined.LockOpen, diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt index d68adf2..d0bcd02 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt @@ -108,7 +108,18 @@ fun HomeScreen( onEnableBluetooth: () -> Unit, ) { val peerCount = peers.size - val primaryPeer = peers.firstOrNull() + // Show the laptop we are actually talking to. + // + // This used to be `peers.firstOrNull()`, which with two trusted laptops + // meant "an arbitrary one" — observed showing a laptop 40 km away as + // "Disconnected" while the phone was happily syncing with the one on the + // desk. Freshest traffic wins; `pairedAt` breaks ties so the choice is + // still deterministic when nothing has been heard from yet (and then it is + // most-recently-paired, which is the best available guess at "yours"). + val primaryPeer = peers.maxWithOrNull( + compareBy { peerLastSeen[it.peerStaticPub.toHex()] ?: 0L } + .thenBy { it.pairedAt }, + ) val primaryState = primaryPeer?.let { peerStates[it.peerStaticPub.toHex()] } val primaryHex = primaryPeer?.peerStaticPub?.toHex() val lastSeen = primaryHex?.let { peerLastSeen[it] } ?: 0L From 5d98168c146cfd99d33bafb6a97753921a8cda40 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Thu, 27 Aug 2026 14:38:14 +0200 Subject: [PATCH 18/71] feat(ui): show every paired laptop, and switch to a named one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone rendered a single laptop card, so with two trusted laptops the second was invisible — no way to see it was still paired, and no way to choose it. This is the per-peer view from design doc §D8. An "Also paired" section lists every laptop except the one in the main card, and tapping a row switches to THAT laptop. Compact rows rather than a card each: PeerDeviceCard is 180 dp, so a card per laptop would push the rest of the screen away for what is mostly "this one exists and is not the one you are on". The row itself is the tap target — a 20 dp glyph is a poor one, and there is a single action per row — so there is no per-row "Switch" label; the heading says it once. Naming the destination is not just UI. `startSeeking` now takes an optional target, and the presence provider advertises ONLY that peer's token instead of cycling all remembered ones. So a targeted switch is found as fast as the single-peer case and spends less time on air, which is the §D1 observation that one advertisement suffices when the user picks where they are going. An untargeted seek (the card header's unlink action) still multiplexes. Each row reports when the laptop was last heard from, falling back to when it was paired. `peerLastSeen` only covers peers seen during THIS app process — it is not persisted — so a laptop paired yesterday reads as never-seen after a restart. "not seen yet" would be true of the session and false to the user, who remembers pairing it. Verified on device with two real peers: main card "Kapital / Connected / 100%", "Also paired" listing "gaia" with its last-seen line, and the targeted-seek handler wired through. The row-label removal that followed is compile-verified only — the phone locked before it could be re-captured. Co-Authored-By: Claude Opus 5 --- .../com/vortex/a3/service/VortexService.kt | 3 +- .../java/com/vortex/a3/service/VortexStack.kt | 21 ++- .../java/com/vortex/a3/ui/MainActivity.kt | 1 + .../com/vortex/a3/ui/MainActivityPairing.kt | 23 +++ .../src/main/java/com/vortex/a3/ui/Strings.kt | 6 + .../main/java/com/vortex/a3/ui/VortexRoot.kt | 3 + .../vortex/a3/ui/components/OtherPeersCard.kt | 150 ++++++++++++++++++ .../com/vortex/a3/ui/screens/HomeScreen.kt | 13 ++ 8 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt index 9fd1fcf..0a9dc9a 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt @@ -698,7 +698,8 @@ class VortexService : Service() { * remembered laptops, so the UI can leave the button disabled rather * than opening a window that cannot succeed. */ - fun startSeeking(): Boolean = liveStack?.startSeeking() ?: false + fun startSeeking(target: ByteArray? = null): Boolean = + liveStack?.startSeeking(target) ?: false /** Close a seek window; advertising returns to whatever the phase * machine says (silent while linked, presence otherwise). */ diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index 5df035c..df0694e 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -94,6 +94,16 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { /** elapsedRealtime of the last WIFI_DIRECT_OFFER, for coalescing (see * [maybeStartWifiDirect]). 0 = none since the group last went down. */ @Volatile internal var lastWifiDirectOfferAtMs: Long = 0L + + /** + * The one laptop a seek is aimed at, when the user picked it explicitly. + * + * A targeted seek advertises only THAT peer's token instead of cycling all + * of them, so it is both faster to be found (no dwell sharing) and cheaper + * on air. `null` = untargeted, i.e. "any remembered laptop but the current + * one" (design doc §D1). + */ + @Volatile internal var seekTarget: ByteArray? = null /** Icon PNG bytes per ICON frame chunk (kept under the BLE notify MTU * once the appId header + AEAD tag + frame header are added). */ internal val ICON_CHUNK = 180 @@ -872,6 +882,13 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { return@provider emptyList() } if (!adv.seeking) return@provider all.map { it.prs } + // Targeted seek: advertise only the chosen laptop's token. One + // token means no dwell sharing, so it is seen as fast as the + // single-peer case. + seekTarget?.let { target -> + return@provider all.filter { it.peerStaticPub.contentEquals(target) } + .map { it.prs } + } val linkedPub = activePeerPub all.filter { linkedPub == null || !it.peerStaticPub.contentEquals(linkedPub) } .map { it.prs } @@ -895,7 +912,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * Open a seek window: advertise to the other remembered laptops while * staying connected to the current one. See [VortexService.startSeeking]. */ - fun startSeeking(): Boolean { + fun startSeeking(target: ByteArray? = null): Boolean { val adv = advertiser ?: return false // Nothing to switch to — refuse rather than burn the radio on a window // that cannot possibly succeed. @@ -905,6 +922,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { return false } seekJob?.cancel() + seekTarget = target?.copyOf() adv.seeking = true // Re-advertise immediately with the seek peer set instead of waiting // out the current dwell / rotation sleep. @@ -928,6 +946,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { val adv = advertiser ?: return if (!adv.seeking) return adv.seeking = false + seekTarget = null // Back to the phase machine's verdict: silent if still linked, // presence otherwise. adv.kickRotation() diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt index 5d74669..43f0c93 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt @@ -449,6 +449,7 @@ class MainActivity : ComponentActivity() { onAddPair = ::onAddPairClicked, onCancelAddPair = ::endPairingWindow, onSwitchLaptop = ::onSwitchLaptopClicked, + onSwitchToPeer = ::onSwitchToPeerClicked, onOpenAutostart = ::onOpenAutostartSettings, onDismissAutostartHint = ::dismissAutostartHint, onRequestBatteryWhitelist = ::onRequestBatteryWhitelist, diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt index 42d1d90..3102f12 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt @@ -259,6 +259,29 @@ internal fun MainActivity.onSwitchLaptopClicked() { } } +/** + * Switch to one specific remembered laptop. + * + * Unlike [onSwitchLaptopClicked] this names the destination, so the seek + * advertises only that peer's token rather than cycling every remembered one — + * found as fast as the single-peer case, and less time on air. + */ +internal fun MainActivity.onSwitchToPeerClicked(peer: TrustedPeer) { + if (VortexService.isSeeking()) { + // Already looking; a second tap would only change the target + // mid-flight. Treat it as cancel, matching the header action. + VortexService.stopSeeking() + seekingLaptop.value = false + return + } + val started = VortexService.startSeeking(peer.peerStaticPub) + seekingLaptop.value = started + android.util.Log.i( + "VortexSwitch", + "targeted seek for '${peer.peerName ?: "laptop"}' started=$started", + ) +} + internal fun MainActivity.onApproveClicked(outcome: PairingOrchestrator.HandshakeOutcome) { val orch = pairingOrchestrator ?: return val frame = orch.buildLocalApprovalFrame( diff --git a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt index 8b8d2f2..b09e107 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/Strings.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/Strings.kt @@ -53,6 +53,8 @@ private val EN = mapOf( "peers.forget_body" to "Stop trusting %s? You'll need to re-pair to connect again.", "peers.forget_confirm" to "Forget", "peers.add_pair" to "Pair another laptop", + "peers.other_title" to "Also paired", + "peers.other_hint" to "Tap to switch", "peers.add_pair_cancel" to "Cancel", "peers.add_pair_hint" to "Make this phone discoverable so a second laptop can pair with it. Your current laptop won't see the phone while the window is open.", "earbuds.not_connected" to "Not connected", @@ -193,6 +195,8 @@ private val UZ = mapOf( "peers.forget_body" to "%s bilan aloqa uziladi. Qayta ulanish uchun yana pairing qilinadi.", "peers.forget_confirm" to "Unutish", "peers.add_pair" to "Boshqa laptop qo'shish", + "peers.other_title" to "Yana ulangan", + "peers.other_hint" to "O'tish uchun bosing", "peers.add_pair_cancel" to "Bekor qilish", "peers.add_pair_hint" to "Ikkinchi laptop ulanishi uchun telefonni ko'rinadigan qiling. Oyna ochiq bo'lganda hozirgi laptop telefonni ko'rmaydi.", "earbuds.not_connected" to "Ulangmagan", @@ -333,6 +337,8 @@ private val RU = mapOf( "peers.forget_body" to "Перестать доверять %s? Чтобы вновь подключиться, потребуется повторное сопряжение.", "peers.forget_confirm" to "Забыть", "peers.add_pair" to "Добавить ноутбук", + "peers.other_title" to "Также сопряжены", + "peers.other_hint" to "Нажмите, чтобы переключиться", "peers.add_pair_cancel" to "Отмена", "peers.add_pair_hint" to "Сделать телефон видимым, чтобы с ним мог связаться второй ноутбук. Пока окно открыто, текущий ноутбук телефон не видит.", "earbuds.not_connected" to "Не подключены", diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index fcfb6d6..b716ea8 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -83,6 +83,8 @@ class VortexActions( val onCancelAddPair: () -> Unit, /** Look for another remembered laptop while staying on this one. */ val onSwitchLaptop: () -> Unit, + /** Switch to one NAMED laptop; lets the seek advertise a single token. */ + val onSwitchToPeer: (TrustedPeer) -> Unit, val onOpenAutostart: () -> Unit, val onDismissAutostartHint: () -> Unit, val onRequestBatteryWhitelist: () -> Unit, @@ -268,6 +270,7 @@ fun VortexRoot( onAddPair = actions.onAddPair, onCancelAddPair = actions.onCancelAddPair, onSwitchLaptop = actions.onSwitchLaptop, + onSwitchToPeer = actions.onSwitchToPeer, seekingLaptop = ui.seekingLaptop.collectAsState().value, onOpenAutostart = actions.onOpenAutostart, onDismissAutostartHint = actions.onDismissAutostartHint, diff --git a/android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt b/android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt new file mode 100644 index 0000000..85eeab0 --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/ui/components/OtherPeersCard.kt @@ -0,0 +1,150 @@ +package com.vortex.a3.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Laptop +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight as FW +import androidx.compose.ui.unit.dp +import com.vortex.a3.core.storage.TrustedPeer +import com.vortex.a3.ui.str + +/** + * The laptops this phone is paired with but not currently using. + * + * Compact rows rather than full [PeerDeviceCard]s: those are 180 dp each, so a + * card per laptop would push everything else off the screen for information + * that is mostly "this one exists and is not the one you are on". + * + * Tapping a row switches to THAT laptop. That is deliberately more specific + * than the card header's generic unlink action: naming the destination lets the + * seek advertise a single token instead of cycling every remembered peer, so it + * is both faster and cheaper on air (design doc §D1). + */ +@Composable +fun OtherPeersCard( + peers: List, + lastSeen: Map, + now: Long, + seeking: Boolean, + onSwitchTo: (TrustedPeer) -> Unit, +) { + if (peers.isEmpty()) return + SurfaceCard { + Text( + str("peers.other_title"), + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FW.SemiBold, + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + str("peers.other_hint"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + for (peer in peers) { + Spacer(modifier = Modifier.height(10.dp)) + val hex = peer.peerStaticPub.toHex() + val seen = lastSeen[hex] ?: 0L + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + // The whole row is the target: a 20 dp glyph is a poor tap + // area, and there is only one action per row anyway. + .clickable(enabled = !seeking) { onSwitchTo(peer) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(10.dp)) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Laptop, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + peer.peerName?.takeIf { it.isNotBlank() } ?: str("device.linux"), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + Text( + // Never claim a peer is reachable: these are the ones we + // are NOT connected to, so the honest thing to show is + // when it was last heard from. + lastSeenLabel(seen, peer.pairedAt, now), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + // No trailing "Switch" label: the row IS the button and the + // heading already says so, so a per-row repeat is noise. Only + // the in-flight spinner earns space here. + if (seeking) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +/** + * "seen 12 min ago", or "paired 2 d ago" when we have not heard from it. + * + * [seenMs] only covers peers heard from during THIS app process — it is not + * persisted — so a laptop paired yesterday reads as never-seen after a restart. + * "not seen yet" would be true of the session and false to the user, who + * remembers pairing it. Falling back to [pairedAtSec] says something both + * accurate and useful. + */ +private fun lastSeenLabel(seenMs: Long, pairedAtSec: Long, nowMs: Long): String { + if (seenMs > 0L) return "seen ${ago((nowMs - seenMs) / 1000)}" + if (pairedAtSec > 0L) return "paired ${ago(nowMs / 1000 - pairedAtSec)}" + return "not connected" +} + +private fun ago(secs: Long): String { + val s = secs.coerceAtLeast(0) + return when { + s < 60 -> "just now" + s < 3600 -> "${s / 60} min ago" + s < 86_400 -> "${s / 3600} h ago" + else -> "${s / 86_400} d ago" + } +} diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt index d0bcd02..6af5337 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt @@ -59,6 +59,7 @@ import com.vortex.a3.ui.components.CardCorner import com.vortex.a3.ui.components.EarbudsCard import com.vortex.a3.ui.components.EarbudsPickerDialog import com.vortex.a3.ui.components.HintCard +import com.vortex.a3.ui.components.OtherPeersCard import com.vortex.a3.ui.components.PeerDeviceCard import com.vortex.a3.ui.components.SurfaceCard import com.vortex.a3.ui.components.VortexDivider @@ -90,6 +91,7 @@ fun HomeScreen( onAddPair: () -> Unit, onCancelAddPair: () -> Unit, onSwitchLaptop: () -> Unit, + onSwitchToPeer: (TrustedPeer) -> Unit, seekingLaptop: Boolean, onOpenAutostart: () -> Unit, onDismissAutostartHint: () -> Unit, @@ -297,6 +299,17 @@ fun HomeScreen( ) } + // Every other paired laptop, tappable to switch to it. + // Compact rows: a 180 dp card each would push the rest of the + // screen away for what is mostly "this one exists". + OtherPeersCard( + peers = peers.filter { it !== primaryPeer }, + lastSeen = peerLastSeen, + now = now, + seeking = seekingLaptop, + onSwitchTo = onSwitchToPeer, + ) + // "Pair another laptop". A pairable window preempts the // trusted-presence beacon (one advertising set), so it is // explicit and bounded rather than always-on: while it is From b16d49ad1530f8adec51230bb274e1be231b4d4e Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Fri, 21 Aug 2026 13:54:13 +0200 Subject: [PATCH 19/71] build(daemon): declare the tokio features the crate actually uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list stopped at "signal" while the code uses `sync` (mpsc/oneshot/Mutex/ Notify/OnceCell across ~14 modules), `net` (the LAN TCP transport), `io-util` (the async read/write over it) and `process` (pactl / loginctl / xdg-user-dir). It built anyway because bluer, secret-service and zbus each pull tokio in with more features and Cargo unifies them — so the crate was relying on what its D-Bus dependencies happened to enable. Nothing platform-specific about the omission; it just wasn't visible while those dependencies were unconditional. Moving them behind a target gate turns ~130 errors loose, starting with `module 'sync' is private`. Verified: `cargo check -p vortex-l3-daemon` and `cargo test --lib` unchanged. Co-Authored-By: Claude Opus 5 --- linux/daemon/Cargo.toml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/linux/daemon/Cargo.toml b/linux/daemon/Cargo.toml index 5a675f2..6d2e955 100644 --- a/linux/daemon/Cargo.toml +++ b/linux/daemon/Cargo.toml @@ -56,7 +56,22 @@ x25519-dalek = { version = "2", features = ["static_secrets"] } uuid = "1" thiserror = "1" bluer = { version = "0.17", features = ["bluetoothd"] } -tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "time", "signal"] } +# The feature list must name everything we actually use. It used to stop at +# "signal" and still built, because bluer/secret-service/zbus pulled the rest in +# and Cargo unified the features — so moving those behind a target gate (below) +# turned ~100 "module `sync` is private" errors loose on the Windows target. +# Nothing here is platform-bound; the omission just wasn't visible with one OS. +tokio = { version = "1", features = [ + "macros", + "rt", + "rt-multi-thread", + "time", + "signal", + "sync", # mpsc/oneshot/Mutex/Notify/OnceCell, ~14 modules + "net", # the LAN TCP transport + "io-util", # AsyncReadExt/AsyncWriteExt over that transport + "process", # spawning helpers (pactl, loginctl, xdg-user-dir) +] } futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } From 2b8c8124b3e28bd7f107f8a8e9f8d272cabeaab9 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Fri, 21 Aug 2026 13:54:57 +0200 Subject: [PATCH 20/71] feat(platform): a platform seam, and a daemon library that builds for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the Windows port: a trait boundary between feature logic and the OS, and enough gating that the platform-neutral core compiles for a second target. Verify with cargo check -p vortex-l3-daemon --lib --target x86_64-pc-windows-gnu `--lib` because `src/main.rs` is a Linux BLE CLI harness, and `-gnu` because an MSVC cross-check wants `lib.exe` and dies in cc-rs before reaching our code. `core::platform` holds the traits: UserPaths, Notifier, SessionControl, Autostart, InputCapture, BleCentral/GattLink. Linux implementations delegate to the existing modules, so nothing changes behaviour there. The first real Windows implementations land here too — WinRT BLE central and the known-folder paths (see below). Two things turned out to be layering mistakes rather than porting problems, and both were already biting on Linux: * `core::ble` mixed the WIRE PROTOCOL with the BlueZ transport. `frame.rs` has no BlueZ reference at all and `lan::tcp_client` already reassembles bulk-sync datasets by BLE frame type — the LAN path was reaching through a "BLE" module for protocol constants. `frame` is now unconditional and only `client`/`scanner`/`audio_signal` are Linux-gated; * `prologue_with_prs` — the IK prologue with the Pairwise Reconnect Secret mixed in — lived in the BLE reconnect module while `lan::tcp_client` called into it. It is normative wire material, so it moves to `crypto::noise` beside the prologue it extends, verbatim, and gains the two tests it never had (layout pinned to base‖PRS; a different PRS gives a different prologue). A mismatch here surfaces as an AEAD failure on msg1, not as anything readable, which is why it is worth pinning. `fs_private` is now genuinely cross-platform rather than gated away, since it is how the mirror caches are written. Windows has no mode to set: a file under the user profile inherits an ACL that excludes other standard users but grants Administrators and SYSTEM, and only while the path really is inside the profile. That is weaker than 0600, so the module says so and carries a TODO naming `SetNamedSecurityInfoW`. Identity and peer keys never go through it. WinRT BLE central (`platform/windows/ble.rs`) implements scan, connect, bonded, write, subscribe and disconnect. Three constraints shaped it: * `DataWriter`/`IBuffer` and the advertisement watcher are NOT agile — they hold a raw COM pointer that isn't Send — so the buffer is built in a scope that ends before the await, and the watcher (which must outlive the scan) runs on its own thread with only the address coming back. No `unsafe impl Send` anywhere: windows-rs marks the genuinely agile types, and asserting the rest would silence the next real violation; * `WriteValueWithResultAsync` takes no write option — the overload is `...AndOptionAsync`. The short form always writes WITH response, which would stall the unacknowledged frame path; * WinRT activation fails with CO_E_NOTINITIALIZED on a thread with no apartment, and the daemon's threads are plain tokio workers, so every entry point joins the MTA first. What that does not cover — a future suspending on one worker and resuming on another — is documented as the known risk, pointing at the `SECRET_RT` precedent in `core::storage`. `WindowsPaths` uses `SHGetKnownFolderPath` for Downloads, AppData and LocalAppData rather than `%USERPROFILE%`: all three can be redirected (OneDrive moves Downloads by default), and writing received files where the user never looks is the same bug the Linux side had with a hardcoded `~/Downloads` on a French desktop. `KF_FLAG_DONT_VERIFY`, matching the XDG side — a configured-but-missing folder is still the user's intent and the receive path creates it. Nothing in the WinRT code has been RUN. It type-checks against the Windows metadata, which catches signatures and types and nothing about behaviour; BLE cannot be exercised from Linux. The one part that is actually verified is `PeerAddr::from_u48`/`to_u48`, kept in the seam with 3 tests because address byte order has no error path — a mirrored address is a valid-looking address that nothing answers on. Also gated, with the reason on each cfg: the D-Bus / BlueZ / PulseAudio / Secret Service modules. Two of those gates are load-bearing and want trait work rather than a second copy — `pairing::{handshake, reconnect}` need `&dyn GattLink` instead of a concrete `VortexClient`, and `ble::audio_signal` has protocol-level frame dispatch trapped inside the BlueZ listener. Verified: Windows lib check clean, no warnings; `cargo test -p vortex-l3-daemon --lib` 152 passed; the Tauri app crate unchanged (`cargo check`, 38 tests). Co-Authored-By: Claude Opus 5 --- linux/Cargo.lock | 198 ++++++-- linux/daemon/Cargo.toml | 32 +- linux/daemon/src/core/ble/mod.rs | 15 +- linux/daemon/src/core/crypto/noise.rs | 44 +- linux/daemon/src/core/fs_private.rs | 131 +++-- linux/daemon/src/core/lan/tcp_client.rs | 2 +- linux/daemon/src/core/mod.rs | 13 + linux/daemon/src/core/pairing/mod.rs | 15 + linux/daemon/src/core/pairing/reconnect.rs | 19 +- linux/daemon/src/core/platform/linux.rs | 226 +++++++++ linux/daemon/src/core/platform/mod.rs | 320 ++++++++++++ linux/daemon/src/core/platform/windows/ble.rs | 475 ++++++++++++++++++ linux/daemon/src/core/platform/windows/mod.rs | 142 ++++++ linux/daemon/src/core/storage/mod.rs | 14 +- linux/ui-tauri/src-tauri/Cargo.lock | 74 ++- .../ui-tauri/src-tauri/src/clipboard_sync.rs | 10 +- 16 files changed, 1616 insertions(+), 114 deletions(-) create mode 100644 linux/daemon/src/core/platform/linux.rs create mode 100644 linux/daemon/src/core/platform/mod.rs create mode 100644 linux/daemon/src/core/platform/windows/ble.rs create mode 100644 linux/daemon/src/core/platform/windows/mod.rs diff --git a/linux/Cargo.lock b/linux/Cargo.lock index a3a3cf3..e0b4616 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -72,7 +72,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -83,7 +83,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -193,9 +193,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -299,7 +299,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -320,7 +320,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -345,7 +345,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -356,14 +356,14 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "futures-channel", "futures-util", @@ -405,13 +405,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -438,7 +438,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -588,7 +588,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -958,7 +958,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1031,22 +1031,22 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1057,9 +1057,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" @@ -1100,7 +1100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1289,7 +1289,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1313,7 +1313,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1435,7 +1435,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -1455,6 +1455,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1463,7 +1474,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1496,7 +1507,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1533,14 +1544,14 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -1596,7 +1607,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1731,6 +1742,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows", "x25519-dalek", "zbus", ] @@ -1791,7 +1803,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1838,12 +1850,107 @@ dependencies = [ "semver", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1887,6 +1994,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1980,7 +2096,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1996,7 +2112,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2089,7 +2205,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -2123,7 +2239,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2143,7 +2259,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2175,7 +2291,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -2188,6 +2304,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "winnow", ] diff --git a/linux/daemon/Cargo.toml b/linux/daemon/Cargo.toml index 6d2e955..8c5ffbe 100644 --- a/linux/daemon/Cargo.toml +++ b/linux/daemon/Cargo.toml @@ -55,7 +55,6 @@ serde_json = "1" x25519-dalek = { version = "2", features = ["static_secrets"] } uuid = "1" thiserror = "1" -bluer = { version = "0.17", features = ["bluetoothd"] } # The feature list must name everything we actually use. It used to stop at # "signal" and still built, because bluer/secret-service/zbus pulled the rest in # and Cargo unified the features — so moving those behind a target gate (below) @@ -77,8 +76,37 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } humantime = "2" rand = "0.8" -secret-service = { version = "5", features = ["rt-tokio-crypto-rust"] } mdns-sd = "0.13" + +# ── platform-bound dependencies ─────────────────────────────────────────── +# Everything below is one OS's API surface and must stay behind +# `core::platform`. Target-gating them is what lets `cargo check --target +# x86_64-pc-windows-msvc` resolve at all: BlueZ, Secret Service and D-Bus have +# no Windows build, and pulling them unconditionally fails during resolution +# rather than in code you can cfg away. +[target.'cfg(target_os = "windows")'.dependencies] +# WinRT + Win32, feature-gated per namespace so we pull metadata for what we +# actually call. BLE central lives in Devices_Bluetooth*; Devices_Enumeration is +# how you find already-paired devices; Storage_Streams is the buffer type every +# GATT read/write goes through; Win32_UI_Shell is SHGetKnownFolderPath and +# Win32_System_Com is the CoTaskMemFree that pairs with it. +windows = { version = "0.62", features = [ + "Devices_Bluetooth", + "Devices_Bluetooth_Advertisement", + "Devices_Bluetooth_GenericAttributeProfile", + "Devices_Enumeration", + "Foundation", + "Foundation_Collections", + "Storage_Streams", + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_WinRT", + "Win32_UI_Shell", +] } + +[target.'cfg(target_os = "linux")'.dependencies] +bluer = { version = "0.17", features = ["bluetoothd"] } +secret-service = { version = "5", features = ["rt-tokio-crypto-rust"] } # Re-use the zbus that secret-service already pulls in for direct # MPRIS calls (call-handoff media pause/resume). zbus = { version = "5", default-features = false, features = ["tokio"] } diff --git a/linux/daemon/src/core/ble/mod.rs b/linux/daemon/src/core/ble/mod.rs index ea3164c..4af0a87 100644 --- a/linux/daemon/src/core/ble/mod.rs +++ b/linux/daemon/src/core/ble/mod.rs @@ -1,9 +1,22 @@ //! BLE constants, advertisement payload codec, and platform-side BLE //! integration per spec §5 and §10. +// `frame` is the WIRE PROTOCOL: frame types, subtypes and chunk headers, shared +// byte-for-byte with the phone and used by the LAN transport too (see +// `lan::tcp_client`, which reassembles bulk-sync datasets by BLE frame type). +// It is pure Rust and must build everywhere — the phone cannot tell the two +// laptops apart, and `shared/vectors/` exists to keep it that way. +pub mod frame; + +// Everything below is BlueZ over D-Bus: the central-role transport that +// carries those frames on Linux. A second OS brings its own transport (WinRT +// `BluetoothLEDevice`) behind `core::platform::BleCentral` and reuses `frame` +// unchanged. +#[cfg(target_os = "linux")] pub mod audio_signal; +#[cfg(target_os = "linux")] pub mod client; -pub mod frame; +#[cfg(target_os = "linux")] pub mod scanner; /// V1 protocol version byte. Receivers MUST reject other versions (§5.2). diff --git a/linux/daemon/src/core/crypto/noise.rs b/linux/daemon/src/core/crypto/noise.rs index eab7701..c381949 100644 --- a/linux/daemon/src/core/crypto/noise.rs +++ b/linux/daemon/src/core/crypto/noise.rs @@ -7,8 +7,7 @@ use snow::{params::NoiseParams, Builder}; pub const NOISE_XX: &str = "Noise_XX_25519_ChaChaPoly_SHA256"; /// The reconnect pattern — used by `run_ik_deterministic` for the test /// vector AND at runtime. Runtime additionally mixes the Pairwise Reconnect -/// Secret into the prologue (see -/// [`crate::core::pairing::reconnect::prologue_with_prs`]), which is what +/// Secret into the prologue (see [`prologue_with_prs`]), which is what /// keeps a reconnect authenticated after a long-term static-key compromise. /// That is the goal `Noise_IKpsk2` would serve; the prologue route reaches it /// without a pattern the Android-side Noise library does not implement. @@ -17,6 +16,26 @@ pub const NOISE_IK: &str = "Noise_IK_25519_ChaChaPoly_SHA256"; pub const PROLOGUE_XX: &[u8] = b"vortex/v1/pairing"; pub const PROLOGUE_IK: &[u8] = b"vortex/v1/reconnect"; +/// Build the IK prologue with the Pairwise Reconnect Secret mixed in. +/// +/// We extend the base prologue with the 32-byte PRS so that any wrong-PRS +/// attempt by an attacker who has compromised only the long-term static +/// private key fails AEAD verification on msg1's `s` decryption. This achieves +/// the same security goal as Noise_IKpsk2_... — binding reconnect to BOTH +/// static keys AND the prior pairing transcript — without requiring a Noise +/// pattern that the Android-side library does not yet implement. +/// +/// Lives HERE, with the prologue it extends, rather than in the BLE reconnect +/// module it was first written in: both transports need it (`lan::tcp_client` +/// runs the same IK over TCP) and it is normative wire material, so it must +/// not sit behind a platform gate. +pub(crate) fn prologue_with_prs(prs: &[u8; 32]) -> Vec { + let mut out = Vec::with_capacity(PROLOGUE_IK.len() + 32); + out.extend_from_slice(PROLOGUE_IK); + out.extend_from_slice(prs); + out +} + /// Result of a deterministic handshake run. #[derive(Debug, Clone)] pub struct HandshakeResult { @@ -209,4 +228,25 @@ mod tests { let result = initiator.read_message(&buf[..len], &mut tmp); assert!(result.is_err(), "mismatched prologue must fail AEAD"); } + + /// The reconnect prologue is normative wire material: the phone builds the + /// same bytes, and a mismatch fails AEAD on msg1 rather than producing a + /// readable error. Pin the layout — base prologue, then the raw 32-byte + /// PRS, nothing else — so a refactor can't silently reorder or pad it. + #[test] + fn ik_prologue_is_base_then_prs() { + let prs = [0xAB; 32]; + let p = prologue_with_prs(&prs); + assert_eq!(p.len(), PROLOGUE_IK.len() + 32); + assert_eq!(&p[..PROLOGUE_IK.len()], PROLOGUE_IK); + assert_eq!(&p[PROLOGUE_IK.len()..], &prs[..]); + assert_eq!(&p[..PROLOGUE_IK.len()], b"vortex/v1/reconnect"); + } + + /// A different PRS must give a different prologue — that difference IS the + /// binding to the prior pairing transcript. + #[test] + fn a_different_prs_gives_a_different_prologue() { + assert_ne!(prologue_with_prs(&[1u8; 32]), prologue_with_prs(&[2u8; 32])); + } } diff --git a/linux/daemon/src/core/fs_private.rs b/linux/daemon/src/core/fs_private.rs index 54aef2d..a57cced 100644 --- a/linux/daemon/src/core/fs_private.rs +++ b/linux/daemon/src/core/fs_private.rs @@ -1,19 +1,35 @@ //! Owner-only filesystem helpers for the on-disk mirror caches. //! -//! Everything under `~/.cache/vortex/` carries phone-private data (SMS -//! bodies, contacts, call history, app icons). These helpers make sure the -//! directory is 0700 and every file 0600 so other local users can't read -//! them — including repairing permissions left behind by older builds that -//! wrote with the default umask. +//! Everything under the cache root carries phone-private data (SMS bodies, +//! contacts, call history, app icons), so the directory and every file in it +//! must be readable by this user and nobody else — including repairing +//! permissions left behind by older builds that wrote with the default umask. +//! +//! # The two platforms do not offer the same guarantee +//! +//! On Unix this is exact: 0700 on the directory, 0600 on each file, set +//! explicitly rather than left to the umask. +//! +//! On Windows there is no mode to set. A file under the user's profile inherits +//! that profile's ACL, which already excludes other standard users — but grants +//! `Administrators` and `SYSTEM`. That is weaker than 0600 (where root is the +//! only equivalent) and it is *inherited*, so it holds only as long as the path +//! really is inside the profile. Tightening it means writing an explicit DACL +//! with `SetNamedSecurityInfoW`; until that exists, [`write_private`] on Windows +//! is "as private as the user's profile" and no more. Callers storing anything +//! stronger than mirror data must not rely on it. (Identity and peer keys do +//! not: they live in Secret Service / Credential Manager via +//! [`crate::core::storage`], never here.) use std::fs; use std::io; -use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::Path; -/// Create `dir` (and parents) owner-only. If it already exists, tighten its -/// mode to 0700 — this repairs caches written by older builds. +/// Create `dir` (and parents) owner-only. If it already exists, tighten it — +/// this repairs caches written by older builds. +#[cfg(unix)] pub fn create_private_dir(dir: &Path) -> io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; match fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} @@ -24,13 +40,25 @@ pub fn create_private_dir(dir: &Path) -> io::Result<()> { fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) } +/// Create `dir` (and parents), inheriting the user profile's ACL. +/// +/// TODO: `SetNamedSecurityInfoW` with an explicit owner-only DACL and +/// `PROTECTED_DACL_SECURITY_INFORMATION` to stop inheritance. See the module +/// docs for what is and isn't guaranteed until then. +#[cfg(windows)] +pub fn create_private_dir(dir: &Path) -> io::Result<()> { + fs::create_dir_all(dir) +} + /// Write `bytes` to `path` with mode 0600, creating the parent dir 0700. /// Truncates an existing file and tightens its mode too. +#[cfg(unix)] pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; if let Some(parent) = path.parent() { create_private_dir(parent)?; } - use std::io::Write; let mut f = fs::OpenOptions::new() .write(true) .create(true) @@ -42,39 +70,78 @@ pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { f.write_all(bytes) } +/// Write `bytes` to `path`, creating the parent dir, both inheriting the user +/// profile's ACL. See the module docs: this is weaker than the Unix path. +#[cfg(windows)] +pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + if let Some(parent) = path.parent() { + create_private_dir(parent)?; + } + let mut f = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path)?; + f.write_all(bytes) +} + #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::PermissionsExt; - fn mode_of(p: &Path) -> u32 { - fs::metadata(p).unwrap().permissions().mode() & 0o777 + /// A fresh per-test directory under the system temp dir. + fn scratch(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vortex-fsp-{tag}-{}", std::process::id())) } + /// Holds on both platforms: the write lands, parents are created, and a + /// second write replaces rather than appends. #[test] - fn dir_and_file_are_owner_only() { - let base = std::env::temp_dir().join(format!("vortex-fsp-{}", std::process::id())); - let dir = base.join("nested"); - let file = dir.join("data.json"); - write_private(&file, b"x").unwrap(); - assert_eq!(mode_of(&dir), 0o700); - assert_eq!(mode_of(&file), 0o600); + fn writes_through_missing_parents_and_replaces_content() { + let base = scratch("rw"); + let file = base.join("nested").join("data.json"); + write_private(&file, b"old").unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"old"); + write_private(&file, b"new").unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"new"); let _ = fs::remove_dir_all(&base); } - #[test] - fn repairs_existing_loose_permissions() { - let base = std::env::temp_dir().join(format!("vortex-fsp-fix-{}", std::process::id())); - fs::create_dir_all(&base).unwrap(); - fs::set_permissions(&base, fs::Permissions::from_mode(0o755)).unwrap(); - let file = base.join("data.json"); - fs::write(&file, b"old").unwrap(); - fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + #[cfg(unix)] + mod unix { + use super::*; + use std::os::unix::fs::PermissionsExt; - write_private(&file, b"new").unwrap(); - assert_eq!(mode_of(&base), 0o700); - assert_eq!(mode_of(&file), 0o600); - assert_eq!(fs::read(&file).unwrap(), b"new"); - let _ = fs::remove_dir_all(&base); + fn mode_of(p: &Path) -> u32 { + fs::metadata(p).unwrap().permissions().mode() & 0o777 + } + + #[test] + fn dir_and_file_are_owner_only() { + let base = scratch("modes"); + let dir = base.join("nested"); + let file = dir.join("data.json"); + write_private(&file, b"x").unwrap(); + assert_eq!(mode_of(&dir), 0o700); + assert_eq!(mode_of(&file), 0o600); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn repairs_existing_loose_permissions() { + let base = scratch("fix"); + fs::create_dir_all(&base).unwrap(); + fs::set_permissions(&base, fs::Permissions::from_mode(0o755)).unwrap(); + let file = base.join("data.json"); + fs::write(&file, b"old").unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + + write_private(&file, b"new").unwrap(); + assert_eq!(mode_of(&base), 0o700); + assert_eq!(mode_of(&file), 0o600); + assert_eq!(fs::read(&file).unwrap(), b"new"); + let _ = fs::remove_dir_all(&base); + } } } diff --git a/linux/daemon/src/core/lan/tcp_client.rs b/linux/daemon/src/core/lan/tcp_client.rs index 1ed5141..2dbdd96 100644 --- a/linux/daemon/src/core/lan/tcp_client.rs +++ b/linux/daemon/src/core/lan/tcp_client.rs @@ -189,7 +189,7 @@ pub(crate) fn build_ik_initiator( // achieves the same goal — wrong PRS yields different MixHash and // breaks AEAD verification on msg1. let params: NoiseParams = NOISE_IK.parse()?; - let prologue = crate::core::pairing::reconnect::prologue_with_prs(prs); + let prologue = crate::core::crypto::noise::prologue_with_prs(prs); Builder::new(params) .local_private_key(static_priv)? .remote_public_key(peer_static_pub)? diff --git a/linux/daemon/src/core/mod.rs b/linux/daemon/src/core/mod.rs index 512c807..6ddf5ec 100644 --- a/linux/daemon/src/core/mod.rs +++ b/linux/daemon/src/core/mod.rs @@ -1,16 +1,25 @@ pub mod appstate; +// The earbuds audio-op transport, driven by `audio_orchestrator` (PulseAudio + +// BlueZ). LAN-shaped but Linux-bound in purpose: a Windows build has no audio +// backend to hand the buds to yet. +#[cfg(target_os = "linux")] pub mod audio_lan_session; pub mod audio_op; +#[cfg(target_os = "linux")] pub mod audio_orchestrator; pub mod audio_route; pub mod audio_sink_cache; +#[cfg(target_os = "linux")] pub mod audio_switch; pub mod audio_switch_persistence; pub mod hogp; +#[cfg(target_os = "linux")] pub mod media_runtime; +#[cfg(target_os = "linux")] pub mod media_watch; pub mod ble; pub mod crypto; +#[cfg(target_os = "linux")] pub mod earbuds; pub mod fs_private; pub mod earbuds_store; @@ -24,9 +33,11 @@ pub mod mirror_session; pub mod mirror_udp; pub mod mirror_tcp; pub mod notif_mirror; +#[cfg(target_os = "linux")] pub mod notification_display; pub mod notif_capturer; pub mod live_activity; +#[cfg(target_os = "linux")] pub mod live_activity_dbus; pub mod call_event; pub mod handoff; @@ -37,6 +48,8 @@ pub mod icon_cache; pub mod identity; pub mod lan; pub mod pairing; +pub mod platform; +#[cfg(target_os = "linux")] pub mod session_lock; pub mod status; pub mod storage; diff --git a/linux/daemon/src/core/pairing/mod.rs b/linux/daemon/src/core/pairing/mod.rs index bab6d39..faa8cc0 100644 --- a/linux/daemon/src/core/pairing/mod.rs +++ b/linux/daemon/src/core/pairing/mod.rs @@ -1,5 +1,20 @@ //! Pairing orchestration per spec §6. pub mod backoff; + +// The XX pairing and IK reconnect handshakes as run OVER BLE: the Noise state +// machine here is platform-neutral, but these two are written against +// `ble::client::VortexClient` concretely, so they can't build without BlueZ. +// +// Porting them is not a matter of cfg: they need to take a +// `&dyn core::platform::GattLink` instead of a `&VortexClient`, at which point +// both platforms share this code and only the transport differs. That refactor +// touches the most security-critical path in the tree, so it is deliberately +// NOT bundled with the mechanical gating — see the note in `platform`. +// +// The LAN side is unaffected either way: `lan::tcp_client` runs its own IK over +// TCP and is already portable. +#[cfg(target_os = "linux")] pub mod handshake; +#[cfg(target_os = "linux")] pub mod reconnect; diff --git a/linux/daemon/src/core/pairing/reconnect.rs b/linux/daemon/src/core/pairing/reconnect.rs index c3c7d47..e4d5756 100644 --- a/linux/daemon/src/core/pairing/reconnect.rs +++ b/linux/daemon/src/core/pairing/reconnect.rs @@ -10,7 +10,7 @@ use tracing::info; use crate::core::ble::client::{ClientError, VortexClient}; use crate::core::ble::frame::{ty, Frame, FrameDecodeError}; -use crate::core::crypto::noise::{NOISE_IK, PROLOGUE_IK}; +use crate::core::crypto::noise::NOISE_IK; use crate::core::crypto::x25519::X25519SecBytes; #[derive(Debug)] @@ -83,25 +83,10 @@ fn build_ik_initiator( Builder::new(params) .local_private_key(static_priv)? .remote_public_key(peer_static_pub)? - .prologue(&prologue_with_prs(prs))? + .prologue(&crate::core::crypto::noise::prologue_with_prs(prs))? .build_initiator() } -/// Build the IK prologue with the Pairwise Reconnect Secret mixed in. -/// -/// We extend the base prologue with the 32-byte PRS so that any wrong- -/// PRS attempt by an attacker who has compromised only the long-term -/// static private key fails AEAD verification on msg1's `s` decryption. -/// This achieves the same security goal as Noise_IKpsk2_... — binding -/// reconnect to BOTH static keys AND the prior pairing transcript — -/// without requiring a Noise pattern that the Android-side library -/// does not yet implement. -pub(crate) fn prologue_with_prs(prs: &[u8; 32]) -> Vec { - let mut out = Vec::with_capacity(PROLOGUE_IK.len() + 32); - out.extend_from_slice(PROLOGUE_IK); - out.extend_from_slice(prs); - out -} /// Run Noise IK against `client`'s peer using the local static identity, /// the trusted peer's static public key, and the Pairwise Reconnect diff --git a/linux/daemon/src/core/platform/linux.rs b/linux/daemon/src/core/platform/linux.rs new file mode 100644 index 0000000..e4c4b74 --- /dev/null +++ b/linux/daemon/src/core/platform/linux.rs @@ -0,0 +1,226 @@ +//! Linux implementations of the platform seam. +//! +//! These delegate to the modules that already existed — the seam is a boundary, +//! not a rewrite, so behaviour on Linux is unchanged by construction. + +use std::path::{Path, PathBuf}; + +use super::{BoxFuture, Notifier, SessionControl, UserPaths}; + +pub struct LinuxPaths; + +impl UserPaths for LinuxPaths { + /// The real XDG download directory (`~/Téléchargements` on a French + /// desktop), never a hardcoded English `~/Downloads` — that mistake + /// silently created a second folder beside the real one and filed every + /// received file where the user never looks. + fn downloads(&self) -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + Some(xdg_download_dir(&home).unwrap_or_else(|| home.join("Downloads"))) + } + + fn config(&self) -> Option { + Some(config_home()?.join("vortex")) + } + + fn cache(&self) -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".cache")); + Some(base.join("vortex")) + } +} + +fn config_home() -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + Some( + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".config")), + ) +} + +/// `XDG_DOWNLOAD_DIR` from the environment, else from the `user-dirs.dirs` file +/// `xdg-user-dir(1)` reads. Not required to exist — a configured-but-missing +/// folder is still the user's stated intent, and the caller creates it. +fn xdg_download_dir(home: &Path) -> Option { + if let Some(v) = std::env::var_os("XDG_DOWNLOAD_DIR") { + if let Some(p) = expand_home(&v.to_string_lossy(), home) { + return Some(p); + } + } + let text = std::fs::read_to_string(config_home()?.join("user-dirs.dirs")).ok()?; + expand_home(&parse_user_dirs(&text, "XDG_DOWNLOAD_DIR")?, home) +} + +/// Pull one key out of a `user-dirs.dirs` file: shell syntax, `# comment` lines +/// and `KEY="value"` assignments, last assignment winning as a shell would. +fn parse_user_dirs(text: &str, key: &str) -> Option { + let mut found = None; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + if k.trim() != key { + continue; + } + let v = v.trim(); + let v = v + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(v); + if !v.is_empty() { + found = Some(v.to_string()); + } + } + found +} + +/// Expand the `$HOME/…` (or `~/…`) prefix the spec mandates. Anything else must +/// already be absolute — a bare relative path is malformed, and guessing could +/// scatter files into the process's cwd. +fn expand_home(raw: &str, home: &Path) -> Option { + let raw = raw.trim(); + for prefix in ["$HOME", "${HOME}", "~"] { + if let Some(rest) = raw.strip_prefix(prefix) { + let rest = rest.trim_start_matches('/'); + return Some(if rest.is_empty() { + home.to_path_buf() + } else { + home.join(rest) + }); + } + } + let p = PathBuf::from(raw); + p.is_absolute().then_some(p) +} + +pub struct LinuxNotifier; + +impl Notifier for LinuxNotifier { + fn show( + &self, + summary: &str, + body: &str, + app_id: &str, + actions: &[(String, String)], + replaces: u32, + urgent: bool, + ) -> BoxFuture> { + let (summary, body, app_id) = (summary.to_string(), body.to_string(), app_id.to_string()); + let actions = actions.to_vec(); + Box::pin(async move { + crate::core::notification_display::show_call_banner( + &summary, &body, &app_id, &actions, replaces, urgent, + ) + .await + }) + } + + fn close(&self, id: u32) -> BoxFuture> { + Box::pin(async move { crate::core::notification_display::close(id).await }) + } + + fn actions(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, String)>) { + tokio::spawn(crate::core::notification_display::watch_actions(tx)); + } + + fn closures(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, u32)>) { + tokio::spawn(crate::core::notification_display::watch_closed(tx)); + } +} + +pub struct LinuxSession; + +impl SessionControl for LinuxSession { + fn lock(&self) -> BoxFuture> { + Box::pin(crate::core::session_lock::lock()) + } + + fn unlock(&self) -> BoxFuture> { + Box::pin(crate::core::session_lock::unlock()) + } + + fn is_locked(&self) -> BoxFuture> { + Box::pin(crate::core::session_lock::locked_hint()) + } + + /// logind can unlock, given the one-time polkit rule. + fn can_unlock(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real French `user-dirs.dirs` — the case that made this code necessary. + const FR: &str = r#"# This file is written by xdg-user-dirs-update +XDG_DESKTOP_DIR="$HOME/Bureau" +XDG_DOWNLOAD_DIR="$HOME/Téléchargements" +XDG_DOCUMENTS_DIR="$HOME/Documents" +"#; + + #[test] + fn parses_localised_download_dir() { + let raw = parse_user_dirs(FR, "XDG_DOWNLOAD_DIR").expect("download dir"); + assert_eq!(raw, "$HOME/Téléchargements"); + assert_eq!( + expand_home(&raw, Path::new("/home/cyril")), + Some(PathBuf::from("/home/cyril/Téléchargements")) + ); + } + + #[test] + fn ignores_comments_and_other_keys() { + assert_eq!(parse_user_dirs(FR, "XDG_MUSIC_DIR"), None); + let text = "#XDG_DOWNLOAD_DIR=\"$HOME/nope\"\nXDG_DOWNLOAD_DIR=\"$HOME/yes\"\n"; + assert_eq!( + parse_user_dirs(text, "XDG_DOWNLOAD_DIR"), + Some("$HOME/yes".to_string()) + ); + } + + #[test] + fn last_assignment_wins_like_a_shell() { + let text = "XDG_DOWNLOAD_DIR=\"$HOME/first\"\nXDG_DOWNLOAD_DIR=\"$HOME/second\"\n"; + assert_eq!( + parse_user_dirs(text, "XDG_DOWNLOAD_DIR"), + Some("$HOME/second".to_string()) + ); + } + + #[test] + fn expands_home_forms_and_rejects_relative() { + let home = Path::new("/home/cyril"); + for raw in ["$HOME/Dl", "${HOME}/Dl", "~/Dl"] { + assert_eq!(expand_home(raw, home), Some(PathBuf::from("/home/cyril/Dl"))); + } + assert_eq!(expand_home("$HOME/", home), Some(home.to_path_buf())); + assert_eq!(expand_home("/data/dl", home), Some(PathBuf::from("/data/dl"))); + assert_eq!(expand_home("Downloads", home), None); + assert_eq!(expand_home("", home), None); + } + + #[test] + fn handles_unquoted_and_single_quoted() { + assert_eq!( + parse_user_dirs("XDG_DOWNLOAD_DIR=$HOME/Dl\n", "XDG_DOWNLOAD_DIR"), + Some("$HOME/Dl".to_string()) + ); + assert_eq!( + parse_user_dirs("XDG_DOWNLOAD_DIR='$HOME/Dl'\n", "XDG_DOWNLOAD_DIR"), + Some("$HOME/Dl".to_string()) + ); + } +} + diff --git a/linux/daemon/src/core/platform/mod.rs b/linux/daemon/src/core/platform/mod.rs new file mode 100644 index 0000000..81a2290 --- /dev/null +++ b/linux/daemon/src/core/platform/mod.rs @@ -0,0 +1,320 @@ +//! The platform seam: everything the laptop side needs from the OS, expressed +//! as traits so a second OS can be added without touching feature logic. +//! +//! # Why this exists +//! +//! Until now every OS call was made inline against a Linux API — BlueZ over +//! D-Bus, logind, XDG directories, the freedesktop notification service — with +//! no `cfg(target_os)` anywhere in the tree. That is fine for one OS and +//! impossible for two. These traits are the boundary: **feature logic above, +//! OS below**. The rule is that nothing above this line names a Linux concept. +//! +//! # What is deliberately NOT here +//! +//! * **Storage.** [`crate::core::storage`] already has `IdentityStore` and +//! `PeerStore`; a Windows Credential Manager implementation slots in beside +//! `SecretServiceIdentityStore` with no new trait. +//! * **The wire protocol, crypto, framing, LAN and mDNS.** They are pure Rust +//! and must stay byte-identical across platforms — the phone cannot tell the +//! two laptops apart, and `shared/vectors/` exists to keep it that way. +//! * **Clipboard.** `arboard` already covers Linux and Windows. +//! +//! # Status +//! +//! The Windows implementations are stubs that name the API they will call. They +//! are compiled only on Windows, so they cannot break the Linux build; and the +//! Linux implementations delegate to the existing modules, so this file adds a +//! boundary without changing behaviour. +//! +//! **The daemon LIBRARY compiles for Windows.** Verify with: +//! +//! ```text +//! cargo check -p vortex-l3-daemon --lib --target x86_64-pc-windows-gnu +//! ``` +//! +//! Two notes on that command. `--lib`, because `src/main.rs` is a Linux BLE CLI +//! harness and is not part of a Windows build (the product there is the Tauri +//! app). And `-gnu` rather than `-msvc`: an MSVC cross-check needs `lib.exe`, +//! which a Linux box does not have, so it dies in `cc-rs` before reaching our +//! code. The GNU target type-checks the same source. +//! +//! What compiles is the platform-neutral core: crypto, framing, the wire +//! protocol (`ble::frame`), LAN + mDNS, the pairing state machine, appstate, +//! the storage traits, and this seam. What is gated out — with the reason on +//! each `cfg` — is every direct BlueZ / D-Bus / PulseAudio / Secret Service +//! module. +//! +//! # The gates are not the port +//! +//! A `cfg(target_os = "linux")` on a module means "no Windows implementation +//! yet", not "not needed on Windows". Two of them are load-bearing and will +//! come back as trait work rather than as a second copy: +//! +//! * `pairing::{handshake, reconnect}` — the XX/IK Noise state machines are +//! platform-neutral but written against `ble::client::VortexClient` +//! concretely. They need to take `&dyn GattLink`, after which both platforms +//! share them. This is the most security-critical path in the tree, so it was +//! deliberately left out of the mechanical gating pass. +//! * `ble::audio_signal` — the frame dispatch, cipher-resync and AppState +//! decode in there are protocol logic that happens to live inside the BlueZ +//! notification listener. Windows needs the same dispatch behind a different +//! transport, so this wants splitting rather than reimplementing. + +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "windows")] +pub mod windows; + +/// Standard user directories. Localised on both platforms and NOT derivable by +/// joining an English folder name onto `$HOME` — the French desktop this was +/// first written on uses `~/Téléchargements`, and Windows relocates the +/// Downloads folder freely (OneDrive moves it by default). +pub trait UserPaths: Send + Sync { + /// Where received files are saved. Must be the user's real download folder. + fn downloads(&self) -> Option; + /// Per-user config root (`~/.config/vortex`, `%APPDATA%\Vortex`). + fn config(&self) -> Option; + /// Per-user cache root — icon cache, transient blobs. + fn cache(&self) -> Option; +} + +/// A desktop notification carrying optional action buttons. +/// +/// The hard part on both platforms is not showing it, it is getting the click +/// back. On Linux the sender must stay on the bus for Plasma to keep the +/// buttons, while GNOME needs a windowless sender. On Windows the toast needs +/// an AppUserModelID, and an unpackaged app needs a registered COM activator +/// before an action can round-trip at all. +pub trait Notifier: Send + Sync { + /// Show (or replace, when `replaces` is non-zero) a notification. `actions` + /// is `(key, label)`; the key comes back through [`Notifier::actions`]. + fn show( + &self, + summary: &str, + body: &str, + app_id: &str, + actions: &[(String, String)], + replaces: u32, + urgent: bool, + ) -> BoxFuture>; + + /// Withdraw a notification we previously showed. + fn close(&self, id: u32) -> BoxFuture>; + + /// Stream of `(notification id, action key)` for every button the user + /// clicks. One process-wide stream: consumers filter by key prefix + /// (`fc:` file consent, `call:` call banner, `act:` mirrored action). + fn actions(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, String)>); + + /// Stream of `(notification id, reason)` closures, so a dismissal on the + /// laptop can be mirrored back to the phone. + fn closures(&self, tx: tokio::sync::mpsc::UnboundedSender<(u32, u32)>); +} + +/// Lock / unlock the desktop session and report its current state — the +/// proximity feature's entire OS surface. +/// +/// Windows can lock (`LockWorkStation`) but deliberately cannot unlock +/// programmatically, so proximity *auto-unlock* is Linux-only and the trait +/// lets an implementation say so rather than fail at the call site. +pub trait SessionControl: Send + Sync { + fn lock(&self) -> BoxFuture>; + fn unlock(&self) -> BoxFuture>; + /// `None` when the platform can't report it. + fn is_locked(&self) -> BoxFuture>; + /// Whether [`SessionControl::unlock`] can work at all here. + fn can_unlock(&self) -> bool; +} + +/// Run Vortex at login. +pub trait Autostart: Send + Sync { + fn is_enabled(&self) -> bool; + fn set_enabled(&self, on: bool) -> Result<(), String>; +} + +/// Pointer/keyboard capture for Universal Control: hold the cursor at a screen +/// edge, take exclusive input, and stream events for forwarding to the phone. +/// +/// This is the one subsystem that is *easier* on Windows — a low-level hook +/// plus `ClipCursor` does what Wayland needs the input-capture portal and libei +/// for, and it works the same on every Windows desktop. +pub trait InputCapture: Send + Sync { + /// Arm capture on the given edge. Events flow to `tx` until released. + fn arm(&self, edge: Edge, tx: tokio::sync::mpsc::UnboundedSender) + -> BoxFuture>; + /// Release capture; the cursor returns to the laptop. + fn release(&self) -> BoxFuture>; + /// Hide the laptop's own cursor while control is on the phone. Best-effort: + /// GNOME-only today, and `false` means "couldn't", not "failed". + fn hide_cursor(&self, hidden: bool) -> bool; +} + +/// Which screen edge the phone sits on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edge { + Left, + Right, + Top, + Bottom, +} + +/// One captured input event, already in the platform-neutral form the phone +/// side expects. +#[derive(Debug, Clone, Copy)] +pub enum InputEvent { + /// Relative pointer motion. + Motion { dx: f64, dy: f64 }, + /// Button 1 = left, 2 = middle, 3 = right. + Button { button: u8, pressed: bool }, + /// Vertical / horizontal scroll, in notches. + Scroll { dx: f64, dy: f64 }, + /// A Linux evdev keycode — the phone side already speaks these, so Windows + /// translates its VK codes into this space rather than inventing a third. + Key { keycode: u16, pressed: bool }, +} + +/// BLE central role: scan for the phone's advertisement, connect, and talk GATT. +/// +/// The laptop is central-**only** — it never advertises and never serves a GATT +/// server, which is what makes Windows viable at all (WinRT's peripheral role is +/// far weaker than its central role). +pub trait BleCentral: Send + Sync { + /// Scan until a Vortex advertisement is seen, or the timeout elapses. + fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>>; + /// Connect and resolve the Vortex GATT service. + fn connect(&self, addr: PeerAddr) -> BoxFuture, String>>; + /// Addresses of already-bonded devices, for the reconnect fast path. + fn bonded(&self) -> BoxFuture, String>>; + /// Whether the radio is present and powered. + fn adapter_ready(&self) -> BoxFuture; +} + +/// An open GATT connection to the phone. +pub trait GattLink: Send + Sync { + /// Write one frame to a characteristic (`with_response` = acknowledged). + fn write(&self, char_uuid: Uuid128, data: &[u8], with_response: bool) + -> BoxFuture>; + /// Subscribe to notifications; frames arrive on `tx` until disconnect. + fn subscribe(&self, char_uuid: Uuid128, tx: tokio::sync::mpsc::UnboundedSender>) + -> BoxFuture>; + fn disconnect(&self) -> BoxFuture>; + fn is_connected(&self) -> bool; +} + +/// A Bluetooth device address. Deliberately a plain newtype rather than +/// `bluer::Address`: Windows hands out a `u64`, and the resolvable private +/// addresses the phone rotates through mean the *value* is never a stable +/// identity anyway — the peer's static public key is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PeerAddr(pub [u8; 6]); + +impl PeerAddr { + /// From the `u64` WinRT hands out (`BluetoothLEDevice::BluetoothAddress`, + /// `BluetoothLEAdvertisementReceivedEventArgs::BluetoothAddress`). + /// + /// The 48-bit address occupies the low six bytes, most-significant byte + /// first in printed order: `0x0000_AABB_CCDD_EEFF` is `AA:BB:CC:DD:EE:FF`. + /// The top two bytes are always zero and are dropped. Kept here rather than + /// in the Windows module so it can be tested on either platform — it is + /// pure arithmetic, and getting it backwards would mean connecting to a + /// mirrored address that simply never answers. + pub fn from_u48(addr: u64) -> Self { + let b = addr.to_be_bytes(); + Self([b[2], b[3], b[4], b[5], b[6], b[7]]) + } + + /// The inverse of [`PeerAddr::from_u48`], for handing an address back to a + /// WinRT call. + pub fn to_u48(self) -> u64 { + let a = self.0; + u64::from_be_bytes([0, 0, a[0], a[1], a[2], a[3], a[4], a[5]]) + } +} + +impl std::fmt::Display for PeerAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let b = self.0; + write!( + f, + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + b[0], b[1], b[2], b[3], b[4], b[5] + ) + } +} + +/// A 128-bit GATT UUID, in the same byte order both platforms accept. +pub type Uuid128 = u128; + +/// Boxed future alias — these traits are object-safe on purpose (the active +/// platform is chosen at runtime through `dyn`, so feature code never carries a +/// platform type parameter). +pub type BoxFuture = std::pin::Pin + Send>>; + +/// The active platform's user paths. +pub fn paths() -> &'static dyn UserPaths { + #[cfg(target_os = "linux")] + { + &linux::LinuxPaths + } + #[cfg(target_os = "windows")] + { + &windows::WindowsPaths + } +} + +/// The active platform's notifier. +pub fn notifier() -> &'static dyn Notifier { + #[cfg(target_os = "linux")] + { + &linux::LinuxNotifier + } + #[cfg(target_os = "windows")] + { + &windows::WindowsNotifier + } +} + +/// The active platform's session control. +pub fn session() -> &'static dyn SessionControl { + #[cfg(target_os = "linux")] + { + &linux::LinuxSession + } + #[cfg(target_os = "windows")] + { + &windows::WindowsSession + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The byte order is a wire-level detail with no error path: a mirrored + /// address is a valid-looking address that nothing answers on. + #[test] + fn u48_round_trips_and_keeps_printed_order() { + let a = PeerAddr([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + assert_eq!(a.to_u48(), 0x0000_AABB_CCDD_EEFF); + assert_eq!(PeerAddr::from_u48(0x0000_AABB_CCDD_EEFF), a); + assert_eq!(a.to_string(), "AA:BB:CC:DD:EE:FF"); + } + + #[test] + fn the_high_two_bytes_are_dropped() { + // WinRT always zeroes them; be explicit that we don't fold them in. + assert_eq!( + PeerAddr::from_u48(0xFFFF_0011_2233_4455), + PeerAddr([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]), + ); + } + + #[test] + fn a_random_address_survives_a_round_trip() { + for seed in [0u64, 1, 0x0000_0102_0304_0506, 0x0000_FFFF_FFFF_FFFF] { + assert_eq!(PeerAddr::from_u48(seed).to_u48(), seed); + } + } +} diff --git a/linux/daemon/src/core/platform/windows/ble.rs b/linux/daemon/src/core/platform/windows/ble.rs new file mode 100644 index 0000000..cd8ead2 --- /dev/null +++ b/linux/daemon/src/core/platform/windows/ble.rs @@ -0,0 +1,475 @@ +//! BLE central over WinRT — the Windows half of [`BleCentral`] / [`GattLink`]. +//! +//! The laptop is central-only: it scans, connects, writes and subscribes, and +//! never advertises or serves a GATT server. That asymmetry is what makes +//! Windows viable, because WinRT's central role is solid while its peripheral +//! role is not. +//! +//! # What WinRT does differently from BlueZ +//! +//! * **No connect call.** `BluetoothLEDevice::FromBluetoothAddressAsync` hands +//! back a device object without opening a link; the ACL connection is created +//! lazily by the first GATT operation. So "connected" here means "we resolved +//! the service", and a failure to connect surfaces as a service-discovery +//! error rather than a connect error. +//! * **No disconnect call either.** The link drops when the last reference to +//! the device and its children is released. [`WindowsGattLink::disconnect`] +//! therefore unsubscribes and drops its handles, and cannot report failure. +//! * **Notifications are a CCCD write plus an event handler**, per +//! characteristic, rather than a start-notify on a D-Bus object. +//! +//! # Untested +//! +//! None of this has been run. It type-checks against the WinRT metadata for +//! `x86_64-pc-windows-gnu`, which catches wrong signatures and wrong types but +//! nothing about behaviour — BLE cannot be exercised from Linux at all. Treat +//! every "works" claim here as unverified until it runs on real hardware. +//! +//! # Known runtime risk: thread affinity +//! +//! [`ensure_winrt`] joins the apartment on whatever thread calls it, and every +//! entry point calls it. What that does NOT cover is a future that suspends on +//! one tokio worker and resumes on another: the resumed half runs on a thread +//! that may never have been initialized, and only the agile objects are safe to +//! touch from a different thread than they were made on. +//! +//! The likely fix is the pattern this codebase already uses for libsecret and +//! zbus — see `SECRET_RT` in [`crate::core::storage`], a dedicated +//! single-worker runtime that owns all traffic for one subsystem, added after a +//! live runtime freeze. WinRT wants the same treatment: one thread owning every +//! BLE call, with the async API in front of it. That is a bigger change than +//! this file and needs a real Windows box to justify, so it is written down +//! rather than guessed at. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use windows::core::GUID; +use windows::Devices::Bluetooth::Advertisement::{ + BluetoothLEAdvertisementReceivedEventArgs, BluetoothLEAdvertisementWatcher, + BluetoothLEScanningMode, +}; +use windows::Devices::Bluetooth::GenericAttributeProfile::{ + GattCharacteristic, GattClientCharacteristicConfigurationDescriptorValue, + GattCommunicationStatus, GattValueChangedEventArgs, GattWriteOption, +}; +use windows::Devices::Bluetooth::{BluetoothAdapter, BluetoothConnectionStatus, BluetoothLEDevice}; +use windows::Devices::Enumeration::DeviceInformation; +use windows::Foundation::TypedEventHandler; +use windows::Storage::Streams::{DataReader, DataWriter}; + +use crate::core::platform::{BleCentral, BoxFuture, GattLink, PeerAddr, Uuid128}; + +/// Turn our platform-neutral [`Uuid128`] into the WinRT GUID. Both treat the +/// value as the big-endian UUID form, so this is a reinterpretation, not a +/// byte swap. +fn guid(uuid: Uuid128) -> GUID { + GUID::from_u128(uuid) +} + +/// WinRT errors carry an HRESULT and a message; keep both, since "the radio is +/// off" and "the device is out of range" are different HRESULTs and the message +/// alone doesn't always say which. +fn err(context: &str, e: windows::core::Error) -> String { + format!("{context}: {} ({})", e.message(), e.code().0) +} + +/// Join the multithreaded apartment on this thread, once. +/// +/// WinRT activation fails with `CO_E_NOTINITIALIZED` on a thread that has not +/// initialized an apartment, and the daemon's threads are plain tokio workers +/// that never do. This compiles fine without it and then fails on the very +/// first call — the sort of thing only a real Windows run surfaces. +/// +/// `S_FALSE` (already initialized) and `RPC_E_CHANGED_MODE` (this thread is +/// already in an STA — the UI thread, say) are both fine: in either case the +/// thread has an apartment, which is all we need. +fn ensure_winrt() { + use std::cell::Cell; + use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED}; + thread_local! { + static JOINED: Cell = const { Cell::new(false) }; + } + JOINED.with(|j| { + if j.get() { + return; + } + // SAFETY: no arguments to get wrong; the failure modes above are the + // documented benign ones and everything else means WinRT is unusable + // here, which the next call will report with real context. + let _ = unsafe { RoInitialize(RO_INIT_MULTITHREADED) }; + j.set(true); + }); +} + +pub struct WindowsBleCentral; + +impl BleCentral for WindowsBleCentral { + /// Watch for an advertisement carrying the Vortex service UUID. + /// + /// Active scanning on purpose: the phone puts its service UUID in the + /// advertisement, but a passive scan on Windows can miss the scan-response + /// payload where a crowded advert spills it. + fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>> { + let wanted = guid(crate::core::ble::VORTEX_SERVICE_UUID.as_u128()); + // The watcher is not agile either, and unlike the write path it has to + // stay alive for the whole scan — so it gets a thread of its own and + // never crosses an await. Only the address comes back, over a channel. + // This also gives the COM object consistent thread affinity, which a + // non-agile object is entitled to expect. + let (done_tx, done_rx) = tokio::sync::oneshot::channel::, String>>(); + std::thread::spawn(move || { + ensure_winrt(); + let outcome = (|| -> Result, String> { + let watcher = BluetoothLEAdvertisementWatcher::new() + .map_err(|e| err("advertisement watcher", e))?; + watcher + .SetScanningMode(BluetoothLEScanningMode::Active) + .map_err(|e| err("scanning mode", e))?; + + // `recv_timeout` gives us the scan deadline for free. The + // sender sits behind a Mutex because WinRT may invoke the + // handler from any thread, so it must be Sync as well as Send. + let (hit_tx, hit_rx) = std::sync::mpsc::channel::(); + let hit_tx = std::sync::Mutex::new(hit_tx); + let handler = TypedEventHandler::< + BluetoothLEAdvertisementWatcher, + BluetoothLEAdvertisementReceivedEventArgs, + >::new(move |_watcher, args| { + // windows 0.62 passes `Ref<'_, T>`; `ok()` turns a null + // sender into the same error path as any WinRT failure. + let args = args.ok()?; + let addr = args.BluetoothAddress()?; + for u in args.Advertisement()?.ServiceUuids()? { + if u == wanted { + // A closed channel means we already have our + // answer; dropping this address is right then. + if let Ok(g) = hit_tx.lock() { + let _ = g.send(addr); + } + break; + } + } + Ok(()) + }); + let token = watcher + .Received(&handler) + .map_err(|e| err("watcher.Received", e))?; + watcher.Start().map_err(|e| err("watcher.Start", e))?; + + let hit = hit_rx + .recv_timeout(std::time::Duration::from_millis(timeout_ms)) + .ok(); + + // Stop before returning either way — a watcher left running + // keeps the radio scanning and Windows won't clean it up. + let _ = watcher.Stop(); + let _ = watcher.RemoveReceived(token); + Ok(hit) + })(); + let _ = done_tx.send(outcome); + }); + + Box::pin(async move { + match done_rx.await { + Ok(Ok(hit)) => Ok(hit.map(PeerAddr::from_u48)), + Ok(Err(e)) => Err(e), + // The scan thread died without reporting. Treat it as "no peer + // seen" rather than a hard error: the caller retries anyway, + // and a scan is a poll, not a commitment. + Err(_) => Ok(None), + } + }) + } + + /// Resolve the device and its Vortex service, and cache every + /// characteristic we might write to or subscribe on. + /// + /// The characteristics are fetched once here rather than per operation: + /// each `GetCharacteristicsAsync` is a round trip over the air, and the + /// call path (pairing, then reconnect, then the audio-signal subscribe) + /// would otherwise pay it repeatedly on a link that is already the slowest + /// part of the handshake. + fn connect(&self, addr: PeerAddr) -> BoxFuture, String>> { + let service_uuid = guid(crate::core::ble::VORTEX_SERVICE_UUID.as_u128()); + Box::pin(async move { + ensure_winrt(); + let device = BluetoothLEDevice::FromBluetoothAddressAsync(addr.to_u48()) + .map_err(|e| err("FromBluetoothAddressAsync", e))? + .await + .map_err(|e| err("FromBluetoothAddressAsync await", e))?; + + let services = device + .GetGattServicesForUuidAsync(service_uuid) + .map_err(|e| err("GetGattServicesForUuidAsync", e))? + .await + .map_err(|e| err("GetGattServicesForUuidAsync await", e))?; + let status = services.Status().map_err(|e| err("services.Status", e))?; + if status != GattCommunicationStatus::Success { + // This is also where an out-of-range or radio-off failure + // lands, since WinRT has no connect step of its own. + return Err(format!("vortex service not reachable: status {status:?}")); + } + let service = services + .Services() + .map_err(|e| err("services.Services", e))? + .into_iter() + .next() + .ok_or_else(|| "vortex service absent on this device".to_string())?; + + let chars_result = service + .GetCharacteristicsAsync() + .map_err(|e| err("GetCharacteristicsAsync", e))? + .await + .map_err(|e| err("GetCharacteristicsAsync await", e))?; + if chars_result.Status().map_err(|e| err("chars.Status", e))? + != GattCommunicationStatus::Success + { + return Err("could not enumerate vortex characteristics".to_string()); + } + let mut chars: HashMap = HashMap::new(); + for c in chars_result + .Characteristics() + .map_err(|e| err("chars.Characteristics", e))? + { + let u = c.Uuid().map_err(|e| err("characteristic.Uuid", e))?; + chars.insert(u.to_u128(), c); + } + + Ok(Box::new(WindowsGattLink { + device, + chars, + subscriptions: Mutex::new(Vec::new()), + }) as Box) + }) + } + + /// Addresses of paired BLE devices, for the reconnect fast path. + /// + /// Each entry costs a `FromIdAsync` because the address is not in the + /// `DeviceInformation`. The alternative — parsing it out of the device Id + /// string — depends on an undocumented format, and a wrong parse here means + /// dialing a made-up address. + fn bonded(&self) -> BoxFuture, String>> { + Box::pin(async move { + ensure_winrt(); + let selector = BluetoothLEDevice::GetDeviceSelectorFromPairingState(true) + .map_err(|e| err("GetDeviceSelectorFromPairingState", e))?; + let found = DeviceInformation::FindAllAsyncAqsFilter(&selector) + .map_err(|e| err("FindAllAsyncAqsFilter", e))? + .await + .map_err(|e| err("FindAllAsyncAqsFilter await", e))?; + + // Collect the ids BEFORE any await: the WinRT collection iterator + // is not agile, so holding one across an await makes this future + // non-Send and it can't be spawned. `HSTRING` is agile, so the ids + // themselves travel fine. + let ids: Vec = found + .into_iter() + .filter_map(|info| info.Id().ok()) + .collect(); + + let mut out = Vec::new(); + for id in ids { + // Skip anything that won't open rather than failing the whole + // list: one stale pairing record must not hide the others. + let Ok(op) = BluetoothLEDevice::FromIdAsync(&id) else { + continue; + }; + let Ok(dev) = op.await else { continue }; + if let Ok(addr) = dev.BluetoothAddress() { + out.push(PeerAddr::from_u48(addr)); + } + } + Ok(out) + }) + } + + fn adapter_ready(&self) -> BoxFuture { + Box::pin(async move { + ensure_winrt(); + let Ok(op) = BluetoothAdapter::GetDefaultAsync() else { + return false; + }; + let Ok(adapter) = op.await else { return false }; + // Present is not enough: a machine can have a Bluetooth radio with + // no LE support, and every call we make is LE. + adapter.IsLowEnergySupported().unwrap_or(false) + }) + } +} + +/// An open GATT link, holding the device plus its resolved characteristics. +/// +/// `Arc>`-free for the characteristic map: it is written once during +/// [`BleCentral::connect`] and read-only afterwards. Only the subscription +/// tokens need a lock, because unsubscribing happens on a different thread from +/// the one that subscribed. +pub struct WindowsGattLink { + device: BluetoothLEDevice, + chars: HashMap, + subscriptions: Mutex>, +} + +impl WindowsGattLink { + fn characteristic(&self, uuid: Uuid128) -> Result { + self.chars + .get(&uuid) + .cloned() + .ok_or_else(|| format!("characteristic {:032x} not on this device", uuid)) + } +} + +impl GattLink for WindowsGattLink { + fn write( + &self, + char_uuid: Uuid128, + data: &[u8], + with_response: bool, + ) -> BoxFuture> { + let c = self.characteristic(char_uuid); + let bytes = data.to_vec(); + Box::pin(async move { + ensure_winrt(); + let c = c?; + let option = if with_response { + GattWriteOption::WriteWithResponse + } else { + GattWriteOption::WriteWithoutResponse + }; + // `DataWriter` and `IBuffer` are NOT agile — they hold a raw COM + // pointer that isn't `Send` — so they must not be alive across the + // await, or this future can't be spawned. Build them, hand the + // buffer to WinRT, and let the scope drop them before we suspend; + // the returned `IAsyncOperation` IS agile and travels fine. + // + // `WriteValueWithResult...`, not `WriteValueAsync`: the former + // reports the protocol error, and a silent write failure on the + // handshake path would look like the phone never answering. + // `...AndOptionAsync` is the overload that takes a write option; + // plain `WriteValueWithResultAsync` always writes WITH response, + // which would stall the unacknowledged frame path. + let op = { + let writer = DataWriter::new().map_err(|e| err("DataWriter", e))?; + writer + .WriteBytes(&bytes) + .map_err(|e| err("DataWriter.WriteBytes", e))?; + let buffer = writer + .DetachBuffer() + .map_err(|e| err("DataWriter.DetachBuffer", e))?; + c.WriteValueWithResultAndOptionAsync(&buffer, option) + .map_err(|e| err("WriteValueWithResultAndOptionAsync", e))? + }; + let result = op + .await + .map_err(|e| err("WriteValueWithResultAndOptionAsync await", e))?; + let status = result.Status().map_err(|e| err("write status", e))?; + if status != GattCommunicationStatus::Success { + return Err(format!("gatt write failed: status {status:?}")); + } + Ok(()) + }) + } + + /// Subscribe to notifications on `char_uuid`. + /// + /// Order matters: register the handler BEFORE writing the CCCD. The phone + /// pushes state the moment it sees the subscribe, and a notification that + /// arrives between the descriptor write and the handler registration is + /// simply lost — on Linux that showed up as a missing first state push. + fn subscribe( + &self, + char_uuid: Uuid128, + tx: tokio::sync::mpsc::UnboundedSender>, + ) -> BoxFuture> { + let c = self.characteristic(char_uuid); + let subs = &self.subscriptions; + let registered: Result<(GattCharacteristic, i64), String> = (|| { + let c = c?; + let handler = TypedEventHandler::::new( + move |_c, args| { + let args = args.ok()?; + let buffer = args.CharacteristicValue()?; + let len = buffer.Length()? as usize; + let reader = DataReader::FromBuffer(&buffer)?; + let mut bytes = vec![0u8; len]; + reader.ReadBytes(&mut bytes)?; + // A closed receiver means the consumer went away; the link + // is torn down separately, so drop the frame quietly. + let _ = tx.send(bytes); + Ok(()) + }, + ); + let token = c + .ValueChanged(&handler) + .map_err(|e| err("ValueChanged", e))?; + Ok((c, token)) + })(); + let (c, token) = match registered { + Ok(v) => v, + Err(e) => return Box::pin(async move { Err(e) }), + }; + if let Ok(mut g) = subs.lock() { + g.push((c.clone(), token)); + } + Box::pin(async move { + let status = c + .WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue::Notify, + ) + .map_err(|e| err("CCCD write", e))? + .await + .map_err(|e| err("CCCD write await", e))?; + if status != GattCommunicationStatus::Success { + return Err(format!("subscribe failed: status {status:?}")); + } + Ok(()) + }) + } + + /// Unsubscribe and release our handles. + /// + /// There is no disconnect API: Windows drops the link when the last + /// reference to the device goes away. We can only stop notifications and + /// let go — so this reports the CCCD write, and nothing about the link + /// itself. + fn disconnect(&self) -> BoxFuture> { + let taken: Vec<(GattCharacteristic, i64)> = self + .subscriptions + .lock() + .map(|mut g| std::mem::take(&mut *g)) + .unwrap_or_default(); + Box::pin(async move { + for (c, token) in taken { + let _ = c.RemoveValueChanged(token); + // Best-effort: if the phone is already gone this write fails, + // which is not an error worth surfacing on the way down. + if let Ok(op) = c.WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue::None, + ) { + let _ = op.await; + } + } + Ok(()) + }) + } + + fn is_connected(&self) -> bool { + self.device + .ConnectionStatus() + .map(|s| s == BluetoothConnectionStatus::Connected) + .unwrap_or(false) + } +} + +// No `unsafe impl Send/Sync` here on purpose. windows-rs marks the WinRT types +// that metadata says are agile — `BluetoothLEDevice`, `GattCharacteristic`, +// `IAsyncOperation` — as `Send + Sync` itself, so this struct derives both. The +// non-agile ones (`DataWriter`, `IBuffer`, the advertisement watcher) are kept +// off every await path above instead of being asserted safe. An `unsafe impl` +// would compile just as well and silence the next real violation. + +/// Keeps the type usable through `Arc` in the same places the Linux side is. +pub fn central() -> Arc { + Arc::new(WindowsBleCentral) +} diff --git a/linux/daemon/src/core/platform/windows/mod.rs b/linux/daemon/src/core/platform/windows/mod.rs new file mode 100644 index 0000000..10f1bf3 --- /dev/null +++ b/linux/daemon/src/core/platform/windows/mod.rs @@ -0,0 +1,142 @@ +//! Windows implementations of the platform seam. +//! +//! Compiled only on Windows, so nothing here can break the Linux build. Each +//! stub names the concrete API it will call, so the remaining work is visible +//! as a checklist rather than as "port it". +//! +//! Verify with +//! `cargo check -p vortex-l3-daemon --lib --target x86_64-pc-windows-gnu` +//! (needs `rustup target add x86_64-pc-windows-gnu` — Arch's packaged rustc +//! ships no Windows std). The `-msvc` target cannot be cross-checked from +//! Linux: a C dependency's build script wants `lib.exe` and fails before +//! reaching our code. Running any of this needs a real Windows machine or VM — +//! BLE and toast activation cannot be exercised from Linux at all. + +use std::path::PathBuf; + +use super::{BoxFuture, Notifier, SessionControl, UserPaths}; + +pub mod ble; + +pub struct WindowsPaths; + +/// Resolve a Windows known folder to a path. +/// +/// This — not an environment variable — is the supported way to ask. Every one +/// of these folders can be REDIRECTED: OneDrive relocates Downloads and +/// Documents by default on a consumer machine, and a domain profile can move +/// AppData. `%USERPROFILE%\Downloads` is merely the common case, and getting it +/// wrong means writing received files into a folder the user never opens — +/// exactly the bug the Linux side already had with a hardcoded `~/Downloads` +/// on a French desktop. +/// +/// `KF_FLAG_DONT_VERIFY` because a configured-but-missing folder is still the +/// user's stated intent: the receive path creates the directory anyway, and +/// verifying here would fail the lookup and send us to a fallback instead. Same +/// reasoning as the XDG side. +#[cfg(target_os = "windows")] +fn known_folder(id: &windows::core::GUID) -> Option { + use windows::Win32::System::Com::CoTaskMemFree; + use windows::Win32::UI::Shell::{SHGetKnownFolderPath, KF_FLAG_DONT_VERIFY}; + + // SAFETY: `id` is one of the FOLDERID_* constants, and the out-pointer is a + // COM allocation we own. It is freed on BOTH paths below — including the + // UTF-16 conversion failure — before anything is returned. + let raw = unsafe { SHGetKnownFolderPath(id, KF_FLAG_DONT_VERIFY, None) }.ok()?; + let text = unsafe { raw.to_string() }; + unsafe { CoTaskMemFree(Some(raw.0 as *const std::ffi::c_void)) }; + Some(PathBuf::from(text.ok()?)) +} + +impl UserPaths for WindowsPaths { + /// The user's real Downloads folder, wherever it has been moved to. + fn downloads(&self) -> Option { + known_folder(&windows::Win32::UI::Shell::FOLDERID_Downloads) + } + + /// `%APPDATA%\Vortex` — roaming, so settings follow a domain profile. + fn config(&self) -> Option { + Some(known_folder(&windows::Win32::UI::Shell::FOLDERID_RoamingAppData)?.join("Vortex")) + } + + /// `%LOCALAPPDATA%\Vortex\Cache` — local, never roamed: the icon cache is + /// machine-specific and would only bloat a roaming profile. + fn cache(&self) -> Option { + Some( + known_folder(&windows::Win32::UI::Shell::FOLDERID_LocalAppData)? + .join("Vortex") + .join("Cache"), + ) + } +} + +pub struct WindowsNotifier; + +impl Notifier for WindowsNotifier { + /// TODO: WinRT `ToastNotificationManager` with an `AppUserModelID`. + /// + /// The AUMID is the whole problem: an unpackaged exe has none until it + /// registers a Start-menu shortcut carrying one, and without it Windows + /// silently refuses to show the toast. Actions then need a registered COM + /// activator (`INotificationActivationCallback`) — see [`Self::actions`]. + fn show( + &self, + _summary: &str, + _body: &str, + _app_id: &str, + _actions: &[(String, String)], + _replaces: u32, + _urgent: bool, + ) -> BoxFuture> { + Box::pin(async { Err("windows notifier: not implemented".to_string()) }) + } + + /// TODO: `ToastNotificationHistory::Remove` by tag. Windows keys toasts by + /// string tag, not the u32 the freedesktop API returns, so the + /// implementation keeps an id→tag map behind this signature. + fn close(&self, _id: u32) -> BoxFuture> { + Box::pin(async { Err("windows notifier: not implemented".to_string()) }) + } + + /// TODO: activation callback → `tx`. + /// + /// This is the piece with no Linux analogue. A toast button carries + /// arguments; clicking it activates the app through COM, and the handler + /// must translate those arguments back into the same `fc:` / `call:` / + /// `act:` keys the existing consumers already filter on — so the routing + /// above this trait needs no Windows-specific branch. + fn actions(&self, _tx: tokio::sync::mpsc::UnboundedSender<(u32, String)>) {} + + /// TODO: `ToastNotification::Dismissed` / `Failed` events. Windows reports + /// dismissal per-notification rather than as a bus signal, so this + /// subscribes as toasts are created and fans them into the one channel. + fn closures(&self, _tx: tokio::sync::mpsc::UnboundedSender<(u32, u32)>) {} +} + +pub struct WindowsSession; + +impl SessionControl for WindowsSession { + /// TODO: `LockWorkStation()` from user32. + fn lock(&self) -> BoxFuture> { + Box::pin(async { Err("windows session lock: not implemented".to_string()) }) + } + + /// Windows has no programmatic unlock, by design — credentials must be + /// presented to the LogonUI. Proximity auto-unlock is therefore Linux-only; + /// [`SessionControl::can_unlock`] reports that so the UI can hide the + /// setting instead of offering something that always fails. + fn unlock(&self) -> BoxFuture> { + Box::pin(async { Err("windows cannot unlock a session programmatically".to_string()) }) + } + + /// TODO: `WTSRegisterSessionNotification` + `WTS_SESSION_LOCK`/`_UNLOCK`, + /// cached — there is no "is it locked right now" query on Windows, only + /// the transition events, so state has to be tracked from process start. + fn is_locked(&self) -> BoxFuture> { + Box::pin(async { None }) + } + + fn can_unlock(&self) -> bool { + false + } +} diff --git a/linux/daemon/src/core/storage/mod.rs b/linux/daemon/src/core/storage/mod.rs index a95567c..57973ce 100644 --- a/linux/daemon/src/core/storage/mod.rs +++ b/linux/daemon/src/core/storage/mod.rs @@ -1,10 +1,17 @@ //! Local storage backends for V1 secrets and trusted-peer metadata //! (spec §3.2 and §3.3). +// Secret Service is the LINUX secret backend; the traits below are the seam. +// A Windows build gets its Credential Manager implementation beside these. +#[cfg(target_os = "linux")] pub mod peers; +#[cfg(target_os = "linux")] pub mod secret_service; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex}; +// Only the Secret Service runtime below needs it, and that is Linux-only. +#[cfg(target_os = "linux")] +use std::sync::OnceLock; use crate::core::identity::{IdentityRecord, IdentityPublicView, Platform}; use crate::core::crypto::x25519::{X25519Sec, X25519SecBytes}; @@ -26,8 +33,12 @@ use crate::core::crypto::x25519::{X25519Sec, X25519SecBytes}; /// enters the dedicated runtime's context, so the zbus tasks land on its /// own worker thread and a store call always completes no matter how /// starved the ambient runtime is. +/// Linux-only, like the Secret Service backend it exists for: the deadlock it +/// avoids is a zbus one. +#[cfg(target_os = "linux")] static SECRET_RT: OnceLock = OnceLock::new(); +#[cfg(target_os = "linux")] fn secret_rt() -> &'static tokio::runtime::Runtime { SECRET_RT.get_or_init(|| { tokio::runtime::Builder::new_multi_thread() @@ -44,6 +55,7 @@ fn secret_rt() -> &'static tokio::runtime::Runtime { /// ambient multi-thread workers hand their core off via `block_in_place`, /// current-thread runtimes (e.g. `#[tokio::test]`) hop to a scoped thread /// (blocking them in place would panic), plain threads just block. +#[cfg(target_os = "linux")] pub(crate) fn secret_block_on(fut: F) -> F::Output where F: std::future::Future + Send, diff --git a/linux/ui-tauri/src-tauri/Cargo.lock b/linux/ui-tauri/src-tauri/Cargo.lock index a5a45e4..9f471ee 100644 --- a/linux/ui-tauri/src-tauri/Cargo.lock +++ b/linux/ui-tauri/src-tauri/Cargo.lock @@ -4429,7 +4429,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4508,7 +4508,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4641,7 +4641,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4666,7 +4666,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5367,6 +5367,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows 0.62.2", "x25519-dalek", "zbus 5.15.0", ] @@ -5715,7 +5716,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5739,7 +5740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5801,11 +5802,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5817,6 +5830,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5851,7 +5873,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5898,6 +5931,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6036,6 +6079,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -6373,7 +6425,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 64d1cce..2e59e8e 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -491,15 +491,13 @@ pub(crate) async fn apply_synced_image(app: &AppHandle, png: Vec) { /// Where instant-share received files land: the user's REAL download folder, /// which is localised — `~/Téléchargements` on a French desktop, `~/Downloads` -/// only on an English one. Hardcoding `~/Downloads` doesn't just miss it, it -/// silently *creates* a second, English-named folder beside the real one and -/// drops every received file where the user never looks. Resolved once per run -/// (neither `$HOME` nor the XDG config changes under us). +/// only on an English one. Resolution lives in `core::platform` so Linux and +/// Windows answer this the same way; here we only cache it (neither `$HOME` nor +/// the XDG config changes under us) and log where files will go. pub(crate) fn downloads_dir() -> Option { static DIR: OnceLock> = OnceLock::new(); DIR.get_or_init(|| { - let home = PathBuf::from(std::env::var_os("HOME")?); - let dir = xdg_user_dir(&home, "XDG_DOWNLOAD_DIR").unwrap_or_else(|| home.join("Downloads")); + let dir = vortex_l3_daemon::core::platform::paths().downloads()?; tracing::info!("received files → {}", dir.display()); Some(dir) }) From e7ccdcf8e58ffe1d4573d7c2b09638593a852f29 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Fri, 21 Aug 2026 15:39:49 +0200 Subject: [PATCH 21/71] feat(platform): the seam now serves both platforms end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing the second implementation is what showed the traits were wrong, so this is as much a reshaping as an addition. BleCentral / GattLink gained what the real flows need and the first draft missed: `read` (the §9.1.5 capability handshake reads before writing), `has` (the audio-signal characteristic is absent on phone builds before P2.13 and those peers must keep working), `peer` (so log lines can name the device), an async `is_connected` (BlueZ answers over D-Bus, and a sync signature would have forced Linux to cache a flag and answer with something stale), and `scan_for_peer` returning an `AdvCandidate` rather than a bare address — the phone rotates its address every few minutes, so the advertisement PAYLOAD is what identifies a peer, and pairing needs the `pairable` flag while reconnect needs the presence token. That last one had left the WinRT scan quietly wrong: matching `ServiceUuids` only says "a Vortex phone is nearby". It now walks the raw AD sections, and both platforms run the same §5.2 filter through a new `ble::decode_service_data_128`. Worth being a tested function rather than an inline comparison: the UUID in an AD structure is little-endian, reversed from the printed form, and one test feeds it big-endian to prove that does not match. `LinuxBleCentral` wraps the existing `VortexClient`/scanner rather than reimplementing them, so everything hard-won about connecting to a dual-mode phone stays in one place. Its `disconnect` deliberately does NOT call `Device::disconnect()`: on a dual-mode phone that tears down every bearer, including A2DP/HFP if the phone is also paired as an audio device, so "close this GATT link" would cut the user's music. BlueZ drops the LE link when the handles go, which is what the pre-seam code relied on. There is also no `platform::ble()` factory to match `paths()`/`notifier()`/`session()`: Linux must take the process's ONE shared adapter (a session per use accumulated D-Bus connections and hung the app after a few call cycles) while Windows needs no handle at all, and a uniform factory would hide exactly that. With both sides written, the callers moved over: * `pairing::{handshake, reconnect}` take `&dyn GattLink` and are no longer gated. `ReconnectError::Client(ClientError)` became `Link(String)` because `ClientError` wraps `bluer::Error` and could never cross the seam. Call sites changed by one line each — `LinuxGattLink::from_client` BORROWS the client, so the connect logic stays put and `ble.rs` still uses `client.audio_signal` afterwards. * `ble::audio_signal` — all nineteen frame types plus the nonce-resync recovery — likewise. Its one genuinely local dependency turned out to be two calls on the AUDIO_OP arm, now behind a narrow `AudioHandoff` trait; a platform with no audio backend passes `None`, drops AUDIO_OP, and keeps the other eighteen. One subtlety preserved explicitly: `write_audio_op` used bluer's bare `char.write()`, whose default is an unacknowledged Write Command, while everything else uses a Write Request for the long-write procedure and the nonce-lockstep ACK. That choice was implicit in a method name; it is now a `false` with the reasoning attached. Windows also gets two real implementations: * `SessionControl` — `LockWorkStation`, and `is_locked` via `WTSSessionInfoEx` rather than the `WTSRegisterSessionNotification` route the TODO named: event tracking cannot answer before the first transition, which is the wrong answer for a daemon that starts while the screen is already locked. `None` means "couldn't tell", never "unlocked" — proximity auto-lock must not act on a guess. The Windows 7 inverted-flags bug is deliberately NOT compensated for; Vortex targets 10+ and a blind correction would invert every supported version. * `Notifier` — WinRT toasts. A click arrives as an `Activated` event on the ToastNotification object, so something must keep that object alive while the toast is up; toast objects are not agile either. Hence one thread owning the notifier and the live toasts with the async methods as a channel in front, the same shape as the advertisement watcher and as `SECRET_RT` on Linux. `replaces` maps to reusing a toast's tag (in-place update, which the transfer pill needs) and dismissal reasons map onto the freedesktop codes the existing consumers already interpret. NOTE for packaging: an unpackaged app has no AppUserModelID until a Start-menu shortcut carries one, and `CreateToastNotifierWithId` with an unregistered AUMID shows nothing at all — no error, no toast. Testability is the point of all this, so: * `FakeGattLink` is a GattLink with no radio, in the seam rather than a test module so the port can keep using it. A full XX pairing with dual approval now runs as a unit test with the test playing the phone (real snow responder), asserting the property that matters: BOTH sides derive the same SAS, which is what a man-in-the-middle breaks. Plus IK msg1's wire shape, a local reject still telling the peer before failing, and a foreign frame type being rejected by type rather than decrypted while the peer is still unauthenticated. * The resync loop has a test that DROPS a frame on purpose and asserts the next one still arrives and the recovery was counted — built on a real IK transport pair, since a stub cipher cannot exercise a nonce sequence. * The toast XML builder lives in `platform/toast_xml.rs`, compiled everywhere, because a toast body is frequently a MIRRORED PHONE NOTIFICATION. Unescaped, a `&` makes the document unparseable and the notification silently vanishes; worse, text that closes an element early can inject its own `` into a prompt the user is about to trust. Two of its seven tests are injection attempts, one against a prompt that already has Accept/Decline buttons. Kept as one commit: the pieces are coupled (the shared apartment helper, the notifier accessor), so a split would leave a commit that does not build. Verified: `cargo test -p vortex-l3-daemon --lib` 173 passed; Windows lib check clean with no warnings; the Tauri app crate builds with 38 tests passing; clippy unchanged on both crates. Nothing Windows-side has been RUN — it type-checks against the WinRT/Win32 metadata and no more. Co-Authored-By: Claude Opus 5 --- linux/daemon/Cargo.toml | 4 + linux/daemon/src/core/ble/audio_signal.rs | 323 +++++++++++++----- linux/daemon/src/core/ble/mod.rs | 93 ++++- linux/daemon/src/core/pairing/handshake.rs | 234 +++++++++++-- linux/daemon/src/core/pairing/mod.rs | 19 +- linux/daemon/src/core/pairing/reconnect.rs | 146 ++++++-- linux/daemon/src/core/platform/linux.rs | 314 +++++++++++++++++ linux/daemon/src/core/platform/mod.rs | 322 +++++++++++++++-- linux/daemon/src/core/platform/toast_xml.rs | 154 +++++++++ linux/daemon/src/core/platform/windows/ble.rs | 128 ++++--- linux/daemon/src/core/platform/windows/mod.rs | 145 +++++--- .../src/core/platform/windows/notify.rs | 293 ++++++++++++++++ linux/daemon/src/main.rs | 13 +- linux/ui-tauri/src-tauri/src/ble.rs | 67 ++-- linux/ui-tauri/src-tauri/src/pairing.rs | 7 +- 15 files changed, 1971 insertions(+), 291 deletions(-) create mode 100644 linux/daemon/src/core/platform/toast_xml.rs create mode 100644 linux/daemon/src/core/platform/windows/notify.rs diff --git a/linux/daemon/Cargo.toml b/linux/daemon/Cargo.toml index 8c5ffbe..2857c9b 100644 --- a/linux/daemon/Cargo.toml +++ b/linux/daemon/Cargo.toml @@ -98,8 +98,12 @@ windows = { version = "0.62", features = [ "Foundation", "Foundation_Collections", "Storage_Streams", + "Data_Xml_Dom", + "UI_Notifications", "Win32_Foundation", "Win32_System_Com", + "Win32_System_RemoteDesktop", + "Win32_System_Shutdown", "Win32_System_WinRT", "Win32_UI_Shell", ] } diff --git a/linux/daemon/src/core/ble/audio_signal.rs b/linux/daemon/src/core/ble/audio_signal.rs index 1c9b367..d99541b 100644 --- a/linux/daemon/src/core/ble/audio_signal.rs +++ b/linux/daemon/src/core/ble/audio_signal.rs @@ -36,20 +36,16 @@ pub(crate) static RESYNC_EVENTS: AtomicU64 = AtomicU64::new(0); pub(crate) static RESYNC_FRAMES_SKIPPED: AtomicU64 = AtomicU64::new(0); pub(crate) static REHANDSHAKE_EVENTS: AtomicU64 = AtomicU64::new(0); -use futures::{pin_mut, StreamExt}; use snow::TransportState; use tokio::sync::Mutex; use tracing::{debug, info, warn}; -use bluer::gatt::remote::{Characteristic, CharacteristicWriteRequest}; -use bluer::gatt::WriteOp; -use super::client::VortexClient; +use super::AUDIO_SIGNAL_UUID; +use crate::core::platform::{AudioHandoff, GattLink}; use super::frame::{ty, Frame}; use crate::core::appstate::AppState; use crate::core::audio_op::{AudioOp, AudioOpFrame}; -use crate::core::audio_orchestrator::SwitchOrchestrator; -use crate::core::media_runtime::{pause_playing_for_call, MediaStateStore}; /// Run the AUDIO_SIGNAL listener loop until the BLE notification stream /// closes (peer drops, adapter goes down, etc). @@ -59,11 +55,13 @@ use crate::core::media_runtime::{pause_playing_for_call, MediaStateStore}; /// the session is unsafe to continue (replayed nonces are NOT errors — /// the orchestrator silently drops them, same as the LAN path). pub async fn run_listener( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, peer_pub: [u8; 32], - orchestrator: Arc, - media_store: MediaStateStore, + // The local audio stack, for the one frame type that needs it. `None` on a + // platform with no audio backend: AUDIO_OP frames are then dropped and the + // other eighteen types carry on. + audio: Option>, // Additive state-push channel: a STATE frame (battery/charging) is // decoded to an `AppState` and forwarded here as (peer_pub, state). // The UI layer turns it into the same peer-state update a LAN @@ -140,16 +138,16 @@ pub async fn run_listener( tokio::sync::mpsc::UnboundedSender<([u8; 32], u8, Vec)>, >, ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; - let notifies = char - .notify() - .await - .map_err(|e| format!("subscribe AUDIO_SIGNAL: {e}"))?; - pin_mut!(notifies); - info!(addr = %client.address, "BLE audio-signal listener up"); + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } + // The seam delivers frames on a channel rather than as a Stream. Same + // ordering guarantee, which is what matters here: this cipher stream is + // nonce-sequenced, so a reordered frame would look exactly like a dropped + // one and burn a resync. + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(AUDIO_SIGNAL_UUID.as_u128(), tx).await?; + info!(addr = %link.peer(), "BLE audio-signal listener up"); // Reconcile on (re)connect: ask the phone to re-send any active notification // we don't already have. Covers notifications posted while we were // disconnected (their notify had no subscriber) — the consumer carries our @@ -193,8 +191,8 @@ pub async fn run_listener( loop { let raw: Vec = match reassembled.pop_front() { Some(inner) => inner, - None => match notifies.next().await { - Some(r) => r.to_vec(), + None => match notifies.recv().await { + Some(r) => r, None => break, }, }; @@ -617,25 +615,22 @@ pub async fn run_listener( // leaving `pause_playing_for_call` with nothing to track on a // later resume. Doing it here, in parallel with the dispatch, // captures the playing set while it's still actually playing. + let Some(audio) = audio.as_ref() else { + debug!("no audio backend on this platform; dropping AUDIO_OP"); + continue; + }; if matches!(af.op, AudioOp::Request) { - let store = media_store.clone(); - tokio::spawn(async move { - let paused = pause_playing_for_call(&store).await; - if !paused.is_empty() { - info!(?paused, "BLE fast-path: paused MPRIS for call"); - } - }); + let audio = Arc::clone(audio); + tokio::spawn(async move { audio.pause_for_call().await }); } // Dispatch on a fresh task so a slow responder can't stall the // notification stream. Same shape as audio_lan_session.rs uses. - let orch = orchestrator.clone(); + let audio = Arc::clone(audio); let peer_copy = peer_pub; - tokio::spawn(async move { - let _ = orch.on_incoming(peer_copy, af).await; - }); + tokio::spawn(async move { audio.on_incoming(peer_copy, af).await }); } - info!(addr = %client.address, "BLE audio-signal listener: stream closed"); + info!(addr = %link.peer(), "BLE audio-signal listener: stream closed"); Ok(()) } @@ -653,14 +648,13 @@ pub async fn run_listener( /// dispatches into `SwitchOrchestrator.onIncoming` — same dispatch /// path as the LAN session. pub async fn write_audio_op( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, frame: AudioOpFrame, ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } let json = frame .to_json() .map_err(|e| format!("AudioOpFrame to_json: {e}"))?; @@ -668,7 +662,7 @@ pub async fn write_audio_op( // ciphertext buffer accordingly and truncate to the bytes // actually written. let mut ct = vec![0u8; json.len() + 16]; - // Hold the lock across char.write — see write_state for why (nonce/wire + // Hold the lock across the write — see write_state for why (nonce/wire // lockstep; otherwise concurrent writers desync the phone's recv cipher). let mut t = transport.lock().await; let n = t @@ -676,7 +670,13 @@ pub async fn write_audio_op( .map_err(|e| format!("audio-signal write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::AUDIO_OP, 0, ct).encode(); - char.write(&wire) + // The ONE unacknowledged writer: `with_response = false`, i.e. an ATT Write + // Command. Audio-op opcodes are tiny and latency-critical (they carry the + // ~200 ms call handoff), so they stay under the Command size cap and skip + // the ACK that [`write_framed`] needs for everything larger. Previously + // this was bluer's bare `char.write()`, whose default IS a Command — the + // choice was implicit in the method name, so it is spelled out here. + link.write(AUDIO_SIGNAL_UUID.as_u128(), &wire, false) .await .map_err(|e| format!("BLE write to AUDIO_SIGNAL: {e}"))?; drop(t); @@ -698,9 +698,8 @@ pub async fn write_audio_op( /// silently-dropped Command desynced it → "AEAD open failed" → session churn). /// AUDIO_SIGNAL advertises PROPERTY_WRITE, so a Request is valid. Used for every /// laptop→phone frame except the tiny latency-critical audio-op opcodes. -async fn write_framed(char: &Characteristic, wire: &[u8]) -> bluer::Result<()> { - let req = CharacteristicWriteRequest { op_type: WriteOp::Request, ..Default::default() }; - char.write_ext(wire, &req).await +async fn write_framed(link: &dyn GattLink, wire: &[u8]) -> Result<(), String> { + link.write(AUDIO_SIGNAL_UUID.as_u128(), wire, true).await } /// Push an `AppState` (battery/charging) to the peer over the AUDIO_SIGNAL @@ -710,14 +709,13 @@ async fn write_framed(char: &Characteristic, wire: &[u8]) -> bluer::Result<()> { /// laptop's power-watcher to push instantly over BLE instead of waiting /// for the LAN heartbeat. pub async fn write_state( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, state: &AppState, ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } let json = serde_json::to_vec(state).map_err(|e| format!("AppState to_json: {e}"))?; let mut ct = vec![0u8; json.len() + 16]; // Hold the transport lock ACROSS the BLE write: the AEAD nonce bump and the @@ -732,7 +730,7 @@ pub async fn write_state( .map_err(|e| format!("state write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::STATE, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write STATE to AUDIO_SIGNAL: {e}"))?; drop(t); @@ -746,14 +744,13 @@ pub async fn write_state( /// notification display, never to the audio orchestrator. Content is not /// logged. pub async fn write_notification( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, notif: &crate::core::notif_mirror::NotificationMirror, ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } let json = serde_json::to_vec(notif).map_err(|e| format!("notif to_json: {e}"))?; let mut ct = vec![0u8; json.len() + 16]; // Hold the lock across char.write — see write_state (nonce/wire lockstep). @@ -763,7 +760,7 @@ pub async fn write_notification( .map_err(|e| format!("notif write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::NOTIFICATION, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write NOTIFICATION to AUDIO_SIGNAL: {e}"))?; debug!(app = %notif.app, "→ BLE notification push (laptop→phone)"); @@ -776,15 +773,14 @@ pub async fn write_notification( /// feature knowledge — a feature module (e.g. notes) supplies its own frame /// type + payload, keeping all of its logic in its own file. pub async fn write_sealed( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, ty: u8, payload: &[u8], ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } let mut ct = vec![0u8; payload.len() + 16]; let mut t = transport.lock().await; let n = t @@ -792,7 +788,7 @@ pub async fn write_sealed( .map_err(|e| format!("sealed write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write 0x{ty:02x} to AUDIO_SIGNAL: {e}"))?; Ok(()) @@ -803,14 +799,13 @@ pub async fn write_sealed( /// writers; the phone routes CLIPBOARD frames to its system clipboard. /// Content is not logged (only length). pub async fn write_clipboard( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, clip: &crate::core::clipboard_mirror::ClipboardMirror, ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } // Long text would overflow a single BLE frame → chunk it over CLIPBOARD_TEXT // (same `[total][idx][data]` wire + 12ms pacing as the image sender). Short // text keeps the fast single-frame CLIPBOARD path. @@ -835,7 +830,7 @@ pub async fn write_clipboard( .map_err(|e| format!("clipboard-text write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::CLIPBOARD_TEXT, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write CLIPBOARD_TEXT to AUDIO_SIGNAL: {e}"))?; } @@ -858,7 +853,7 @@ pub async fn write_clipboard( .map_err(|e| format!("clipboard write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::CLIPBOARD, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write CLIPBOARD to AUDIO_SIGNAL: {e}"))?; debug!(chars = clip.text.chars().count(), "→ BLE clipboard push (laptop→phone)"); @@ -869,14 +864,13 @@ pub async fn write_clipboard( /// CLIPBOARD_IMAGE chunk frames. Each chunk is AEAD-sealed and paced so the /// BLE notify queue doesn't overflow (same discipline as the icon sender). pub async fn write_clipboard_image( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, png: &[u8], ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } let chunks = crate::core::clipboard_mirror::build_image_chunks(png); let total = chunks.len(); for payload in chunks { @@ -895,7 +889,7 @@ pub async fn write_clipboard_image( .map_err(|e| format!("clipboard-image write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::CLIPBOARD_IMAGE, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write CLIPBOARD_IMAGE to AUDIO_SIGNAL: {e}"))?; } @@ -910,14 +904,13 @@ pub async fn write_clipboard_image( /// CALL_CONTROL frame (0x38). Same lock-across-write nonce discipline as the /// other writers; routed separately from the audio handoff. pub async fn write_call_control( - client: &VortexClient, + link: &dyn GattLink, transport: Arc>, ctrl: &crate::core::call_event::CallControl, ) -> Result<(), String> { - let char = client - .audio_signal - .as_ref() - .ok_or_else(|| "peer has no AUDIO_SIGNAL characteristic".to_string())?; + if !link.has(AUDIO_SIGNAL_UUID.as_u128()) { + return Err("peer has no AUDIO_SIGNAL characteristic".to_string()); + } let json = ctrl.to_json(); let mut ct = vec![0u8; json.len() + 16]; let mut t = transport.lock().await; @@ -926,9 +919,179 @@ pub async fn write_call_control( .map_err(|e| format!("call-control write_message: {e}"))?; ct.truncate(n); let wire = Frame::new(ty::CALL_CONTROL, 0, ct).encode(); - write_framed(char, &wire) + write_framed(link, &wire) .await .map_err(|e| format!("BLE write CALL_CONTROL to AUDIO_SIGNAL: {e}"))?; debug!(action = %ctrl.action, "→ BLE call-control push (laptop→phone)"); Ok(()) } + +#[cfg(test)] +mod link_tests { + use super::*; + use crate::core::crypto::noise::{NOISE_IK, PROLOGUE_IK}; + use crate::core::platform::FakeGattLink; + use snow::Builder; + + /// A matched pair of transport states, as an IK handshake leaves them. + /// + /// The receive path is nonce-sequenced, so testing it needs a real cipher + /// pair rather than a stub: the whole point of the resync logic is what + /// happens to a REAL nonce sequence when a frame goes missing. + fn transport_pair() -> (TransportState, TransportState) { + let init_priv = [0x11u8; 32]; + let resp_priv = [0x22u8; 32]; + let resp_pub = { + let d = x25519_dalek::StaticSecret::from(resp_priv); + *x25519_dalek::PublicKey::from(&d).as_bytes() + }; + let params: snow::params::NoiseParams = NOISE_IK.parse().unwrap(); + let mut init = Builder::new(params.clone()) + .local_private_key(&init_priv) + .unwrap() + .remote_public_key(&resp_pub) + .unwrap() + .prologue(PROLOGUE_IK) + .unwrap() + .build_initiator() + .unwrap(); + let mut resp = Builder::new(params) + .local_private_key(&resp_priv) + .unwrap() + .prologue(PROLOGUE_IK) + .unwrap() + .build_responder() + .unwrap(); + let mut b1 = vec![0u8; 1024]; + let mut b2 = vec![0u8; 1024]; + let n = init.write_message(&[], &mut b1).unwrap(); + resp.read_message(&b1[..n], &mut b2).unwrap(); + let n = resp.write_message(&[], &mut b2).unwrap(); + init.read_message(&b2[..n], &mut b1).unwrap(); + ( + init.into_transport_mode().unwrap(), + resp.into_transport_mode().unwrap(), + ) + } + + /// Seal `payload` as the phone would, with the phone's send cipher. + fn phone_frame(phone: &mut TransportState, ty_byte: u8, payload: &[u8]) -> Vec { + let mut ct = vec![0u8; payload.len() + 16]; + let n = phone.write_message(payload, &mut ct).unwrap(); + ct.truncate(n); + Frame::new(ty_byte, 0, ct).encode() + } + + /// Spawn the listener with only a raw-frame channel wired, and return it. + fn spawn_listener( + link: Arc, + laptop: TransportState, + ) -> ( + tokio::sync::mpsc::UnboundedReceiver<(u8, Vec)>, + tokio::task::JoinHandle>, + ) { + let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<(u8, Vec)>(); + let handle = tokio::spawn(async move { + run_listener( + &*link, + Arc::new(Mutex::new(laptop)), + [0u8; 32], + None, // no audio backend: exactly the Windows shape + // 13 feature channels we don't need here, then the raw one. + None, None, None, None, None, None, None, None, None, None, None, + None, None, + Some(raw_tx), + ) + .await + }); + (raw_rx, handle) + } + + /// The dropped-notify recovery, driven deliberately rather than observed in + /// the wild. + /// + /// A BLE notify lost in flight desyncs the receive nonce, and every frame + /// after it fails to decrypt — permanently, unless the reader walks forward + /// to find the nonce that authenticates. This is the code that saved a + /// dropped file offer in production; now it has a test that skips a frame + /// on purpose and asserts the NEXT one still arrives. + #[tokio::test] + async fn a_dropped_frame_resyncs_instead_of_wedging_the_stream() { + let link = Arc::new(FakeGattLink::new(vec![AUDIO_SIGNAL_UUID.as_u128()])); + let (laptop, mut phone) = transport_pair(); + let before = RESYNC_EVENTS.load(Ordering::Relaxed); + let (mut raw_rx, handle) = spawn_listener(Arc::clone(&link), laptop); + tokio::time::sleep(Duration::from_millis(20)).await; + + // Frame 1 arrives normally. + link.push_notification( + AUDIO_SIGNAL_UUID.as_u128(), + phone_frame(&mut phone, ty::NOTES_SYNC, b"first"), + ); + assert_eq!(raw_rx.recv().await.unwrap(), (ty::NOTES_SYNC, b"first".to_vec())); + + // Frame 2 is sealed and then THROWN AWAY — the link lost it. The phone's + // send nonce has advanced; the laptop's receive nonce has not. + let _lost = phone_frame(&mut phone, ty::NOTES_SYNC, b"lost"); + + // Frame 3 must still be delivered, by skipping the burnt nonce. + link.push_notification( + AUDIO_SIGNAL_UUID.as_u128(), + phone_frame(&mut phone, ty::NOTES_SYNC, b"third"), + ); + let (ty_byte, payload) = tokio::time::timeout(Duration::from_secs(2), raw_rx.recv()) + .await + .expect("must not hang") + .expect("must not close"); + assert_eq!((ty_byte, payload), (ty::NOTES_SYNC, b"third".to_vec())); + assert!( + RESYNC_EVENTS.load(Ordering::Relaxed) > before, + "the recovery must be counted, not silent" + ); + + handle.abort(); + } + + /// A frame type outside the allow-list is dropped without being opened, so + /// an unexpected type can never consume a nonce or reach a feature channel. + #[tokio::test] + async fn an_unlisted_frame_type_is_ignored() { + let link = Arc::new(FakeGattLink::new(vec![AUDIO_SIGNAL_UUID.as_u128()])); + let (laptop, mut phone) = transport_pair(); + let (mut raw_rx, handle) = spawn_listener(Arc::clone(&link), laptop); + tokio::time::sleep(Duration::from_millis(20)).await; + + // PAIRING_HANDSHAKE has no business on this characteristic. + link.push_notification( + AUDIO_SIGNAL_UUID.as_u128(), + phone_frame(&mut phone, ty::PAIRING_HANDSHAKE, b"nope"), + ); + // …and a legitimate frame right after still lands, because the rejected + // one never touched the cipher. + link.push_notification( + AUDIO_SIGNAL_UUID.as_u128(), + phone_frame(&mut phone, ty::NOTES_SYNC, b"ok"), + ); + assert_eq!(raw_rx.recv().await.unwrap(), (ty::NOTES_SYNC, b"ok".to_vec())); + handle.abort(); + } + + /// A peer without the characteristic is refused up front rather than + /// failing later on the first write. + #[tokio::test] + async fn a_peer_without_the_characteristic_is_refused() { + let link = FakeGattLink::new(vec![]); + let (laptop, _phone) = transport_pair(); + let err = run_listener( + &link, + Arc::new(Mutex::new(laptop)), + [0u8; 32], + None, + None, None, None, None, None, None, None, None, None, None, None, None, + None, None, + ) + .await + .expect_err("no AUDIO_SIGNAL characteristic"); + assert!(err.contains("AUDIO_SIGNAL"), "{err}"); + } +} diff --git a/linux/daemon/src/core/ble/mod.rs b/linux/daemon/src/core/ble/mod.rs index 4af0a87..59cdd95 100644 --- a/linux/daemon/src/core/ble/mod.rs +++ b/linux/daemon/src/core/ble/mod.rs @@ -8,12 +8,16 @@ // laptops apart, and `shared/vectors/` exists to keep it that way. pub mod frame; +// The post-handshake event stream: AEAD-sealed frames in both directions over +// the AUDIO_SIGNAL characteristic, with nonce resync when the link drops one. +// Platform-neutral — it speaks `core::platform::GattLink`, so the same dispatch +// and the same resync run over BlueZ, over WinRT, or over a test fake. +pub mod audio_signal; + // Everything below is BlueZ over D-Bus: the central-role transport that // carries those frames on Linux. A second OS brings its own transport (WinRT -// `BluetoothLEDevice`) behind `core::platform::BleCentral` and reuses `frame` -// unchanged. -#[cfg(target_os = "linux")] -pub mod audio_signal; +// `BluetoothLEDevice`) behind `core::platform::BleCentral` and reuses both +// `frame` and `audio_signal` unchanged. #[cfg(target_os = "linux")] pub mod client; #[cfg(target_os = "linux")] @@ -155,6 +159,39 @@ impl AdvPayload { } } +/// Decode a **Service Data — 128-bit UUID** AD section (type `0x21`): sixteen +/// bytes of service UUID followed by the service data itself. +/// +/// Returns the payload only when the UUID is ours AND the payload passes the +/// §5.2 filter. `None` covers a foreign advert, a truncated section, and a +/// malformed payload alike, because a scanner's only useful question is "is +/// this a Vortex peer worth looking at". +/// +/// The UUID in an AD structure is **little-endian** — reversed from the printed +/// form. That reversal is the whole reason this is a function with a test +/// rather than a comparison written inline at a call site. +/// +/// Platforms that hand back parsed service data (BlueZ gives a UUID→bytes map) +/// go straight to [`AdvPayload::decode`]; this is for the ones that hand over +/// raw AD sections, as WinRT does. +pub fn decode_service_data_128(section: &[u8]) -> Option { + if section.len() < 16 { + return None; + } + let (uuid_le, payload) = section.split_at(16); + let mut be = [0u8; 16]; + for (i, b) in uuid_le.iter().rev().enumerate() { + be[i] = *b; + } + if uuid::Uuid::from_bytes(be) != VORTEX_SERVICE_UUID { + return None; + } + AdvPayload::decode(payload).ok() +} + +/// AD type for Service Data with a 128-bit UUID (Core Spec Supplement §1.11). +pub const AD_TYPE_SERVICE_DATA_128: u8 = 0x21; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum AdvDecodeError { WrongLength(usize), @@ -266,6 +303,54 @@ mod tests { )); } + /// Build the AD section a phone actually emits: little-endian UUID, then + /// the 10-byte payload. + fn service_data_section(payload: [u8; ADV_PAYLOAD_LEN]) -> Vec { + let mut v: Vec = VORTEX_SERVICE_UUID.as_bytes().iter().rev().copied().collect(); + v.extend_from_slice(&payload); + v + } + + #[test] + fn decodes_a_service_data_section_from_raw_ad_bytes() { + let token = [0x11u8; 8]; + let section = service_data_section(AdvPayload::trusted_presence(token).encode()); + let decoded = decode_service_data_128(§ion).expect("ours"); + assert!(decoded.flags.is_trusted_presence()); + assert_eq!(decoded.payload_8, token); + } + + /// The UUID is little-endian on air. Feeding it big-endian must NOT match, + /// or a scanner would silently depend on which way round the platform + /// happened to hand the bytes over. + #[test] + fn a_big_endian_uuid_does_not_match() { + let mut section: Vec = VORTEX_SERVICE_UUID.as_bytes().to_vec(); + section.extend_from_slice(&AdvPayload::pairable([0; 8]).encode()); + assert!(decode_service_data_128(§ion).is_none()); + } + + #[test] + fn rejects_foreign_short_and_malformed_sections() { + // Someone else's service data. + let mut foreign: Vec = uuid::uuid!("00001234-0000-1000-8000-00805f9b34fb") + .as_bytes() + .iter() + .rev() + .copied() + .collect(); + foreign.extend_from_slice(&AdvPayload::pairable([0; 8]).encode()); + assert!(decode_service_data_128(&foreign).is_none()); + + // Truncated before the UUID even ends. + assert!(decode_service_data_128(&[0u8; 8]).is_none()); + + // Ours, but the payload fails the §5.2 filter (both mode bits set). + let mut bad = AdvPayload::pairable([0; 8]).encode(); + bad[1] = 0x03; + assert!(decode_service_data_128(&service_data_section(bad)).is_none()); + } + #[test] fn rejects_no_mode_set() { let mut bytes = AdvPayload::pairable([0; 8]).encode(); diff --git a/linux/daemon/src/core/pairing/handshake.rs b/linux/daemon/src/core/pairing/handshake.rs index 8db18ee..0d66fa2 100644 --- a/linux/daemon/src/core/pairing/handshake.rs +++ b/linux/daemon/src/core/pairing/handshake.rs @@ -5,12 +5,12 @@ use std::time::Duration; -use futures::{pin_mut, StreamExt}; use snow::{params::NoiseParams, Builder, HandshakeState}; use tokio::time::timeout; use tracing::{debug, info}; -use crate::core::ble::client::{ClientError, VortexClient}; +use crate::core::ble::PAIRING_CONTROL_UUID; +use crate::core::platform::GattLink; use crate::core::ble::frame::{ty, Frame, FrameDecodeError}; use crate::core::crypto::derive::derive_prs; use crate::core::crypto::noise::{NOISE_XX, PROLOGUE_XX}; @@ -51,7 +51,10 @@ pub struct PairingOutcome { #[derive(Debug)] pub enum HandshakeError { Snow(snow::Error), - Client(ClientError), + /// The GATT link failed the write or subscribe. A `String` because + /// [`GattLink`] is the seam — BlueZ and WinRT share no error type, and + /// callers only log it. + Link(String), UnexpectedFrame { ty: u8, sub: u8 }, FrameDecode(FrameDecodeError), Timeout(&'static str), @@ -64,7 +67,7 @@ impl std::fmt::Display for HandshakeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Snow(e) => write!(f, "noise: {e}"), - Self::Client(e) => write!(f, "ble client: {e}"), + Self::Link(e) => write!(f, "gatt link: {e}"), Self::UnexpectedFrame { ty, sub } => { write!(f, "unexpected frame type=0x{ty:02x} sub=0x{sub:02x}") } @@ -85,9 +88,9 @@ impl From for HandshakeError { } } -impl From for HandshakeError { - fn from(e: ClientError) -> Self { - Self::Client(e) +impl From for HandshakeError { + fn from(e: String) -> Self { + Self::Link(e) } } @@ -99,12 +102,22 @@ fn build_initiator(static_priv: &X25519SecBytes) -> Result Result<(), HandshakeError> { + link.write(PAIRING_CONTROL_UUID.as_u128(), &frame.encode(), false) + .await + .map_err(HandshakeError::Link) +} + +/// Run Noise XX against the peer on `link`, using the supplied static private /// key. Production callers pass the long-lived identity scalar. /// /// Bounded by `wait_per_step` for each notify step. pub async fn run_xx_initiator( - client: &VortexClient, + link: &dyn GattLink, static_priv: &X25519SecBytes, wait_per_step: Duration, ) -> Result { @@ -113,22 +126,18 @@ pub async fn run_xx_initiator( let mut payload_scratch = vec![0u8; 1024]; // Subscribe BEFORE writing msg1 so we don't miss the msg2 notification. - let notifies = client - .pairing_control - .notify() - .await - .map_err(ClientError::from)?; - pin_mut!(notifies); + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(PAIRING_CONTROL_UUID.as_u128(), tx).await?; // ---- msg1 (initiator → responder) ---- let n = handshake.write_message(&[], &mut buffer)?; debug!(bytes = n, "noise xx msg1"); let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x01, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg1 sent ({} bytes)", n); // ---- msg2 (responder → initiator) ---- - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| HandshakeError::Timeout("msg2 notify"))? .ok_or(HandshakeError::Timeout("notify stream closed"))?; @@ -146,7 +155,7 @@ pub async fn run_xx_initiator( let n = handshake.write_message(&[], &mut buffer)?; debug!(bytes = n, "noise xx msg3"); let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x03, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg3 sent ({} bytes)", n); // Snow advances to transport mode after the third XX message; harvest @@ -176,7 +185,7 @@ pub async fn run_xx_initiator( /// derived PRS. Otherwise returns [`HandshakeError::LocalRejected`] or /// [`HandshakeError::PeerRejected`]. pub async fn run_pairing_initiator( - client: &VortexClient, + link: &dyn GattLink, static_priv: &X25519SecBytes, wait_per_step: Duration, decide: F, @@ -186,14 +195,11 @@ where F: FnOnce(&str) -> Fut, Fut: std::future::Future, { - // Subscribe to PairingControl notifications BEFORE writing msg1 and - // keep the stream alive across XX + approval. - let notifies = client - .pairing_control - .notify() - .await - .map_err(ClientError::from)?; - pin_mut!(notifies); + // Subscribe to PairingControl notifications BEFORE writing msg1 and keep + // the channel alive across XX + approval — the subscription outlives every + // step, so a notification can never land between two of them unheard. + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(PAIRING_CONTROL_UUID.as_u128(), tx).await?; let mut handshake = build_initiator(static_priv)?; let mut buffer = vec![0u8; 1024]; @@ -202,11 +208,11 @@ where // ---- XX msg1 ---- let n = handshake.write_message(&[], &mut buffer)?; let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x01, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg1 sent ({} bytes)", n); // ---- XX msg2 ---- - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| HandshakeError::Timeout("msg2 notify"))? .ok_or(HandshakeError::Timeout("notify stream closed"))?; @@ -223,7 +229,7 @@ where // ---- XX msg3 ---- let n = handshake.write_message(&[], &mut buffer)?; let frame = Frame::new(ty::PAIRING_HANDSHAKE, 0x03, buffer[..n].to_vec()); - client.write_pairing_control(&frame).await?; + write_pairing(link, &frame).await?; info!("→ msg3 sent ({} bytes)", n); // Harvest XX outputs. @@ -270,9 +276,11 @@ where let mut approval_ct = vec![0u8; approval_plain.len() + 16]; let approval_ct_len = transport.write_message(&approval_plain, &mut approval_ct)?; approval_ct.truncate(approval_ct_len); - client - .write_pairing_control(&Frame::new(ty::PAIRING_APPROVAL, approval_sub, approval_ct)) - .await?; + write_pairing( + link, + &Frame::new(ty::PAIRING_APPROVAL, approval_sub, approval_ct), + ) + .await?; info!( "→ approval sent ({} ct bytes): {}", approval_ct_len, @@ -284,7 +292,7 @@ where } // Wait for peer's approval frame. - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| HandshakeError::Timeout("peer approval"))? .ok_or(HandshakeError::Timeout("notify stream closed"))?; @@ -396,3 +404,161 @@ mod sanitize_tests { assert_eq!(sanitize_peer_name("\x00\x01\x02"), ""); } } + +#[cfg(test)] +mod link_tests { + use super::*; + use crate::core::crypto::noise::{NOISE_XX, PROLOGUE_XX}; + use crate::core::platform::FakeGattLink; + use snow::Builder; + + fn uuid() -> crate::core::platform::Uuid128 { + PAIRING_CONTROL_UUID.as_u128() + } + + fn fake() -> FakeGattLink { + FakeGattLink::new(vec![uuid()]) + } + + /// Wait until the initiator has written `n` frames, then return the last. + /// + /// Polling rather than a callback because `FakeGattLink` deliberately has + /// no notion of "react to a write" — it is a recorder, and the test drives + /// the peer side itself. + async fn nth_write(fake: &FakeGattLink, n: usize) -> Frame { + for _ in 0..200 { + { + let w = fake.writes.lock().unwrap(); + if w.len() >= n { + return Frame::decode(&w[n - 1].1).expect("well-formed frame"); + } + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + panic!("initiator never wrote frame {n}"); + } + + /// A full XX pairing with dual approval, both sides in one test and no + /// radio anywhere: the test plays the phone. + /// + /// This is the flow that decides whether two devices trust each other + /// forever, and until the seam existed it could only be exercised with a + /// real phone, a real adapter and a human reading a code off a screen. The + /// property that matters most is the one asserted last: BOTH sides derive + /// the same SAS, because that is the only thing standing between the user + /// and a man in the middle. + #[tokio::test] + async fn a_full_pairing_completes_and_both_sides_agree_on_the_sas() { + let fake = fake(); + let responder_priv = [0x42u8; 32]; + + let phone = async { + let mut resp = Builder::new(NOISE_XX.parse().unwrap()) + .local_private_key(&responder_priv) + .unwrap() + .prologue(PROLOGUE_XX) + .unwrap() + .build_responder() + .unwrap(); + let mut scratch = vec![0u8; 1024]; + let mut out = vec![0u8; 1024]; + + // msg1 → msg2 + let msg1 = nth_write(&fake, 1).await; + assert_eq!((msg1.ty, msg1.sub), (ty::PAIRING_HANDSHAKE, 0x01)); + resp.read_message(&msg1.payload, &mut scratch).unwrap(); + let n = resp.write_message(&[], &mut out).unwrap(); + fake.push_notification( + uuid(), + Frame::new(ty::PAIRING_HANDSHAKE, 0x02, out[..n].to_vec()).encode(), + ); + + // msg3 completes XX + let msg3 = nth_write(&fake, 2).await; + assert_eq!((msg3.ty, msg3.sub), (ty::PAIRING_HANDSHAKE, 0x03)); + resp.read_message(&msg3.payload, &mut scratch).unwrap(); + let responder_hash = resp.get_handshake_hash().to_vec(); + let mut transport = resp.into_transport_mode().unwrap(); + + // The laptop's APPROVE arrives AEAD-wrapped; a tampered frame + // would fail right here, which is the point of wrapping it. + let approval = nth_write(&fake, 3).await; + assert_eq!(approval.ty, ty::PAIRING_APPROVAL); + assert_eq!(approval.sub, 0x01, "approve"); + let mut pt = vec![0u8; approval.payload.len()]; + let len = transport.read_message(&approval.payload, &mut pt).unwrap(); + assert_eq!(&pt[..len], b"test-laptop", "our name, decrypted"); + + // Answer with our own approval. + let mut ct = vec![0u8; 64]; + let n = transport.write_message(b"test-phone", &mut ct).unwrap(); + fake.push_notification( + uuid(), + Frame::new(ty::PAIRING_APPROVAL, 0x01, ct[..n].to_vec()).encode(), + ); + responder_hash + }; + + let laptop = run_pairing_initiator( + &fake, + &[0x11u8; 32], + Duration::from_secs(5), + |_sas| async { LocalDecision::Approve }, + Some("test-laptop"), + ); + + let (outcome, responder_hash) = tokio::join!(laptop, phone); + let outcome = outcome.expect("pairing should complete"); + + assert_eq!(outcome.peer_name.as_deref(), Some("test-phone")); + // The SAS the user compares is derived from the transcript, so both + // sides MUST agree — a mismatch is exactly what a MITM produces. + assert_eq!(outcome.xx.transcript_hash, responder_hash); + let (_, phone_sas) = crate::core::crypto::sas::derive_sas(&responder_hash); + assert_eq!(outcome.xx.sas_string, phone_sas); + assert_eq!(outcome.xx.sas_string.len(), 6); + // PRS comes from the transcript too, and only after both approved. + assert_eq!(outcome.prs, crate::core::crypto::derive::derive_prs(&responder_hash)); + } + + /// A local reject must still TELL the peer, then fail — otherwise the phone + /// sits waiting on a pairing the user already refused. + #[tokio::test] + async fn a_local_reject_sends_a_reject_frame_and_then_fails() { + let fake = fake(); + let phone = async { + let mut resp = Builder::new(NOISE_XX.parse().unwrap()) + .local_private_key(&[0x42u8; 32]) + .unwrap() + .prologue(PROLOGUE_XX) + .unwrap() + .build_responder() + .unwrap(); + let mut scratch = vec![0u8; 1024]; + let mut out = vec![0u8; 1024]; + let msg1 = nth_write(&fake, 1).await; + resp.read_message(&msg1.payload, &mut scratch).unwrap(); + let n = resp.write_message(&[], &mut out).unwrap(); + fake.push_notification( + uuid(), + Frame::new(ty::PAIRING_HANDSHAKE, 0x02, out[..n].to_vec()).encode(), + ); + nth_write(&fake, 3).await + }; + + let laptop = run_pairing_initiator( + &fake, + &[0x11u8; 32], + Duration::from_secs(5), + |_sas| async { LocalDecision::Reject }, + Some("test-laptop"), + ); + + let (outcome, reject_frame) = tokio::join!(laptop, phone); + assert!(matches!(outcome, Err(HandshakeError::LocalRejected))); + assert_eq!(reject_frame.ty, ty::PAIRING_APPROVAL); + assert_eq!(reject_frame.sub, 0x02, "reject"); + // No name leaks to a peer we just refused. + assert!(reject_frame.payload.len() <= 16, "empty plaintext + AEAD tag"); + } +} diff --git a/linux/daemon/src/core/pairing/mod.rs b/linux/daemon/src/core/pairing/mod.rs index faa8cc0..843a603 100644 --- a/linux/daemon/src/core/pairing/mod.rs +++ b/linux/daemon/src/core/pairing/mod.rs @@ -2,19 +2,12 @@ pub mod backoff; -// The XX pairing and IK reconnect handshakes as run OVER BLE: the Noise state -// machine here is platform-neutral, but these two are written against -// `ble::client::VortexClient` concretely, so they can't build without BlueZ. +// The XX pairing and IK reconnect handshakes. Platform-neutral: they take a +// `&dyn core::platform::GattLink`, so the same Noise state machine runs over +// BlueZ, over WinRT, and over a test fake with no radio at all. Only the +// transport differs, which is the whole point of the seam. // -// Porting them is not a matter of cfg: they need to take a -// `&dyn core::platform::GattLink` instead of a `&VortexClient`, at which point -// both platforms share this code and only the transport differs. That refactor -// touches the most security-critical path in the tree, so it is deliberately -// NOT bundled with the mechanical gating — see the note in `platform`. -// -// The LAN side is unaffected either way: `lan::tcp_client` runs its own IK over -// TCP and is already portable. -#[cfg(target_os = "linux")] +// (The LAN side is separate either way: `lan::tcp_client` runs its own IK over +// TCP.) pub mod handshake; -#[cfg(target_os = "linux")] pub mod reconnect; diff --git a/linux/daemon/src/core/pairing/reconnect.rs b/linux/daemon/src/core/pairing/reconnect.rs index e4d5756..884b9bf 100644 --- a/linux/daemon/src/core/pairing/reconnect.rs +++ b/linux/daemon/src/core/pairing/reconnect.rs @@ -2,14 +2,14 @@ use std::time::Duration; -use futures::{pin_mut, StreamExt}; use rand::RngCore; use snow::{params::NoiseParams, Builder, HandshakeState, TransportState}; use tokio::time::timeout; use tracing::info; -use crate::core::ble::client::{ClientError, VortexClient}; use crate::core::ble::frame::{ty, Frame, FrameDecodeError}; +use crate::core::ble::RECONNECT_CONTROL_UUID; +use crate::core::platform::GattLink; use crate::core::crypto::noise::NOISE_IK; use crate::core::crypto::x25519::X25519SecBytes; @@ -34,7 +34,10 @@ pub struct ReconnectOutcome { #[derive(Debug)] pub enum ReconnectError { Snow(snow::Error), - Client(ClientError), + /// The GATT link failed the read, write or subscribe. A `String` because + /// [`GattLink`] is the seam: BlueZ and WinRT have nothing in common to + /// name here, and every caller only logs it. + Link(String), Timeout(&'static str), UnexpectedFrame { ty: u8, sub: u8 }, FrameDecode(FrameDecodeError), @@ -47,7 +50,7 @@ impl std::fmt::Display for ReconnectError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Snow(e) => write!(f, "noise: {e}"), - Self::Client(e) => write!(f, "ble client: {e}"), + Self::Link(e) => write!(f, "gatt link: {e}"), Self::Timeout(what) => write!(f, "timeout: {what}"), Self::UnexpectedFrame { ty, sub } => { write!(f, "unexpected frame type=0x{ty:02x} sub=0x{sub:02x}") @@ -68,12 +71,19 @@ impl From for ReconnectError { } } -impl From for ReconnectError { - fn from(e: ClientError) -> Self { - Self::Client(e) +impl From for ReconnectError { + fn from(e: String) -> Self { + Self::Link(e) } } +/// One frame to Reconnect Control, unacknowledged (§9.1). +async fn write_reconnect(link: &dyn GattLink, frame: &Frame) -> Result<(), ReconnectError> { + link.write(RECONNECT_CONTROL_UUID.as_u128(), &frame.encode(), false) + .await + .map_err(ReconnectError::Link) +} + fn build_ik_initiator( static_priv: &X25519SecBytes, peer_static_pub: &[u8; 32], @@ -88,7 +98,7 @@ fn build_ik_initiator( } -/// Run Noise IK against `client`'s peer using the local static identity, +/// Run Noise IK against the peer on `link`, using the local static identity, /// the trusted peer's static public key, and the Pairwise Reconnect /// Secret (mixed into the handshake prologue). /// @@ -100,20 +110,18 @@ fn build_ik_initiator( /// On success, the initiator follows up with a ping/pong liveness probe /// (frame `0x30/0x01` → `0x30/0x02`) before returning. pub async fn run_ik_initiator( - client: &VortexClient, + link: &dyn GattLink, static_priv: &X25519SecBytes, peer_static_pub: &[u8; 32], prs: &[u8; 32], local_counter: u64, wait_per_step: Duration, ) -> Result { - // Subscribe to Reconnect Control notifications BEFORE sending msg1. - let notifies = client - .reconnect_control - .notify() - .await - .map_err(ClientError::from)?; - pin_mut!(notifies); + // Subscribe to Reconnect Control notifications BEFORE sending msg1: the + // phone answers the moment it sees the write, and a notification that + // arrives before we are listening is simply gone. + let (tx, mut notifies) = tokio::sync::mpsc::unbounded_channel::>(); + link.subscribe(RECONNECT_CONTROL_UUID.as_u128(), tx).await?; let mut handshake = build_ik_initiator(static_priv, peer_static_pub, prs)?; let mut buffer = vec![0u8; 1024]; @@ -125,11 +133,15 @@ pub async fn run_ik_initiator( let counter_bytes = local_counter.to_be_bytes(); let n = handshake.write_message(&counter_bytes, &mut buffer)?; let frame = Frame::new(ty::RECONNECT_HANDSHAKE, 0x01, buffer[..n].to_vec()); - client.write_reconnect_control(&frame).await?; + // Write WITHOUT response throughout, per §9.1: the flow is driven by the + // notification each write provokes, so an ATT ack adds a round trip and no + // reliability. `write_reconnect_control` used to encode that choice; now + // the `false` does. + write_reconnect(link, &frame).await?; info!("→ IK msg1 sent ({} bytes, counter={local_counter})", n); // ---- IK msg2 ---- - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| ReconnectError::Timeout("msg2 notify"))? .ok_or(ReconnectError::Timeout("notify stream closed"))?; @@ -170,10 +182,10 @@ pub async fn run_ik_initiator( let mut nonce = [0u8; 8]; rand::rngs::OsRng.fill_bytes(&mut nonce); let ping = Frame::new(ty::TRANSPORT_KEEPALIVE, 0x01, nonce.to_vec()); - client.write_reconnect_control(&ping).await?; + write_reconnect(link, &ping).await?; info!("→ ping ({})", hex::encode(nonce)); - let raw = timeout(wait_per_step, notifies.next()) + let raw = timeout(wait_per_step, notifies.recv()) .await .map_err(|_| ReconnectError::Timeout("pong"))? .ok_or(ReconnectError::Timeout("notify stream closed"))?; @@ -197,3 +209,97 @@ pub async fn run_ik_initiator( transport: Some(transport), }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::platform::FakeGattLink; + + fn link() -> FakeGattLink { + FakeGattLink::new(vec![RECONNECT_CONTROL_UUID.as_u128()]) + } + + /// What IK msg1 must look like on the wire, without a phone in the room. + /// + /// This is the first test of this flow that has ever been possible: before + /// the seam it needed a BlueZ adapter and a real peer, so the frame type, + /// the characteristic and the write mode were only ever verified by the + /// handshake working end to end. + #[tokio::test] + async fn msg1_goes_out_unacknowledged_on_reconnect_control() { + let fake = link(); + let err = run_ik_initiator( + &fake, + &[7u8; 32], + &[9u8; 32], + &[3u8; 32], + 42, + Duration::from_millis(20), + ) + .await + .expect_err("no peer answers, so this must time out"); + assert!(matches!(err, ReconnectError::Timeout("msg2 notify")), "{err}"); + + let writes = fake.writes.lock().unwrap(); + assert_eq!(writes.len(), 1, "exactly msg1, nothing speculative"); + let (uuid, bytes, with_response) = &writes[0]; + assert_eq!(*uuid, RECONNECT_CONTROL_UUID.as_u128()); + assert!(!with_response, "§9.1: unacknowledged writes"); + + let frame = Frame::decode(bytes).expect("a well-formed frame"); + assert_eq!(frame.ty, ty::RECONNECT_HANDSHAKE); + assert_eq!(frame.sub, 0x01); + // Noise IK msg1 is e (32) + encrypted s (32+16) + encrypted payload + // (8-byte counter + 16 tag): a fixed 104 bytes for our pattern. A + // change here means the wire format moved. + assert_eq!(frame.payload.len(), 104); + } + + /// A frame that isn't msg2 must be rejected by type, not misparsed. The + /// peer is unauthenticated at this point, so this is the boundary where a + /// stray or hostile notification gets turned away. + #[tokio::test] + async fn a_wrong_frame_type_is_rejected_rather_than_decrypted() { + let fake = link(); + let uuid = RECONNECT_CONTROL_UUID.as_u128(); + let driver = async { + // Give the initiator a moment to subscribe and send msg1. + tokio::time::sleep(Duration::from_millis(5)).await; + fake.push_notification(uuid, Frame::new(ty::PAIRING_HANDSHAKE, 0x02, vec![0; 48]).encode()); + }; + let run = run_ik_initiator( + &fake, + &[7u8; 32], + &[9u8; 32], + &[3u8; 32], + 0, + Duration::from_millis(200), + ); + let (outcome, ()) = tokio::join!(run, driver); + match outcome.expect_err("must not accept a foreign frame") { + ReconnectError::UnexpectedFrame { ty, sub } => { + assert_eq!((ty, sub), (crate::core::ble::frame::ty::PAIRING_HANDSHAKE, 0x02)); + } + other => panic!("expected UnexpectedFrame, got {other}"), + } + } + + /// A link that can't carry the write fails the handshake with the reason, + /// rather than hanging until the step timeout. + #[tokio::test] + async fn a_dead_link_fails_fast_with_its_own_error() { + // Nothing present → subscribe itself fails. + let fake = FakeGattLink::new(vec![]); + let err = run_ik_initiator( + &fake, + &[7u8; 32], + &[9u8; 32], + &[3u8; 32], + 0, + Duration::from_secs(30), + ) + .await + .expect_err("a link with no characteristic cannot handshake"); + assert!(matches!(err, ReconnectError::Link(_)), "{err}"); + } +} diff --git a/linux/daemon/src/core/platform/linux.rs b/linux/daemon/src/core/platform/linux.rs index e4c4b74..4c21cf3 100644 --- a/linux/daemon/src/core/platform/linux.rs +++ b/linux/daemon/src/core/platform/linux.rs @@ -224,3 +224,317 @@ XDG_DOCUMENTS_DIR="$HOME/Documents" } } + +// --------------------------------------------------------------------------- +// BLE central over BlueZ +// --------------------------------------------------------------------------- + +use std::sync::Arc; + +use super::{AdvCandidate, AudioHandoff, BleCentral, GattLink, PeerAddr, Uuid128}; +use crate::core::ble::client::VortexClient; +use crate::core::ble::{ + AUDIO_SIGNAL_UUID, CAPABILITY_UUID, PAIRING_CONTROL_UUID, RECONNECT_CONTROL_UUID, +}; + +/// BlueZ-backed [`BleCentral`]. Wraps the existing [`VortexClient`] and scanner +/// rather than reimplementing them: everything hard-won about connecting to a +/// dual-mode phone (see the bearer-selection comment in `ble::client`) stays in +/// one place, and this file only adapts the shapes. +pub struct LinuxBleCentral { + adapter: bluer::Adapter, +} + +impl LinuxBleCentral { + /// Takes the process's shared adapter — see the note on [`BleCentral`] for + /// why this is passed in rather than acquired here. + pub fn new(adapter: bluer::Adapter) -> Self { + Self { adapter } + } +} + +impl BleCentral for LinuxBleCentral { + /// First Vortex advertisement seen, or `None` on timeout. + /// + /// `run_filtered_scan` never returns on its own — it is meant to be driven + /// until dropped — so it races against the deadline and the first hit. + fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>> { + let adapter = self.adapter.clone(); + Box::pin(async move { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let scan = crate::core::ble::scanner::run_filtered_scan(adapter, move |c| { + // The scanner has already run the §5.2 filter, so the payload + // it hands over is valid — pass it through rather than + // re-deriving anything from the address. + let _ = tx.send(AdvCandidate { + addr: PeerAddr(c.address.0), + payload: c.payload, + rssi: c.rssi, + }); + }); + tokio::select! { + // Dropping `scan` here stops the discovery, which is the + // documented way to end it. + Some(found) = rx.recv() => Ok(Some(found)), + r = scan => match r { + // It only returns on error; a clean return means the + // discovery ended without a candidate. + Ok(()) => Ok(None), + Err(e) => Err(format!("scan: {e}")), + }, + _ = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)) => Ok(None), + } + }) + } + + fn connect(&self, addr: PeerAddr) -> BoxFuture, String>> { + let adapter = self.adapter.clone(); + Box::pin(async move { + let address = bluer::Address::new(addr.0); + let client = VortexClient::connect(&adapter, address) + .await + .map_err(|e| format!("connect {address}: {e}"))?; + Ok(Box::new(LinuxGattLink::from_client(adapter, &client)) as Box) + }) + } + + /// Paired devices known to the adapter. + /// + /// A device that fails to answer `is_paired` is skipped rather than + /// failing the list: one stale record must not hide the rest. + fn bonded(&self) -> BoxFuture, String>> { + let adapter = self.adapter.clone(); + Box::pin(async move { + let addrs = adapter + .device_addresses() + .await + .map_err(|e| format!("device_addresses: {e}"))?; + let mut out = Vec::new(); + for a in addrs { + let Ok(dev) = adapter.device(a) else { continue }; + if dev.is_paired().await.unwrap_or(false) { + out.push(PeerAddr(a.0)); + } + } + Ok(out) + }) + } + + fn adapter_ready(&self) -> BoxFuture { + let adapter = self.adapter.clone(); + Box::pin(async move { adapter.is_powered().await.unwrap_or(false) }) + } +} + +/// An open BlueZ GATT link: the characteristics a [`VortexClient`] resolved, +/// plus the adapter and address needed to answer "are we still connected?". +/// +/// Holds the characteristics rather than the client so that [`Self::from_client`] +/// can BORROW a client the caller keeps using. bluer's handles are cheap clones +/// of D-Bus paths, and the existing call sites (pairing, the BLE loop) still +/// want their `VortexClient` for the typed helpers after the handshake. +pub struct LinuxGattLink { + adapter: bluer::Adapter, + address: bluer::Address, + capability: bluer::gatt::remote::Characteristic, + pairing_control: bluer::gatt::remote::Characteristic, + reconnect_control: bluer::gatt::remote::Characteristic, + /// `None` on phone builds before P2.13 — see [`GattLink::has`]. + audio_signal: Option, + /// One forwarding task per subscription, aborted on disconnect. bluer hands + /// back a Stream; the seam hands out a channel, so something has to pump. + /// + /// `Arc` because the returned futures are `'static` (the trait's `BoxFuture` + /// carries no lifetime), so they cannot borrow from `&self` — they get a + /// handle instead. + notify_tasks: Arc>>>, +} + +impl LinuxGattLink { + /// Present an already-connected [`VortexClient`] as a [`GattLink`]. + /// + /// This is the migration path: the pairing and reconnect flows move onto + /// `&dyn GattLink` while their callers keep the connect logic they have — + /// all the dual-mode bearer handling in `ble::client` — and simply wrap it + /// here. No second connect, no behaviour change. + pub fn from_client(adapter: bluer::Adapter, client: &VortexClient) -> Self { + Self { + adapter, + address: client.address, + capability: client.capability.clone(), + pairing_control: client.pairing_control.clone(), + reconnect_control: client.reconnect_control.clone(), + audio_signal: client.audio_signal.clone(), + notify_tasks: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } +} + +impl LinuxGattLink { + /// Resolve a UUID to the characteristic the client already discovered. + /// + /// A match rather than a map because the set is fixed by the spec (§10.1) + /// and `audio_signal` is optional — an absent one has to read as "not on + /// this peer", not as a lookup bug. + fn characteristic( + &self, + uuid: Uuid128, + ) -> Result<&bluer::gatt::remote::Characteristic, String> { + let u = uuid::Uuid::from_u128(uuid); + if u == CAPABILITY_UUID { + Ok(&self.capability) + } else if u == PAIRING_CONTROL_UUID { + Ok(&self.pairing_control) + } else if u == RECONNECT_CONTROL_UUID { + Ok(&self.reconnect_control) + } else if u == AUDIO_SIGNAL_UUID { + self.audio_signal + .as_ref() + .ok_or_else(|| "audio-signal characteristic absent on this peer".to_string()) + } else { + Err(format!("{u} is not a vortex characteristic")) + } + } +} + +impl GattLink for LinuxGattLink { + fn write( + &self, + char_uuid: Uuid128, + data: &[u8], + with_response: bool, + ) -> BoxFuture> { + let c = self.characteristic(char_uuid).cloned(); + let bytes = data.to_vec(); + Box::pin(async move { + let c = c?; + let req = bluer::gatt::remote::CharacteristicWriteRequest { + offset: 0, + op_type: if with_response { + bluer::gatt::WriteOp::Request + } else { + bluer::gatt::WriteOp::Command + }, + prepare_authorize: false, + ..Default::default() + }; + c.write_ext(&bytes, &req) + .await + .map_err(|e| format!("gatt write: {e}")) + }) + } + + fn read(&self, char_uuid: Uuid128) -> BoxFuture, String>> { + let c = self.characteristic(char_uuid).cloned(); + Box::pin(async move { c?.read().await.map_err(|e| format!("gatt read: {e}")) }) + } + + fn subscribe( + &self, + char_uuid: Uuid128, + tx: tokio::sync::mpsc::UnboundedSender>, + ) -> BoxFuture> { + let c = self.characteristic(char_uuid).cloned(); + let tasks = Arc::clone(&self.notify_tasks); + Box::pin(async move { + let c = c?; + let stream = c.notify().await.map_err(|e| format!("gatt notify: {e}"))?; + let handle = tokio::spawn(async move { + use futures::StreamExt; + let mut stream = std::pin::pin!(stream); + while let Some(bytes) = stream.next().await { + if tx.send(bytes).is_err() { + break; // consumer gone + } + } + }); + if let Ok(mut g) = tasks.lock() { + g.push(handle); + } + Ok(()) + }) + } + + fn peer(&self) -> PeerAddr { + PeerAddr(self.address.0) + } + + fn has(&self, char_uuid: Uuid128) -> bool { + self.characteristic(char_uuid).is_ok() + } + + /// Stop forwarding notifications and let the link go. + /// + /// Deliberately does NOT call `Device::disconnect()`. On a dual-mode phone + /// that tears down every bearer, including the A2DP/HFP link if the phone + /// is also paired as an audio device — so a "close this GATT link" would + /// cut the user's music. BlueZ drops the LE link once the handles go, which + /// is what the pre-seam code relied on too. + fn disconnect(&self) -> BoxFuture> { + let taken: Vec> = self + .notify_tasks + .lock() + .map(|mut g| std::mem::take(&mut *g)) + .unwrap_or_default(); + Box::pin(async move { + for t in taken { + t.abort(); + } + Ok(()) + }) + } + + fn is_connected(&self) -> BoxFuture { + let adapter = self.adapter.clone(); + let address = self.address; + Box::pin(async move { + match adapter.device(address) { + Ok(d) => d.is_connected().await.unwrap_or(false), + Err(_) => false, + } + }) + } +} + +/// Linux audio handoff: the PulseAudio/BlueZ switch orchestrator plus the MPRIS +/// store the fast-path pause needs. Both already existed; this only presents +/// them to the (platform-neutral) BLE event stream. +pub struct LinuxAudioHandoff { + orchestrator: Arc, + media_store: crate::core::media_runtime::MediaStateStore, +} + +impl LinuxAudioHandoff { + pub fn new( + orchestrator: Arc, + media_store: crate::core::media_runtime::MediaStateStore, + ) -> Self { + Self { + orchestrator, + media_store, + } + } +} + +impl AudioHandoff for LinuxAudioHandoff { + fn pause_for_call(&self) -> BoxFuture<()> { + let store = self.media_store.clone(); + Box::pin(async move { + let paused = crate::core::media_runtime::pause_playing_for_call(&store).await; + if !paused.is_empty() { + tracing::info!(?paused, "BLE fast-path: paused MPRIS for call"); + } + }) + } + + fn on_incoming( + &self, + peer: [u8; 32], + frame: crate::core::audio_op::AudioOpFrame, + ) -> BoxFuture<()> { + let orch = Arc::clone(&self.orchestrator); + Box::pin(async move { + let _ = orch.on_incoming(peer, frame).await; + }) + } +} diff --git a/linux/daemon/src/core/platform/mod.rs b/linux/daemon/src/core/platform/mod.rs index 81a2290..ff00846 100644 --- a/linux/daemon/src/core/platform/mod.rs +++ b/linux/daemon/src/core/platform/mod.rs @@ -21,10 +21,16 @@ //! //! # Status //! -//! The Windows implementations are stubs that name the API they will call. They -//! are compiled only on Windows, so they cannot break the Linux build; and the -//! Linux implementations delegate to the existing modules, so this file adds a -//! boundary without changing behaviour. +//! Linux implementations delegate to the modules that already existed, so this +//! file adds a boundary without changing behaviour there. On Windows, +//! [`UserPaths`], [`Notifier`], [`SessionControl`], [`BleCentral`] and +//! [`GattLink`] are written; [`Autostart`] and [`InputCapture`] have no Windows +//! implementation yet, and neither does the secret store (a Credential Manager +//! backend slots in beside `SecretServiceIdentityStore`). +//! +//! Everything Windows-side is compiled only on Windows and none of it has ever +//! run: it type-checks against the WinRT/Win32 metadata, which catches wrong +//! signatures and wrong types and nothing about behaviour. //! //! **The daemon LIBRARY compiles for Windows.** Verify with: //! @@ -44,26 +50,43 @@ //! each `cfg` — is every direct BlueZ / D-Bus / PulseAudio / Secret Service //! module. //! +//! # BLE is the one trait with both sides written +//! +//! [`BleCentral`] / [`GattLink`] now have a BlueZ implementation +//! ([`linux::LinuxBleCentral`], wrapping the existing `ble::client` rather than +//! reimplementing its dual-mode connect dance) and a WinRT one +//! ([`windows::ble::WindowsBleCentral`]). Writing the second one is what +//! reshaped the trait: it needed a `read` (the capability handshake), a `has` +//! (the audio-signal characteristic is absent on older phones), an async +//! `is_connected` (BlueZ answers over D-Bus) and a `scan_for_peer` that returns +//! the advertisement PAYLOAD rather than a bare address — the phone rotates its +//! address, so the payload is what identifies a peer. +//! +//! Its callers moved over too: `pairing::{handshake, reconnect}` now take +//! `&dyn GattLink`, are no longer gated, and build for Windows. That also made +//! them testable for the first time — a full XX pairing with dual approval, and +//! the IK msg1 wire shape, now run as unit tests against [`FakeGattLink`] with +//! no adapter and no phone. +//! +//! `ble::audio_signal` — the post-handshake event stream, all nineteen frame +//! types plus the nonce-resync recovery — moved across too. Its one genuinely +//! local dependency, the earbuds handoff, went behind [`AudioHandoff`]; a +//! platform with no audio backend passes `None`, drops `AUDIO_OP`, and keeps +//! the other eighteen. +//! //! # The gates are not the port //! //! A `cfg(target_os = "linux")` on a module means "no Windows implementation -//! yet", not "not needed on Windows". Two of them are load-bearing and will -//! come back as trait work rather than as a second copy: -//! -//! * `pairing::{handshake, reconnect}` — the XX/IK Noise state machines are -//! platform-neutral but written against `ble::client::VortexClient` -//! concretely. They need to take `&dyn GattLink`, after which both platforms -//! share them. This is the most security-critical path in the tree, so it was -//! deliberately left out of the mechanical gating pass. -//! * `ble::audio_signal` — the frame dispatch, cipher-resync and AppState -//! decode in there are protocol logic that happens to live inside the BlueZ -//! notification listener. Windows needs the same dispatch behind a different -//! transport, so this wants splitting rather than reimplementing. +//! yet", not "not needed on Windows". What is left behind one is a Linux +//! *implementation* — BlueZ, logind, MPRIS, PulseAudio, Secret Service — with +//! its trait already named here, or a subsystem with no Windows counterpart +//! written yet. use std::path::PathBuf; #[cfg(target_os = "linux")] pub mod linux; +pub mod toast_xml; #[cfg(target_os = "windows")] pub mod windows; @@ -128,6 +151,27 @@ pub trait SessionControl: Send + Sync { fn can_unlock(&self) -> bool; } +/// The audio-handoff side of the phone's event stream. +/// +/// `ble::audio_signal` carries nineteen frame types, and exactly one of them — +/// `AUDIO_OP`, the earbuds handoff — needs to touch the local audio stack. This +/// trait is that touch point, so the other eighteen don't drag PulseAudio and +/// MPRIS into a build that has neither. +/// +/// A platform with no audio backend passes `None` and simply drops `AUDIO_OP` +/// frames: no earbuds switching, everything else works. +pub trait AudioHandoff: Send + Sync { + /// The phone is starting a buds-claim (almost always an incoming call). + /// Pause local media BEFORE the buds are released — once the sink goes away + /// the audio server migrates the stream and the player often auto-pauses on + /// its own, leaving nothing to resume later. + fn pause_for_call(&self) -> BoxFuture<()>; + + /// Drive the switch state machine with a frame from `peer`. + fn on_incoming(&self, peer: [u8; 32], frame: crate::core::audio_op::AudioOpFrame) + -> BoxFuture<()>; +} + /// Run Vortex at login. pub trait Autostart: Send + Sync { fn is_enabled(&self) -> bool; @@ -180,9 +224,27 @@ pub enum InputEvent { /// The laptop is central-**only** — it never advertises and never serves a GATT /// server, which is what makes Windows viable at all (WinRT's peripheral role is /// far weaker than its central role). +/// +/// # Construction is deliberately not part of this trait +/// +/// There is no `platform::ble()` factory to match [`paths`] / [`notifier`] / +/// [`session`], because the two platforms genuinely differ in what they need to +/// exist: +/// +/// * Linux takes the process's ONE shared `bluer::Adapter` +/// ([`linux::LinuxBleCentral::new`]). Creating a session per use accumulated +/// D-Bus connections and hung the app after a few call cycles, so the adapter +/// is passed in rather than acquired — the same reason the heartbeat and the +/// BLE loop already share one. +/// * Windows needs no handle at all; WinRT resolves the radio per call. +/// +/// A uniform factory would have to hide that, and hiding it is how the leak +/// came back. Callers construct the platform's central once at startup and pass +/// `Arc` down, which is what the BLE loop already does with its +/// adapter today. pub trait BleCentral: Send + Sync { /// Scan until a Vortex advertisement is seen, or the timeout elapses. - fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>>; + fn scan_for_peer(&self, timeout_ms: u64) -> BoxFuture, String>>; /// Connect and resolve the Vortex GATT service. fn connect(&self, addr: PeerAddr) -> BoxFuture, String>>; /// Addresses of already-bonded devices, for the reconnect fast path. @@ -192,15 +254,62 @@ pub trait BleCentral: Send + Sync { } /// An open GATT connection to the phone. +/// +/// The shape of this trait is set by what the pairing and reconnect flows +/// actually do over the link, which is: read the capability characteristic, +/// write frames without response (§9.1 — the flow is driven by notify-on-write, +/// so the ATT ack buys latency and no reliability), and subscribe for the +/// notifications those writes provoke. pub trait GattLink: Send + Sync { - /// Write one frame to a characteristic (`with_response` = acknowledged). + /// Write one frame to a characteristic. `with_response = false` is an ATT + /// Write Command, which is what the pairing and reconnect frames use. fn write(&self, char_uuid: Uuid128, data: &[u8], with_response: bool) -> BoxFuture>; + + /// Read a characteristic — the capability handshake (§9.1.5) needs this + /// before any frame is written. + fn read(&self, char_uuid: Uuid128) -> BoxFuture, String>>; + /// Subscribe to notifications; frames arrive on `tx` until disconnect. fn subscribe(&self, char_uuid: Uuid128, tx: tokio::sync::mpsc::UnboundedSender>) -> BoxFuture>; + + /// Which peer this link talks to. Both platforms know it at connect time; + /// it exists so log lines can name the device without the caller having to + /// carry the address alongside the link. + fn peer(&self) -> PeerAddr; + + /// Whether this link resolved `char_uuid` at all. + /// + /// Not every characteristic is guaranteed: the audio-signal one is absent + /// on phone builds before P2.13, and those peers must keep working with the + /// LAN heartbeat instead of failing the connect. Callers check rather than + /// discovering it as a write error. + fn has(&self, char_uuid: Uuid128) -> bool; + fn disconnect(&self) -> BoxFuture>; - fn is_connected(&self) -> bool; + + /// Async because Linux has to ask BlueZ over D-Bus; Windows reads a + /// property. A sync signature would have forced Linux to cache a flag and + /// answer with something stale. + fn is_connected(&self) -> BoxFuture; +} + +/// A Vortex peer seen on air. +/// +/// Carries the advertisement payload, not just the address, because the address +/// alone cannot answer the questions the callers ask: the pairing UI needs the +/// `pairable` flag and the instance id to match the window the user just opened +/// on the phone, and the reconnect path needs the presence token to know WHICH +/// trusted peer this is. The phone rotates its address every few minutes, so it +/// is the payload that identifies, not the address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdvCandidate { + pub addr: PeerAddr, + pub payload: crate::core::ble::AdvPayload, + /// Signal strength, where the platform reports it — used only to prefer a + /// nearer peer, never to decide identity. + pub rssi: Option, } /// A Bluetooth device address. Deliberately a plain newtype rather than @@ -272,7 +381,7 @@ pub fn notifier() -> &'static dyn Notifier { } #[cfg(target_os = "windows")] { - &windows::WindowsNotifier + &windows::notify::WindowsNotifier } } @@ -288,6 +397,115 @@ pub fn session() -> &'static dyn SessionControl { } } +/// A [`GattLink`] with no radio behind it: writes are recorded, reads replay a +/// scripted value, and notifications are whatever the test pushes. +/// +/// This is the payoff for having a seam at all. The pairing and reconnect flows +/// are the code most worth testing and the least testable — today they need a +/// phone, a BlueZ adapter and a human. Written against `&dyn GattLink` they can +/// be driven from a unit test on either platform, and this is the harness that +/// makes that possible. It lives here rather than in a test module so the port +/// work can use it as it moves those flows onto the trait. +#[cfg(test)] +pub struct FakeGattLink { + /// Characteristics this link pretends to have. + pub present: Vec, + /// `(uuid, bytes, with_response)` in call order. + pub writes: std::sync::Mutex, bool)>>, + /// What [`GattLink::read`] answers, per characteristic. + pub reads: std::collections::HashMap>, + /// Senders handed to [`GattLink::subscribe`], so a test can push frames. + pub subscribers: std::sync::Mutex>)>>, + pub connected: bool, +} + +#[cfg(test)] +impl FakeGattLink { + pub fn new(present: Vec) -> Self { + Self { + present, + writes: std::sync::Mutex::new(Vec::new()), + reads: std::collections::HashMap::new(), + subscribers: std::sync::Mutex::new(Vec::new()), + connected: true, + } + } + + /// Deliver `bytes` as a notification on `uuid`, as the phone would. + pub fn push_notification(&self, uuid: Uuid128, bytes: Vec) { + for (u, tx) in self.subscribers.lock().unwrap().iter() { + if *u == uuid { + let _ = tx.send(bytes.clone()); + } + } + } +} + +#[cfg(test)] +impl GattLink for FakeGattLink { + fn write( + &self, + char_uuid: Uuid128, + data: &[u8], + with_response: bool, + ) -> BoxFuture> { + let ok = self.present.contains(&char_uuid); + if ok { + self.writes + .lock() + .unwrap() + .push((char_uuid, data.to_vec(), with_response)); + } + Box::pin(async move { + if ok { + Ok(()) + } else { + Err("no such characteristic".to_string()) + } + }) + } + + fn read(&self, char_uuid: Uuid128) -> BoxFuture, String>> { + let v = self.reads.get(&char_uuid).cloned(); + Box::pin(async move { v.ok_or_else(|| "nothing scripted for this read".to_string()) }) + } + + fn subscribe( + &self, + char_uuid: Uuid128, + tx: tokio::sync::mpsc::UnboundedSender>, + ) -> BoxFuture> { + let ok = self.present.contains(&char_uuid); + if ok { + self.subscribers.lock().unwrap().push((char_uuid, tx)); + } + Box::pin(async move { + if ok { + Ok(()) + } else { + Err("no such characteristic".to_string()) + } + }) + } + + fn peer(&self) -> PeerAddr { + PeerAddr([0xFA, 0xCE, 0x00, 0x00, 0x00, 0x01]) + } + + fn has(&self, char_uuid: Uuid128) -> bool { + self.present.contains(&char_uuid) + } + + fn disconnect(&self) -> BoxFuture> { + Box::pin(async { Ok(()) }) + } + + fn is_connected(&self) -> BoxFuture { + let c = self.connected; + Box::pin(async move { c }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -317,4 +535,68 @@ mod tests { assert_eq!(PeerAddr::from_u48(seed).to_u48(), seed); } } + + const PAIRING: Uuid128 = 0x0000_0000_0000_0000_0000_0000_0000_0001; + const CAPABILITY: Uuid128 = 0x0000_0000_0000_0000_0000_0000_0000_0002; + const ABSENT: Uuid128 = 0x0000_0000_0000_0000_0000_0000_0000_0099; + + /// A round of the shape the pairing flow uses — read capability, write a + /// frame without response, receive the notification it provokes — driven + /// entirely through `&dyn GattLink`. + /// + /// This is what the seam is FOR: the same caller runs against BlueZ, WinRT + /// or this fake, so the protocol flow can be tested with no radio. + #[tokio::test] + async fn a_caller_can_drive_the_link_through_the_trait() { + let mut fake = FakeGattLink::new(vec![PAIRING, CAPABILITY]); + fake.reads.insert(CAPABILITY, vec![0x01, 0x00, 0x00]); + let link: &dyn GattLink = &fake; + + assert_eq!(link.read(CAPABILITY).await.unwrap(), vec![0x01, 0x00, 0x00]); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + link.subscribe(PAIRING, tx).await.unwrap(); + link.write(PAIRING, b"msg1", false).await.unwrap(); + + // The peer answers on the characteristic we wrote to. + fake.push_notification(PAIRING, b"msg2".to_vec()); + assert_eq!(rx.recv().await.unwrap(), b"msg2".to_vec()); + + // Write-without-response is what §9.1 specifies for this path; a + // caller that flipped it would still "work" against real hardware but + // pay an ATT ack per frame. + let writes = fake.writes.lock().unwrap(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0], (PAIRING, b"msg1".to_vec(), false)); + } + + /// An absent characteristic must be reportable BEFORE a write is attempted + /// — that is how a peer without the audio-signal characteristic keeps + /// working instead of failing the connect. + #[tokio::test] + async fn an_absent_characteristic_is_visible_and_unwritable() { + let fake = FakeGattLink::new(vec![PAIRING]); + let link: &dyn GattLink = &fake; + assert!(link.has(PAIRING)); + assert!(!link.has(ABSENT)); + assert!(link.write(ABSENT, b"x", false).await.is_err()); + assert!(fake.writes.lock().unwrap().is_empty()); + } + + /// The trait has to be usable as a spawned, shared object — that is how the + /// BLE loop will hold it. Fails to compile if a signature stops being + /// `Send + Sync` or the futures stop being `Send`. + #[tokio::test] + async fn the_link_survives_being_shared_across_tasks() { + let link: std::sync::Arc = + std::sync::Arc::new(FakeGattLink::new(vec![PAIRING])); + let l2 = std::sync::Arc::clone(&link); + let joined = tokio::spawn(async move { + l2.write(PAIRING, b"from another task", false).await.unwrap(); + l2.is_connected().await + }) + .await + .unwrap(); + assert!(joined); + } } diff --git a/linux/daemon/src/core/platform/toast_xml.rs b/linux/daemon/src/core/platform/toast_xml.rs new file mode 100644 index 0000000..3cffbec --- /dev/null +++ b/linux/daemon/src/core/platform/toast_xml.rs @@ -0,0 +1,154 @@ +//! The toast XML document a Windows notification is built from. +//! +//! # Why this is not inside the Windows module +//! +//! The dialect is Windows-only, but the code is pure string building — and it +//! is the one part of the notification path that handles text this machine did +//! not author. A mirrored phone notification's title and body go straight in +//! here, so a missing escape is not cosmetic: `&` or `<` in a message makes the +//! document unparseable and the notification silently vanishes, and text that +//! closes an element early can inject its own `` buttons into a prompt +//! the user is about to trust. +//! +//! Compiled on every platform so it can be tested on the machine this is +//! developed on, rather than being verified for the first time on Windows. + +/// Build the toast XML for a notification. +/// +/// `actions` is `(key, label)`; the key comes back as the activation argument, +/// so it is what the `fc:` / `call:` / `act:` consumers filter on. +pub fn toast_xml(summary: &str, body: &str, actions: &[(String, String)], urgent: bool) -> String { + let mut xml = String::from(""); + xml.push_str(""); + xml.push_str(&escape(summary)); + xml.push_str(""); + if !body.is_empty() { + xml.push_str(""); + xml.push_str(&escape(body)); + xml.push_str(""); + } + xml.push_str(""); + if !actions.is_empty() { + xml.push_str(""); + for (key, label) in actions { + xml.push_str(""); + } + xml.push_str(""); + } + xml.push_str(""); + xml +} + +/// Escape the five predefined XML entities. +/// +/// Ampersand first, and only once: doing it in any other order would rewrite +/// the `&` of an escape produced by an earlier replacement, turning `<` into +/// `&lt;` and showing the user the escape instead of the character. +pub fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn accept_decline() -> Vec<(String, String)> { + vec![ + ("fc:accept".to_string(), "Accept".to_string()), + ("fc:decline".to_string(), "Decline".to_string()), + ] + } + + #[test] + fn escapes_all_five_entities_ampersand_first() { + assert_eq!(escape("a&b"), "a&b"); + assert_eq!(escape(""), "<x>"); + assert_eq!(escape("\"q\" 'a'"), ""q" 'a'"); + // The ordering trap: an ampersand introduced by escaping `<` must not + // be escaped again. + assert_eq!(escape("<"), "<"); + assert!(!escape("<").contains("&")); + } + + /// The attack this file exists to prevent: a phone notification whose text + /// tries to close the document and add its own button to a consent prompt. + #[test] + fn remote_text_cannot_inject_an_action_button() { + let hostile = "hi\ + + diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt index 5745b5e..6f933ac 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt @@ -3,8 +3,10 @@ package com.vortex.a3.core.fs import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Environment import android.provider.DocumentsContract import android.util.Log +import java.io.File /** * What this phone serves to a paired laptop, and the gate every path-taking op @@ -32,7 +34,53 @@ import android.util.Log */ class FsRoots(private val context: Context) { - data class Root(val treeUri: Uri, val name: String, val writable: Boolean) + /** + * One shared location. + * + * Two kinds, because the two grant models are genuinely different: a SAF + * tree is addressed by document URI and enumerated through a provider, + * while all-files access hands back the ordinary filesystem. Modelling them + * as one and converting would mean inventing a fake URI for real paths, or + * vice versa — both lossy. + */ + sealed class Root { + abstract val name: String + abstract val writable: Boolean + + data class Tree( + val treeUri: Uri, + override val name: String, + override val writable: Boolean, + ) : Root() + + /** The whole of shared storage, available only while the user has + * granted all-files access. */ + data class Local( + val dir: File, + override val name: String, + override val writable: Boolean, + ) : Root() + } + + /** What a resolved path turned out to address. */ + sealed class Target { + data class Doc(val uri: Uri) : Target() + data class Local(val file: File) : Target() + } + + /** + * Whether the user has granted all-files access. + * + * Read from the OS, never cached and never mirrored into a preference of + * our own: the user can revoke it in system settings at any time, and a + * stale "on" would mean offering the laptop a root we can no longer read. + * The permission IS the setting — same rule as the SAF grants above. + */ + fun allFilesGranted(): Boolean = try { + Environment.isExternalStorageManager() + } catch (_: Exception) { + false + } /** * The trees the user has granted us, newest first. @@ -41,23 +89,43 @@ class FsRoots(private val context: Context) { * a grant in system settings at any moment, and a cache would keep serving * a folder that has actually been withdrawn. */ - fun roots(): List = + fun roots(): List { + val out = ArrayList() + // All-files access supersedes the individual trees: offering both would + // show the same photo twice under two different paths, and the laptop + // has no way to know they are the same file. + if (allFilesGranted()) { + val shared = try { + @Suppress("DEPRECATION") + Environment.getExternalStorageDirectory() + } catch (_: Exception) { + null + } + if (shared != null && shared.isDirectory) { + out.add(Root.Local(shared, "Phone storage", writable = false)) + return out + } + } context.contentResolver.persistedUriPermissions .filter { it.isReadPermission } - .mapNotNull { perm -> + .forEach { perm -> val uri = perm.uri // Only tree grants: a single-document grant cannot be browsed // and has no children to enumerate. - if (!DocumentsContract.isTreeUri(uri)) return@mapNotNull null - Root( - treeUri = uri, - name = displayNameOf(uri), - // v1 is read-only end to end; the flag is carried so the - // laptop can show the folder as read-only rather than - // discovering it by failing a write. - writable = false, + if (!DocumentsContract.isTreeUri(uri)) return@forEach + out.add( + Root.Tree( + treeUri = uri, + name = displayNameOf(uri), + // v1 is read-only end to end; the flag is carried so the + // laptop can show the folder as read-only rather than + // discovering it by failing a write. + writable = false, + ), ) } + return out + } fun isEmpty(): Boolean = roots().isEmpty() @@ -79,6 +147,12 @@ class FsRoots(private val context: Context) { */ fun resolve(path: String, forWrite: Boolean): Result { if (path.isEmpty()) return Result.Err(FsCode.INVAL) + // An absolute path means the all-files root; anything else must be a + // content URI. Dispatching on the first character rather than trying + // both keeps the two gates separate, so neither can be reached by a + // path shaped for the other. + if (path.startsWith("/")) return resolveLocal(path, forWrite) + val uri = try { Uri.parse(path) } catch (_: Exception) { @@ -95,6 +169,7 @@ class FsRoots(private val context: Context) { } ?: return Result.Err(FsCode.ACCES) for (root in roots()) { + if (root !is Root.Tree) continue val grantedTree = try { DocumentsContract.getTreeDocumentId(root.treeUri) } catch (_: Exception) { @@ -103,7 +178,7 @@ class FsRoots(private val context: Context) { if (uri.authority != root.treeUri.authority) continue if (requestedTree != grantedTree) continue if (forWrite && !root.writable) return Result.Err(FsCode.ROFS) - return Result.Ok(uri) + return Result.Ok(Target.Doc(uri)) } // ACCES, not NOENT, and deliberately so: answering "no such file" for a // path outside every root would let a paired peer probe for the @@ -111,6 +186,38 @@ class FsRoots(private val context: Context) { return Result.Err(FsCode.ACCES) } + /** + * Resolve a real filesystem path under the all-files root. + * + * Canonicalises before comparing, so `..` traversal and symlinks pointing + * out of shared storage are rejected rather than merely discouraged. Being + * granted all-files access is not the same as agreeing to serve `/data` — + * the user turned on "any files" meaning their files, and this app can read + * a great deal more than that. + */ + private fun resolveLocal(path: String, forWrite: Boolean): Result { + if (!allFilesGranted()) return Result.Err(FsCode.ACCES) + val canonical = try { + File(path).canonicalFile + } catch (_: Exception) { + return Result.Err(FsCode.NOENT) + } + for (root in roots()) { + if (root !is Root.Local) continue + val croot = try { + root.dir.canonicalFile + } catch (_: Exception) { + continue + } + val inside = canonical == croot || + canonical.path.startsWith(croot.path + File.separator) + if (!inside) continue + if (forWrite && !root.writable) return Result.Err(FsCode.ROFS) + return Result.Ok(Target.Local(canonical)) + } + return Result.Err(FsCode.ACCES) + } + /** Take a tree the user just picked, persisting the grant across reboots. */ fun grant(uri: Uri): Boolean = try { context.contentResolver.takePersistableUriPermission( @@ -176,7 +283,7 @@ class FsRoots(private val context: Context) { } sealed class Result { - data class Ok(val uri: Uri) : Result() + data class Ok(val target: Target) : Result() data class Err(val code: Int) : Result() } diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt index 64ebd88..ac670a6 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt @@ -6,7 +6,9 @@ import android.provider.DocumentsContract import android.system.ErrnoException import android.system.Os import android.system.OsConstants +import android.os.ParcelFileDescriptor import android.util.Log +import java.io.File import org.json.JSONObject /** @@ -103,7 +105,7 @@ class FsServer( name = it.name, isDir = true, readonly = !it.writable, - path = treeRootPath(it.treeUri), + path = rootPath(it), ) }, cursor = null, @@ -112,13 +114,40 @@ class FsServer( } // With exactly one folder, a synthetic level above it would be a // directory the user clicks through every time for no information. - val only = all[0] - return listChildren(r.id, treeRootUri(only.treeUri) ?: return err(r.id, FsCode.IO, "bad tree"), r.cursor) + return when (val only = all[0]) { + is FsRoots.Root.Local -> listLocal(r.id, only.dir, r.cursor) + is FsRoots.Root.Tree -> + listChildren( + r.id, + treeRootUri(only.treeUri) ?: return err(r.id, FsCode.IO, "bad tree"), + r.cursor, + ) + } } return when (val res = roots.resolve(r.path, forWrite = false)) { is FsRoots.Result.Err -> err(r.id, res.code, "refused") - is FsRoots.Result.Ok -> listChildren(r.id, res.uri, r.cursor) + is FsRoots.Result.Ok -> when (val t = res.target) { + is FsRoots.Target.Doc -> listChildren(r.id, t.uri, r.cursor) + is FsRoots.Target.Local -> listLocal(r.id, t.file, r.cursor) + } + } + } + + /** Directory listing over the all-files root. */ + private fun listLocal(id: Int, dir: File, cursor: Int): Served { + if (!dir.isDirectory) return err(id, FsCode.INVAL, "not a directory") + // Sorted so paging is stable: listFiles has no defined order, and an + // unstable one would drop or repeat entries across pages. + val all = try { + dir.listFiles()?.sortedBy { it.name } ?: return err(id, FsCode.IO, "cannot list") + } catch (e: SecurityException) { + return err(id, FsCode.ACCES, "not granted") + } catch (e: Exception) { + return err(id, FsCode.IO, e.message ?: "list failed") } + val page = all.drop(cursor).take(LIST_PAGE) + val next = if (cursor + page.size < all.size) cursor + page.size else null + return Served.Meta(FsReply.ListPage(id, page.map { localEntry(it) }, next)) } private fun listChildren(id: Int, dirUri: Uri, cursor: Int): Served { @@ -172,17 +201,23 @@ class FsServer( } return when (val res = roots.resolve(r.path, forWrite = false)) { is FsRoots.Result.Err -> err(r.id, res.code, "refused") - is FsRoots.Result.Ok -> { - val docUri = asDocumentUri(res.uri) ?: return err(r.id, FsCode.INVAL, "not a document") - try { - context.contentResolver.query(docUri, PROJECTION, null, null, null)?.use { c -> - if (!c.moveToFirst()) return err(r.id, FsCode.NOENT, "no such document") - Served.Meta(FsReply.Stat(r.id, entryOf(c, res.uri))) - } ?: err(r.id, FsCode.NOENT, "no such document") - } catch (e: SecurityException) { - err(r.id, FsCode.ACCES, "not granted") - } catch (e: Exception) { - err(r.id, FsCode.IO, e.message ?: "stat failed") + is FsRoots.Result.Ok -> when (val t = res.target) { + is FsRoots.Target.Local -> + if (!t.file.exists()) err(r.id, FsCode.NOENT, "no such file") + else Served.Meta(FsReply.Stat(r.id, localEntry(t.file))) + is FsRoots.Target.Doc -> { + val docUri = asDocumentUri(t.uri) + ?: return err(r.id, FsCode.INVAL, "not a document") + try { + context.contentResolver.query(docUri, PROJECTION, null, null, null)?.use { c -> + if (!c.moveToFirst()) return err(r.id, FsCode.NOENT, "no such document") + Served.Meta(FsReply.Stat(r.id, entryOf(c, t.uri))) + } ?: err(r.id, FsCode.NOENT, "no such document") + } catch (e: SecurityException) { + err(r.id, FsCode.ACCES, "not granted") + } catch (e: Exception) { + err(r.id, FsCode.IO, e.message ?: "stat failed") + } } } } @@ -190,40 +225,67 @@ class FsServer( private fun doOpen(r: OpenReq): Served { if (r.write) return err(r.id, FsCode.ROFS, "this device serves read-only") - return when (val res = roots.resolve(r.path, forWrite = false)) { - is FsRoots.Result.Err -> err(r.id, res.code, "refused") - is FsRoots.Result.Ok -> { - val docUri = asDocumentUri(res.uri) ?: return err(r.id, FsCode.INVAL, "not a document") - var size = 0L - try { - context.contentResolver.query(docUri, PROJECTION, null, null, null)?.use { c -> - if (c.moveToFirst()) { - if (isDir(c)) return err(r.id, FsCode.ISDIR, "is a directory") - size = c.getLong(IDX_SIZE) - } - } - } catch (_: Exception) { - // Size is advisory — the open below is the real test. - } - val pfd = try { - context.contentResolver.openFileDescriptor(docUri, "r") - } catch (e: SecurityException) { - return err(r.id, FsCode.ACCES, "not granted") - } catch (e: java.io.FileNotFoundException) { - return err(r.id, FsCode.NOENT, "no such document") - } catch (e: Exception) { - return err(r.id, FsCode.IO, e.message ?: "open failed") - } ?: return err(r.id, FsCode.IO, "provider returned no descriptor") + val target = when (val res = roots.resolve(r.path, forWrite = false)) { + is FsRoots.Result.Err -> return err(r.id, res.code, "refused") + is FsRoots.Result.Ok -> res.target + } + return when (target) { + is FsRoots.Target.Local -> openLocal(r.id, target.file) + is FsRoots.Target.Doc -> openDoc(r.id, target.uri) + } + } - if (size <= 0) size = try { pfd.statSize.coerceAtLeast(0) } catch (_: Exception) { 0 } - val handle = handles.insert(pfd, size) - if (handle == null) { - try { pfd.close() } catch (_: Exception) {} - return err(r.id, FsCode.IO, "too many open handles") + private fun openLocal(id: Int, f: File): Served { + if (f.isDirectory) return err(id, FsCode.ISDIR, "is a directory") + if (!f.exists()) return err(id, FsCode.NOENT, "no such file") + val pfd = try { + ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY) + } catch (e: SecurityException) { + return err(id, FsCode.ACCES, "not granted") + } catch (e: java.io.FileNotFoundException) { + return err(id, FsCode.NOENT, "no such file") + } catch (e: Exception) { + return err(id, FsCode.IO, e.message ?: "open failed") + } + return finishOpen(id, pfd, f.length()) + } + + private fun openDoc(id: Int, uri: Uri): Served { + val docUri = asDocumentUri(uri) ?: return err(id, FsCode.INVAL, "not a document") + var size = 0L + try { + context.contentResolver.query(docUri, PROJECTION, null, null, null)?.use { c -> + if (c.moveToFirst()) { + if (isDir(c)) return err(id, FsCode.ISDIR, "is a directory") + size = c.getLong(IDX_SIZE) } - Served.Meta(FsReply.Open(r.id, handle, size, readonly = true)) } + } catch (_: Exception) { + // Size is advisory — the open below is the real test. + } + val pfd = try { + context.contentResolver.openFileDescriptor(docUri, "r") + } catch (e: SecurityException) { + return err(id, FsCode.ACCES, "not granted") + } catch (e: java.io.FileNotFoundException) { + return err(id, FsCode.NOENT, "no such document") + } catch (e: Exception) { + return err(id, FsCode.IO, e.message ?: "open failed") + } ?: return err(id, FsCode.IO, "provider returned no descriptor") + + if (size <= 0) size = try { pfd.statSize.coerceAtLeast(0) } catch (_: Exception) { 0 } + return finishOpen(id, pfd, size) + } + + private fun finishOpen(id: Int, pfd: ParcelFileDescriptor, size: Long): Served { + val handle = handles.insert(pfd, size) + if (handle == null) { + // Close what we just opened: refusing the request must not also + // leak the descriptor that made us refuse it. + try { pfd.close() } catch (_: Exception) {} + return err(id, FsCode.IO, "too many open handles") } + return Served.Meta(FsReply.Open(id, handle, size, readonly = true)) } private fun doRead(r: ReadReq): Served { @@ -279,7 +341,22 @@ class FsServer( null } - private fun treeRootPath(treeUri: Uri): String = (treeRootUri(treeUri) ?: treeUri).toString() + /** The address a peer should send back to enter this root. A document URI + * for a SAF tree; an ordinary absolute path for the all-files root. */ + private fun rootPath(root: FsRoots.Root): String = when (root) { + is FsRoots.Root.Tree -> (treeRootUri(root.treeUri) ?: root.treeUri).toString() + is FsRoots.Root.Local -> root.dir.absolutePath + } + + private fun localEntry(f: File): FsEntry = FsEntry( + name = f.name, + isDir = f.isDirectory, + size = if (f.isDirectory) 0 else f.length(), + // The protocol carries seconds; File reports milliseconds. + mtime = f.lastModified() / 1000, + readonly = true, + path = f.absolutePath, + ) /** Peer-supplied URIs are already tree-document URIs (we only ever emit * those), but a bare tree URI is accepted too so the laptop can address a diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt index 418b04a..6ae3e7b 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt @@ -266,6 +266,47 @@ class MainActivity : ComponentActivity() { } } + /** + * Open Android's all-files-access screen. + * + * Only reachable from the "Allow access to any files" setting — never on + * the first-run path. It is a special access, granted on a system screen we + * cannot skip, and the honest default is the folder picker: pairing a + * laptop should not quietly come to mean handing over the whole phone + * (design doc §5). + * + * Toggling off is Android's job too, on the same screen, so there is one + * place that decides and nothing of ours to keep in step. + */ + internal fun openAllFilesAccess() { + val intents = listOf( + // App-specific screen first: it lands on our entry with the toggle + // right there. + android.content.Intent( + android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + android.net.Uri.parse("package:$packageName"), + ), + // Some OEM ROMs (MIUI among them) do not implement the per-app + // screen and throw; the global list is the documented fallback. + android.content.Intent( + android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION, + ), + ) + for (i in intents) { + try { + startActivity(i) + return + } catch (_: Exception) { + // Try the next one. + } + } + android.widget.Toast.makeText( + this, + "This phone has no all-files access screen", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + /** Open the folder picker. Adding is the only action here: revoking is * Android's own "remove permission" in app settings, and duplicating it * would give two places that must agree about what is shared. */ @@ -498,6 +539,7 @@ class MainActivity : ComponentActivity() { onOpenScreenControl = ::onOpenAccessibilitySettings, onRequestMediaPermission = ::requestMediaPermission, onPickSharedFolder = ::pickSharedFolder, + onOpenAllFilesAccess = ::openAllFilesAccess, onEnableBluetooth = ::onEnableBluetooth, isAggressiveOem = isAggressiveOemRom(), isIgnoringBatteryOptimizations = ::isIgnoringBatteryOptimizations, diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index 7500bb1..7977ea8 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -104,6 +104,7 @@ class VortexActions( /** Ask for the storage grant the media-share toggles need (no-op if held). */ val onRequestMediaPermission: () -> Unit, val onPickSharedFolder: () -> Unit, + val onOpenAllFilesAccess: () -> Unit, /** Ask the system to turn Bluetooth on (one-tap dialog). */ val onEnableBluetooth: () -> Unit, val isAggressiveOem: Boolean, @@ -193,6 +194,9 @@ fun VortexRoot( val sharedFolderCount = remember(showSettings) { com.vortex.a3.core.fs.FsRoots(activity).roots().size } + val allFilesOn = remember(showSettings) { + com.vortex.a3.core.fs.FsRoots(activity).allFilesGranted() + } if (showNotes) { // System back pops to Home instead of leaving the app. // NotesScreen's own handlers (close the editor) compose @@ -257,6 +261,8 @@ fun VortexRoot( onScreenControlClick = actions.onOpenScreenControl, sharedFolderCount = sharedFolderCount, onSharedFoldersClick = actions.onPickSharedFolder, + allFilesOn = allFilesOn, + onAllFilesClick = actions.onOpenAllFilesAccess, onBack = { showSettings = false }, ) } else { diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt index bfa0479..6e99e9c 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Headset import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LightMode @@ -102,6 +103,8 @@ fun SettingsScreen( onScreenControlClick: () -> Unit, sharedFolderCount: Int, onSharedFoldersClick: () -> Unit, + allFilesOn: Boolean, + onAllFilesClick: () -> Unit, onBack: () -> Unit, ) { Column( @@ -316,6 +319,20 @@ fun SettingsScreen( status = if (sharedFolderCount > 0) "$sharedFolderCount" else "Off", onClick = onSharedFoldersClick, ) + ActionRow( + icon = Icons.Outlined.Storage, + title = "Allow access to any files", + // Says what it costs before it is granted, and what it + // replaces once it is: with all-files on, the picked + // folders are superseded rather than added to, and showing + // the same file under two paths would be worse than saying + // so here. + hint = if (allFilesOn) + "On — the laptop can browse all of your storage, read-only" + else "Off — instead of picking folders, share everything (asks Android)", + status = if (allFilesOn) "On" else "Off", + onClick = onAllFilesClick, + ) } } } diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 68c0de4..850f6d8 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -166,6 +166,22 @@ There is no storage permission today, so this is new surface either way: opt-in for users who want the full view. That keeps the scary permission out of the first-run path while not capping what power users can do. +*Implemented as recommended.* "Shared folders" runs the SAF picker; "Allow +access to any files" opens Android's special-access screen and is never touched +on the first-run path. Neither grant is mirrored into a preference of ours — the +OS grant IS the setting, read live, so a revocation in system settings cannot +leave us offering a root we can no longer read. + +Two consequences worth knowing: + +* All-files **supersedes** the picked folders rather than adding to them. + Serving both would show one file under two unrelated paths, and nothing on + the wire says they are the same file. +* Under all-files the served root is shared storage only, still canonicalised + and gated. Being granted all-files is not agreement to serve `/data`: the + user turned on "any files" meaning *their* files, and the app can read a + great deal more than that. + --- ## 6. Transport reality @@ -205,7 +221,11 @@ This is where these features usually fail, and it is all daemon-side: 1. **`FS_STAT` + `FS_LIST` + `FS_READ`** on the phone (answer ranged reads, nothing else) and the daemon-side client. No mount yet — validate over the - existing session with a CLI. + existing session with a CLI. **Built, not yet run against a phone:** protocol + and laptop server/client (`fs_proto`, `fs_server`, `fs_link`), the phone's + server (`core/fs/`), and the CLI (`--fs-ls`, `--fs-stat`, `--fs-get`, which + log to `~/.cache/vortex/vortex.log`). What remains for this step is a live + run: share a folder, list it, fetch a file, compare checksums. 2. **Rework large-file transfer onto ranged reads.** Removes `MAX_FILE_BYTES` and the buffer-the-whole-file crash. Ships value before any mount exists. 3. **Daemon cache layer** — metadata, readahead, content budget. From d0e7666299b181be7ab77760a557821c0a3c2dba Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 18:00:20 +0200 Subject: [PATCH 37/71] fix(ble): stop an oversized notify killing the phone app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two limits govern a GATT notify and only one was being honoured. `ATT_MTU-3` is the transport limit; the attribute value itself caps at 512 bytes, and `notifyCharacteristicChanged` THROWS above that rather than truncating. On a 517 MTU the two disagree — budget 514 > 512 — so every fragment built at full budget raised IllegalArgumentException on a coroutine worker, which takes the whole process down. The fragmentation path was therefore broken exactly on the links that negotiate the largest MTU, and only for frames big enough to need fragmenting. Found live the first time one did: an FS directory listing of shared storage crashed the app, and the phone came back with a fresh PID and no session. Nothing about it is specific to the filesystem work — a 529-696 byte NOTIFICATION frame, the case the fragmentation comment cites as its reason for existing, would have done the same on this phone. So the budget is now min(MTU-3, 512), and `notifyTo` catches RuntimeException alongside SecurityException: anything the stack throws must become a failed send rather than a dead app, which every caller here already handles. Verified on the device: a 4126-byte reply now goes out as 9 fragments at budget 512, 13 fragmented frames across the session, zero crashes, PID stable. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/core/ble/GattServer.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt index 728e7fc..68fd829 100644 --- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt @@ -382,7 +382,16 @@ class GattServer( * Called under `synchronized(cipher)` from [sealAndNotify], so a * fragment burst can't interleave with another sealed frame. */ fun sendAudioSignal(device: BluetoothDevice, frame: Frame): Boolean { - val budget = ((deviceMtu[device.address] ?: 23) - 3).coerceAtLeast(1) + // ATT_MTU-3 is the transport limit, but NOT the only one: the GATT + // attribute value itself caps at 512 bytes, and + // `notifyCharacteristicChanged` THROWS above that rather than + // truncating. On a 517 MTU the two disagree — budget 514 > 512 — so + // every fragment we built at full budget crashed the app from a worker + // thread. Observed live the first time a frame needed fragmenting on a + // 517-MTU link (an FS directory listing); any notification over the + // budget would have done it. + val budget = minOf((deviceMtu[device.address] ?: 23) - 3, ATT_MAX_VALUE_LEN) + .coerceAtLeast(1) val encoded = frame.encode() if (encoded.size <= budget) { return notifyTo(device, frame, audioSignalChar, audioSignalSubscribers) @@ -733,6 +742,16 @@ class GattServer( } catch (e: SecurityException) { Log.w(TAG, "notify threw for ${device.address}: ${e.message}") false + } catch (e: RuntimeException) { + // Anything else the stack throws — an oversized value, a stale + // server handle — must become a failed send, not a dead app. + // This runs on a coroutine worker, where an escaping exception + // takes the whole process down, and every caller here already + // handles false. A 517-byte MTU once did exactly that: the + // fragment budget was MTU-3 while GATT caps an attribute value + // at 512, and the stack threw rather than truncating. + Log.w(TAG, "notify failed for ${device.address}: ${e.message}") + false } if (!queued) { gate.pending = false @@ -1249,6 +1268,10 @@ class GattServer( companion object { private const val TAG = "VortexGattSrv" + + /** GATT caps an attribute value at 512 bytes regardless of the + * negotiated MTU, and the notify call throws above it. */ + private const val ATT_MAX_VALUE_LEN = 512 /** How far to skip the recv nonce forward when an AUDIO_SIGNAL open * fails, to resync past dropped BLE writes without a re-handshake * (mirrors the laptop daemon's NONCE_RESYNC_WINDOW). */ From 50aa13bef657bbeda6ef56cc59128ff1773ec1f5 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 18:00:20 +0200 Subject: [PATCH 38/71] fix(logging): don't let a CLI invocation destroy the running app's log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `init_logging` rolls the previous log aside and truncates, which is right for a fresh launch and catastrophic for a forwarding one. Every `--fs-ls`, `--share` or `--mirror` starts a process that inits logging BEFORE single-instance hands its argv over and exits — so it renames the running app's file out from under its open handle. One invocation moves the live log to vortex.log.1; a second overwrites that name, and the running app is then writing to an unlinked inode nobody can open. Found while testing the FS CLI: two `--fs-ls` runs destroyed the very log they were meant to be read in, which is a particularly annoying way to lose the evidence you just went to the trouble of producing. A launch with arguments is always a forwarder in practice, so it now keeps no file log at all and writes to stderr only. The cost is that a genuine FIRST launch carrying arguments has no file log for that session — rare, since both autostart and the desktop entry launch bare, and recoverable by restarting. Losing the running app's log is neither. Co-Authored-By: Claude Opus 5 --- linux/ui-tauri/src-tauri/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 6f89a6f..592aa20 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -282,8 +282,22 @@ impl std::io::Write for Tee { fn init_logging() { use std::io::Write; + // A launch WITH arguments is a forwarder: single-instance hands the argv to + // the already-running app and this process exits seconds later. It must not + // touch the log file, because rolling it aside pulls the running app's file + // out from under its open handle — after two such invocations the real + // app's output is going to an unlinked inode nobody can read. Found the + // hard way: two `--fs-ls` runs in a row destroyed the very log they were + // supposed to be inspected in. + // + // The cost is that a FIRST launch carrying arguments keeps no file log for + // that session. That is the rare case (autostart and the desktop entry both + // launch bare) and it is recoverable by restarting, whereas losing the + // running app's log is not. + let forwarding = std::env::args().len() > 1; let path = vortex_l3_daemon::core::platform::paths() .logs() + .filter(|_| !forwarding) .map(|dir| { let _ = std::fs::create_dir_all(&dir); dir.join("vortex.log") From d4064ad2968a20148a9d340af77132cc186dea4e Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 18:00:37 +0200 Subject: [PATCH 39/71] docs(fs): record what step 1 actually did on a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 is validated end to end, and the doc now says so with the numbers rather than "should work": listings, stat, and byte-identical fetches over both a single read and 11 ranged ones, plus the refusal paths. Also records the gap the measurement exposed. Throughput was 30-41 KiB/s, which is BLE — §6 says content rides Wi-Fi, and `fs_link` currently sends over the sealed BLE writer only. That is fine for metadata and small files and is the thing step 3/4 has to fix before a mount is pleasant to use. Co-Authored-By: Claude Opus 5 --- docs/design/file-browsing.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 850f6d8..7c86c9f 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -221,11 +221,25 @@ This is where these features usually fail, and it is all daemon-side: 1. **`FS_STAT` + `FS_LIST` + `FS_READ`** on the phone (answer ranged reads, nothing else) and the daemon-side client. No mount yet — validate over the - existing session with a CLI. **Built, not yet run against a phone:** protocol - and laptop server/client (`fs_proto`, `fs_server`, `fs_link`), the phone's - server (`core/fs/`), and the CLI (`--fs-ls`, `--fs-stat`, `--fs-get`, which - log to `~/.cache/vortex/vortex.log`). What remains for this step is a live - run: share a folder, list it, fetch a file, compare checksums. + existing session with a CLI. **Done and validated on a device** + (2026-09-06, over BLE, all-files root): `--fs-ls` returned 34 entries of + shared storage; `--fs-stat` matched size and mtime; `--fs-get` fetched + 28 KB and 482 KB files byte-identical by md5, the latter over 11 ranged + reads with the zip still passing `unzip -t`. Refusals behave: a missing path + inside a root answers NOENT, while `/data/...`, `/etc/hosts` and a SAF URI + under all-files all answer ACCES, so a peer cannot probe outside what is + served. + + Two bugs it caught, both pre-existing and neither specific to this feature: + fragments were sized at `MTU-3` while GATT caps an attribute value at 512 + and *throws* above it, so fragmenting crashed the app on a 517-MTU link; and + `init_logging` rolled the log on every forwarding CLI launch, destroying the + running app's file. + + Measured throughput was 30-41 KiB/s, which is BLE. Content is supposed to + ride Wi-Fi (§6) and does not yet — `fs_link` sends over the sealed BLE + writer only. Fine for metadata and small files; it is what step 3/4 has to + fix before this is a mount anyone would enjoy using. 2. **Rework large-file transfer onto ranged reads.** Removes `MAX_FILE_BYTES` and the buffer-the-whole-file crash. Ships value before any mount exists. 3. **Daemon cache layer** — metadata, readahead, content budget. From b5b22c8d2298df9afe1eeaf818ae386f1bd65f68 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 18:13:46 +0200 Subject: [PATCH 40/71] feat(fs): prefer Wi-Fi for filesystem traffic, fall back to Bluetooth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc §6 says content rides Wi-Fi and BLE stays for metadata and wake-up. Measured on the phone, same 482 KB file over the same session: 41 KiB/s on BLE, 931 KiB/s on LAN — 23x — and a directory listing went from ~2.5 s to 11 ms. Both fetches byte-identical. Most of that is framing, not radio. A BLE notify caps at 512 bytes, so a 48 KiB read leaves as ~96 fragments paced 10 ms apart; over TCP it is one frame. The session is a dedicated one rather than the heartbeat's. The heartbeat connects, syncs and disconnects every ~13 s, while filesystem work is bursty and correlated — a fetch is OPEN, N READs, CLOSE — so a TCP connect plus IK (~300 ms here) per request would cost more than the reads it carried. It opens lazily on the first op, is reused, and closes after 60 s idle so an abandoned browse stops holding the phone's radio. A failed connect is not retried for 30 s: without that, one unreachable phone turns into a full connect timeout per read instead of one for the burst. Both transports serve from ONE handle table on the phone. Handles are minted by OPEN and used by later READs, and the laptop may switch transports between them — per-transport tables would answer BADF in the middle of a file, precisely when the network got worse. That forced a small ordering fix: BLE components start before the LAN server exists, and `restartBleComponents` replaces the server and its handle table without touching the LAN side, so whichever starts second installs the serve function. The peer's static key is verified against the trusted record before a single filesystem frame goes out. This session can be asked to read the user's files, so "who is on the other end" is not a question to answer optimistically. Falling back notifies the user, because the failure is invisible otherwise: the app keeps working and simply becomes 20x slower, which reads as broken rather than degraded. The fix — put both devices on one network — is something they can act on, which is the test for whether a notification earns its place. Shown once per outage, not once per request. 204 daemon + 39 app tests pass; both targets check clean. Live-verified on the phone: LAN listing, LAN fetch, checksums. NOT live-verified: the fallback and its notification — making LAN fail on this setup means cutting the Wi-Fi path adb itself runs over. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/core/lan/LanServer.kt | 36 +++ .../java/com/vortex/a3/service/VortexStack.kt | 9 + .../com/vortex/a3/service/VortexStackFs.kt | 26 +++ docs/design/file-browsing.md | 13 +- linux/daemon/src/core/fs_lan.rs | 218 ++++++++++++++++++ linux/daemon/src/core/mod.rs | 1 + linux/ui-tauri/src-tauri/src/fs_lan.rs | 184 +++++++++++++++ linux/ui-tauri/src-tauri/src/fs_link.rs | 17 ++ linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src-tauri/src/worker.rs | 3 + 10 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 linux/daemon/src/core/fs_lan.rs create mode 100644 linux/ui-tauri/src-tauri/src/fs_lan.rs diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt index 09e8c16..d04af56 100644 --- a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt @@ -101,6 +101,18 @@ class LanServer( * the stack can gate the redundant BLE burst. */ var onBulkDelivered: (key: String, hash: String) -> Unit = { _, _ -> } + /** + * Serve one filesystem op (FS_REQ 0x50) and return the reply as + * `(frame type, payload)`, or null when there is no server wired. + * + * Wired by VortexStack to the SAME [com.vortex.a3.core.fs.FsServer] the BLE + * path uses, deliberately: handles are minted by OPEN and used by later + * READs, and the laptop may switch transports between the two — it prefers + * Wi-Fi and falls back to Bluetooth on failure. A per-transport handle + * table would turn that fallback into a BADF in the middle of a file. + */ + var fsServe: ((op: Byte, payload: ByteArray) -> Pair)? = null + /** Fired after an instant-share FILE blob has been written to the peer, * with the content token it pulled by. Closes the loop the outgoing-offer * watchdog waits on: an offer is only really done once the laptop has the @@ -993,6 +1005,30 @@ class LanServer( status.toString().toByteArray(Charsets.UTF_8), ) } + frame.type == FrameType.FS_REQ -> { + // Ranged filesystem op over Wi-Fi. The laptop + // prefers this transport because BLE caps a notify + // at 512 bytes: a 48 KiB read is ~96 paced + // fragments there and a single frame here. + val plain = runCatching { + aeadOpen(pair.receiver, frame.payload) + }.getOrNull() + if (plain == null) { + Log.w(TAG, "fs: AEAD decrypt failed") + continue + } + val serve = fsServe + if (serve == null) { + Log.w(TAG, "fs: no server wired; ignoring op 0x${"%02x".format(frame.sub)}") + continue + } + // Serving touches the disk and runs on this + // connection's thread, which is what we want: it + // serialises the ops on this socket and cannot + // stall any other peer's connection. + val (type, bytes) = serve(frame.sub, plain) + lockedSealAndWrite(type, 0x00, bytes) + } frame.type == FrameType.AUDIO_OP -> { // Earbuds-switch frame (Phase 1). AEAD-decrypt // the payload, decode the AudioOpFrame JSON, diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index b2674db..e58f3ae 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -85,6 +85,12 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { /** Open read handles held for the current peer's filesystem session, so * they can be dropped when the link goes. Null until [startFsServer]. */ internal var fsHandles: com.vortex.a3.core.fs.FsHandles? = null + + /** The filesystem serve function, kept here because the two transports are + * started at different times: BLE comes up before the LAN server exists, + * and `restartBleComponents` replaces the server (and its handle table) + * without touching the LAN side. Whoever starts second installs it. */ + internal var fsServeFn: ((Byte, ByteArray) -> Pair)? = null /** Buffers phone→laptop notifications that fail to send while BLE is down; * flushed when the peer re-subscribes to AUDIO_SIGNAL. */ internal val notificationOutbox = com.vortex.a3.core.notif.NotificationOutbox() @@ -1181,6 +1187,9 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // measured at five seconds on the first real attempt. The BLE write is // ours to make and lands in a couple of hundred milliseconds. VortexService.appStateNudge = { pushStateViaBle() } + // BLE started first, so the serve function already exists; install it + // now that there is a LAN server to hang it on. + lan.fsServe = fsServeFn } /** diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt index fe1949e..7c5f0fc 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt @@ -25,6 +25,32 @@ internal fun VortexStack.startFsServer() { val server = FsServer(ctx, roots, handles) fsHandles = handles + // Wi-Fi path. The laptop prefers it and falls back to BLE, so BOTH + // transports serve from this one server and one handle table: a handle is + // minted by OPEN and used by later READs, and a fallback between the two + // would otherwise answer BADF halfway through a file. + // + // Runs on the LanServer connection's own thread rather than being hopped + // onto Dispatchers.IO: that thread exists to serialise this socket's + // frames, and the reply must be written before the next op is read. + val serve: (Byte, ByteArray) -> Pair = { op, payload -> + val srv = try { + server.serve(op, payload) + } catch (e: Exception) { + FsServer.Served.Err(FsErr(0, FsCode.IO, e.message ?: "serve failed")) + } + when (srv) { + is FsServer.Served.Meta -> FrameType.FS_META to srv.reply.toJsonBytes() + is FsServer.Served.Data -> FrameType.FS_DATA to srv.bytes + is FsServer.Served.Err -> FrameType.FS_ERR to srv.err.toJsonBytes() + } + } + fsServeFn = serve + // Null on first start (BLE comes up before the LAN server, which installs + // it itself); non-null on a BLE restart, which replaces the server and + // handle table the LAN side was still pointing at. + lanServer?.fsServe = serve + gattServer?.onFsRequest = { peerPub, op, payload -> // Off the GATT callback thread, always. A document provider can stall // for seconds — a cloud-backed one indefinitely — and blocking here diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 7c86c9f..80fc4c8 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -236,10 +236,15 @@ This is where these features usually fail, and it is all daemon-side: `init_logging` rolled the log on every forwarding CLI launch, destroying the running app's file. - Measured throughput was 30-41 KiB/s, which is BLE. Content is supposed to - ride Wi-Fi (§6) and does not yet — `fs_link` sends over the sealed BLE - writer only. Fine for metadata and small files; it is what step 3/4 has to - fix before this is a mount anyone would enjoy using. + Wi-Fi is now the preferred transport, with BLE as the fallback (§6). Same + 482 KB file, same phone, same session: **41 KiB/s over BLE, 931 KiB/s over + LAN** — 23x — and a directory listing went from ~2.5 s to 11 ms. Both + byte-identical. The gain is mostly framing: a BLE notify caps at 512 bytes, + so a 48 KiB read is ~96 fragments paced 10 ms apart, against one TCP frame. + + The LAN session is opened lazily, kept for 60 s of idleness, and both + transports serve from ONE handle table on the phone — a handle minted by + OPEN over Wi-Fi must still be readable by a READ that fell back to BLE. 2. **Rework large-file transfer onto ranged reads.** Removes `MAX_FILE_BYTES` and the buffer-the-whole-file crash. Ships value before any mount exists. 3. **Daemon cache layer** — metadata, readahead, content budget. diff --git a/linux/daemon/src/core/fs_lan.rs b/linux/daemon/src/core/fs_lan.rs new file mode 100644 index 0000000..5fc829f --- /dev/null +++ b/linux/daemon/src/core/fs_lan.rs @@ -0,0 +1,218 @@ +//! A LAN transport for the filesystem protocol. +//! +//! BLE carries these frames today and works, but at 30-40 KiB/s (measured) — +//! fine for a directory listing, hopeless for content. Design doc §6 is +//! explicit that content streams over Wi-Fi and BLE stays for metadata and +//! wake-up, so this opens a TCP+IK session and pushes the same frames down it. +//! +//! Two properties make it worth a dedicated session rather than reusing the +//! heartbeat: +//! +//! * **It stays open.** The heartbeat connects, syncs and disconnects every +//! ~13 s. Filesystem work is bursty and correlated — a fetch is one OPEN, N +//! READs and a CLOSE — and paying a TCP connect plus an IK handshake +//! (~300 ms on this link) per request would cost more than the reads. +//! * **No fragmentation.** A BLE notify caps at 512 bytes, so a 48 KiB read is +//! 96 fragments paced 10 ms apart. Over TCP the same read is one frame. +//! +//! The session is otherwise deliberately dumb: it moves frames and knows +//! nothing about ops, ids or handles. The caller keeps all of that, which is +//! what lets the same `fs_link` client sit on either transport. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::time::timeout; + +use crate::core::ble::frame::{ty, Frame, FRAME_HEADER_LEN, MAX_FRAME_PAYLOAD}; +use crate::core::crypto::x25519::X25519SecBytes; + +/// Budget for the handshake. Same shape as the audio session's: generous +/// enough for a sleepy phone, short enough that a dead address fails fast and +/// the caller can fall back to BLE while the user is still watching. +const IK_STEP_TIMEOUT: Duration = Duration::from_secs(8); + +/// Writes one sealed frame onto the session. +pub type FsLanWriter = Arc< + dyn Fn(u8, u8, Vec) -> futures::future::BoxFuture<'static, Result<(), String>> + + Send + + Sync, +>; + +/// Open a TCP+IK session for filesystem frames. +/// +/// On success the read loop runs until the socket closes or a frame fails to +/// open, handing every decrypted frame to `on_frame` and calling `on_closed` +/// exactly once at the end — the caller uses that to drop its cached writer so +/// the next request reopens or falls back rather than writing into a dead +/// socket. +#[allow(clippy::too_many_arguments)] +pub async fn open_session( + addr: SocketAddr, + static_priv: &X25519SecBytes, + peer_static_pub: &[u8; 32], + prs: &[u8; 32], + local_counter: u64, + on_frame: Arc, + on_closed: Arc, +) -> Result { + let mut stream = timeout(IK_STEP_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| "tcp connect timeout".to_string())? + .map_err(|e| format!("tcp connect: {e}"))?; + // Filesystem traffic is many small round trips (a stat per icon) plus a + // few large ones. Nagle would sit on the small ones waiting for company. + let _ = stream.set_nodelay(true); + + let mut handshake = + crate::core::lan::tcp_client::build_ik_initiator(static_priv, peer_static_pub, prs) + .map_err(|e| format!("noise build: {e}"))?; + let mut buf = vec![0u8; 1024]; + let mut tmp = vec![0u8; 1024]; + + let n = handshake + .write_message(&local_counter.to_be_bytes(), &mut buf) + .map_err(|e| format!("noise write msg1: {e}"))?; + write_frame(&mut stream, &Frame::new(ty::RECONNECT_HANDSHAKE, 0x01, buf[..n].to_vec())).await?; + + let msg2 = timeout(IK_STEP_TIMEOUT, read_frame_capped(&mut stream, 128)) + .await + .map_err(|_| "msg2 timeout".to_string())??; + if msg2.ty != ty::RECONNECT_HANDSHAKE || msg2.sub != 0x02 { + return Err(format!("unexpected msg2 ty=0x{:02x}", msg2.ty)); + } + handshake + .read_message(&msg2.payload, &mut tmp) + .map_err(|e| format!("noise read msg2: {e}"))?; + + // The peer's static must be the one we trusted at pair time. Checked + // before a single filesystem frame goes out: this session can be asked to + // read the user's files, so "who is on the other end" is not a question to + // answer optimistically. + if handshake + .get_remote_static() + .ok_or_else(|| "no remote static after IK".to_string())? + != peer_static_pub + { + return Err("peer static mismatch".to_string()); + } + + let transport = Arc::new(Mutex::new( + handshake + .into_transport_mode() + .map_err(|e| format!("transport mode: {e}"))?, + )); + + let (mut reader, writer_half) = stream.into_split(); + let writer_half = Arc::new(Mutex::new(writer_half)); + + // Read loop. Owns the receive side of the cipher, so it never contends + // with the writer for it beyond the shared mutex. + { + let transport = transport.clone(); + tokio::spawn(async move { + loop { + match read_sealed(&mut reader, &transport).await { + Ok(Some(frame)) => on_frame(frame), + Ok(None) => { + tracing::info!("fs-lan: peer closed the session"); + break; + } + Err(e) => { + tracing::warn!("fs-lan: read loop ended: {e}"); + break; + } + } + } + on_closed(); + }); + } + + let writer: FsLanWriter = Arc::new(move |ty_byte: u8, sub: u8, plain: Vec| { + let transport = transport.clone(); + let writer_half = writer_half.clone(); + Box::pin(async move { + if plain.len() + 16 > MAX_FRAME_PAYLOAD { + return Err(format!("frame too large: {}", plain.len())); + } + let mut out = vec![0u8; plain.len() + 16]; + let n = { + let mut t = transport.lock().await; + t.write_message(&plain, &mut out) + .map_err(|e| format!("aead seal: {e}"))? + }; + let bytes = Frame::new(ty_byte, sub, out[..n].to_vec()).encode(); + let mut w = writer_half.lock().await; + w.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?; + w.flush().await.map_err(|e| format!("tcp flush: {e}"))?; + Ok(()) + }) + }); + + tracing::info!(%addr, "fs-lan: session up"); + Ok(writer) +} + +async fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), String> { + let bytes = frame.encode(); + stream.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?; + stream.flush().await.map_err(|e| format!("tcp flush: {e}"))?; + Ok(()) +} + +async fn read_frame_capped(stream: &mut TcpStream, cap: usize) -> Result { + let cap = cap.min(MAX_FRAME_PAYLOAD); + let mut header = [0u8; FRAME_HEADER_LEN]; + stream + .read_exact(&mut header) + .await + .map_err(|e| format!("tcp read header: {e}"))?; + let length = u16::from_be_bytes([header[2], header[3]]) as usize; + if length > cap { + return Err(format!("oversize frame {length}")); + } + let mut full = vec![0u8; FRAME_HEADER_LEN + length]; + full[..FRAME_HEADER_LEN].copy_from_slice(&header); + if length > 0 { + stream + .read_exact(&mut full[FRAME_HEADER_LEN..]) + .await + .map_err(|e| format!("tcp read body: {e}"))?; + } + Frame::decode(&full).map_err(|e| format!("frame decode: {e}")) +} + +/// Read one frame and AEAD-open it. `Ok(None)` is a clean EOF. +async fn read_sealed( + reader: &mut tokio::net::tcp::OwnedReadHalf, + transport: &Arc>, +) -> Result, String> { + let mut header = [0u8; FRAME_HEADER_LEN]; + match reader.read_exact(&mut header).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(format!("tcp read header: {e}")), + } + let length = u16::from_be_bytes([header[2], header[3]]) as usize; + if length > MAX_FRAME_PAYLOAD { + return Err(format!("oversize frame {length}")); + } + let mut body = vec![0u8; length]; + if length > 0 { + reader + .read_exact(&mut body) + .await + .map_err(|e| format!("tcp read body: {e}"))?; + } + let mut plain = vec![0u8; length.max(16)]; + let n = { + let mut t = transport.lock().await; + t.read_message(&body, &mut plain) + .map_err(|e| format!("aead open: {e}"))? + }; + Ok(Some(Frame::new(header[0], header[1], plain[..n].to_vec()))) +} diff --git a/linux/daemon/src/core/mod.rs b/linux/daemon/src/core/mod.rs index 6ceac63..0206bd2 100644 --- a/linux/daemon/src/core/mod.rs +++ b/linux/daemon/src/core/mod.rs @@ -21,6 +21,7 @@ pub mod ble; pub mod crypto; #[cfg(target_os = "linux")] pub mod earbuds; +pub mod fs_lan; pub mod fs_private; pub mod earbuds_store; pub mod phone_files; diff --git a/linux/ui-tauri/src-tauri/src/fs_lan.rs b/linux/ui-tauri/src-tauri/src/fs_lan.rs new file mode 100644 index 0000000..b6af5f4 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/fs_lan.rs @@ -0,0 +1,184 @@ +//! Prefer Wi-Fi for filesystem traffic; fall back to BLE. +//! +//! Both transports carry the same frames, so this is purely a routing +//! decision. It matters because they are three orders of magnitude apart: BLE +//! measured 30-41 KiB/s on this link, and a 48 KiB read has to go out as ~96 +//! notify fragments paced 10 ms apart. Over TCP it is one frame. +//! +//! The session is opened lazily on the first filesystem op and kept while it is +//! being used, because filesystem work arrives in correlated bursts — a fetch +//! is OPEN, N READs, CLOSE — and a TCP connect plus IK per request would cost +//! more than the reads it carried. It is dropped after [`IDLE_TIMEOUT`] so a +//! browse that ended does not hold the phone's Wi-Fi awake. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use vortex_l3_daemon::core::fs_lan::FsLanWriter; +use vortex_l3_daemon::core::identity::IdentityRecord; +use vortex_l3_daemon::core::storage::peers::PeerStore; + +/// How long an unused session is kept before it is closed. +/// +/// Long enough to cover a user clicking through folders, short enough that an +/// abandoned browse stops holding a socket (and the phone's radio) open. +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Don't retry a failed connect more often than this. +/// +/// Without it, every request in a burst would pay the full connect timeout +/// before falling back — turning one unreachable phone into a stall per read +/// instead of a single one. +const RETRY_COOLDOWN: Duration = Duration::from_secs(30); + +struct Ctx { + identity: IdentityRecord, + peer_store: Arc, +} + +static CTX: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[derive(Default)] +struct Session { + writer: Option, + last_used: Option, + last_failed: Option, + /// Whether the user has already been told about this outage, so a browse + /// that issues fifty reads over a dead network produces one notification + /// rather than fifty. + warned: bool, +} + +fn session() -> &'static tokio::sync::Mutex { + static S: std::sync::OnceLock> = std::sync::OnceLock::new(); + S.get_or_init(|| tokio::sync::Mutex::new(Session::default())) +} + +/// Supply the credentials a session needs. Called once, alongside +/// [`crate::fs_link::init`]. +pub(crate) fn init(identity: IdentityRecord, peer_store: Arc) { + let _ = CTX.set(Ctx { + identity, + peer_store, + }); +} + +/// The LAN writer, opening a session if needed. `None` means "use BLE". +pub(crate) async fn writer() -> Option { + let mut s = session().lock().await; + + // Reuse a live session, unless it has been idle long enough that the peer + // may have dropped it under us. + if let Some(w) = s.writer.clone() { + if s.last_used.is_some_and(|t| t.elapsed() < IDLE_TIMEOUT) { + s.last_used = Some(Instant::now()); + return Some(w); + } + tracing::info!("fs-lan: closing idle session"); + s.writer = None; + } + if s.last_failed.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) { + return None; + } + + let ctx = CTX.get()?; + let peers = { + let store = ctx.peer_store.clone(); + tokio::task::spawn_blocking(move || store.list().unwrap_or_default()) + .await + .ok()? + }; + // The peer whose files we are browsing is the active one, for the same + // reason the BLE loop dials it: it owns the session the UI is showing. + let peer = crate::arbiter::preferred_peer(peers)?; + let addr = crate::lan::resolve_peer_addr(false).await?; + let local_counter = { + let store = ctx.peer_store.clone(); + let peer_pub = peer.peer_static_pub; + tokio::task::spawn_blocking(move || store.load_counter(&peer_pub).unwrap_or(0)) + .await + .unwrap_or(0) + }; + + let peer_pub = peer.peer_static_pub; + let on_frame = Arc::new(move |f: vortex_l3_daemon::core::ble::frame::Frame| { + // Same entry point the BLE listener uses, so a reply is handled + // identically whichever transport carried it. + crate::fs_link::dispatch(vortex_l3_daemon::core::ble::frame::RawFrame { + peer_pub, + ty: f.ty, + sub: f.sub, + payload: f.payload, + }); + }); + let on_closed = Arc::new(|| { + tokio::spawn(async { + let mut s = session().lock().await; + s.writer = None; + s.last_used = None; + }); + }); + + match vortex_l3_daemon::core::fs_lan::open_session( + addr, + &ctx.identity.static_priv.0, + &peer.peer_static_pub, + &peer.prs, + local_counter, + on_frame, + on_closed, + ) + .await + { + Ok(w) => { + s.writer = Some(w.clone()); + s.last_used = Some(Instant::now()); + s.last_failed = None; + s.warned = false; + Some(w) + } + Err(e) => { + tracing::warn!(%addr, "fs-lan: session failed ({e}); using BLE"); + s.last_failed = Some(Instant::now()); + if !s.warned { + s.warned = true; + warn_user_slow_link(); + } + None + } + } +} + +/// Drop the session — the peer changed, or the link went. +pub(crate) async fn close() { + let mut s = session().lock().await; + if s.writer.take().is_some() { + tracing::info!("fs-lan: session closed"); + } + s.last_used = None; +} + +/// Tell the user why browsing just got slow. +/// +/// Worth interrupting for: over BLE a folder listing is fine but a file copy +/// runs at ~40 KiB/s, so a transfer that should take a second takes minutes. +/// Without this the app looks broken rather than degraded, and the fix — +/// putting both devices on the same Wi-Fi — is one the user can actually act +/// on, which is the test for whether a notification earns its place. +fn warn_user_slow_link() { + tokio::spawn(async { + let _ = crate::notify::show_banner( + "Phone files over Bluetooth", + "Wi-Fi isn't reachable, so browsing and copying will be slow. \ + Put both devices on the same network to speed it up.", + "vortex", + &[], + 0, + // Not urgent: this is a "why is it slow" explanation, not something + // to act on before continuing. Sticking it on screen until + // dismissed would be worse than the problem it describes. + false, + ) + .await; + }); +} diff --git a/linux/ui-tauri/src-tauri/src/fs_link.rs b/linux/ui-tauri/src-tauri/src/fs_link.rs index cc7f54a..3eacbbe 100644 --- a/linux/ui-tauri/src-tauri/src/fs_link.rs +++ b/linux/ui-tauri/src-tauri/src/fs_link.rs @@ -171,6 +171,23 @@ async fn serve_request(state: Arc, f: RawFrame) { } async fn send(state: &State, ty_byte: u8, sub: u8, payload: Vec) { + // Wi-Fi first, Bluetooth second (design doc §6). The two carry identical + // frames, so this is only a routing choice — but a 48 KiB read is one TCP + // frame and ~96 paced BLE fragments, which is the difference between a + // copy taking a second and taking minutes. + if let Some(w) = crate::fs_lan::writer().await { + match w(ty_byte, sub, payload.clone()).await { + Ok(()) => return, + Err(e) => { + // The session looked alive and wasn't — a phone that changed + // network, or a socket the peer dropped. Fall through to BLE + // rather than failing the request: the caller cannot retry a + // transport it does not know about. + tracing::warn!("fs: LAN send failed ({e}); falling back to BLE"); + crate::fs_lan::close().await; + } + } + } let w = { state.writer.lock().await.clone() }; let Some(w) = w else { tracing::debug!("fs: no writer (link down); dropping a reply"); diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 592aa20..8b2ad21 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -83,6 +83,7 @@ mod send_to_phone; mod share; mod file_consent; mod fs_cli; +mod fs_lan; mod contacts; mod desktop_apps; mod diagnostics; diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index 6ba90a6..8bf31db 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -509,6 +509,9 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { // this laptop's roots, and correlates replies to requests we issue. // Shares the same sealed writer — the ops are just frames. crate::fs_link::init(ble_sealed_writer.clone()); + // Wi-Fi is preferred for the same frames; this hands it the + // credentials a TCP+IK session needs (design doc §6). + crate::fs_lan::init(identity.clone(), peer_store.clone()); let ble_raw_tx = crate::peer_handoff::spawn_dispatcher( app.clone(), peer_store.clone(), From 45467ccc6e88b730f7bb61d8b823befdaa4ee6be Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 18:25:18 +0200 Subject: [PATCH 41/71] fix(fs): fall back to BLE without stalling, and say so when it happens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing the fallback with the phone's Wi-Fi off — the case the previous commit shipped unverified — showed it worked but did the two things around it wrong. The user was never told. `warn_user_slow_link` fired only when a handshake failed, but with the phone off Wi-Fi there is no address to hand to a handshake: `resolve_peer_addr` returns None and the old code bailed on a `?` before reaching it. That is the COMMON case, so in practice the notification the last commit added would essentially never fire. Worse, that early return skipped the retry cooldown too, so every send re-ran discovery. Measured with Wi-Fi off, resolving costs ~12 s (a probe of the cached IP, then an mDNS browse). One listing is one request and merely felt slow; a fetch is OPEN + N READs + CLOSE, so an eleven-read file would have spent over two minutes rediscovering a phone that was not there — each time, before falling back. The fallback would have looked like a hang. Both paths now go through one `note_failure`, so a future "give up on LAN" branch cannot forget either half. The notification also logs whether the daemon accepted it, because the point is to tell the user why things got slow and an assumption is not evidence. Verified end to end with the phone on USB and its Wi-Fi off: * first request warns, arms the cooldown, and completes over BLE; * a second inside the window falls straight through — 0 s, no discovery; * the notification reached the daemon; * re-enabling Wi-Fi recovers by itself, and since the phone came back on a DIFFERENT IP (.35 to .107) that also exercised rediscovery rather than a cached address: session up, listing in 2 s, 482 KB fetched at 754 KiB/s byte-identical. Co-Authored-By: Claude Opus 5 --- linux/ui-tauri/src-tauri/src/fs_lan.rs | 56 ++++++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/fs_lan.rs b/linux/ui-tauri/src-tauri/src/fs_lan.rs index b6af5f4..6b8281a 100644 --- a/linux/ui-tauri/src-tauri/src/fs_lan.rs +++ b/linux/ui-tauri/src-tauri/src/fs_lan.rs @@ -81,17 +81,30 @@ pub(crate) async fn writer() -> Option { return None; } - let ctx = CTX.get()?; + let Some(ctx) = CTX.get() else { return None }; let peers = { let store = ctx.peer_store.clone(); - tokio::task::spawn_blocking(move || store.list().unwrap_or_default()) - .await - .ok()? + match tokio::task::spawn_blocking(move || store.list().unwrap_or_default()).await { + Ok(v) => v, + Err(_) => return None, + } }; // The peer whose files we are browsing is the active one, for the same // reason the BLE loop dials it: it owns the session the UI is showing. - let peer = crate::arbiter::preferred_peer(peers)?; - let addr = crate::lan::resolve_peer_addr(false).await?; + let Some(peer) = crate::arbiter::preferred_peer(peers) else { return None }; + + // Every "cannot use LAN" path from here on records the failure, rather + // than only the ones that get as far as a failed handshake. Resolving the + // address is itself expensive — a probe plus an mDNS browse, measured at + // 12 s with the phone's Wi-Fi off — and returning early without arming the + // cooldown made every send in a burst pay it again. An eleven-read fetch + // would have spent over two minutes rediscovering a phone that was not + // there, before falling back each time. + let Some(addr) = crate::lan::resolve_peer_addr(false).await else { + tracing::info!("fs-lan: phone not reachable on the network; using BLE"); + note_failure(&mut s); + return None; + }; let local_counter = { let store = ctx.peer_store.clone(); let peer_pub = peer.peer_static_pub; @@ -139,16 +152,26 @@ pub(crate) async fn writer() -> Option { } Err(e) => { tracing::warn!(%addr, "fs-lan: session failed ({e}); using BLE"); - s.last_failed = Some(Instant::now()); - if !s.warned { - s.warned = true; - warn_user_slow_link(); - } + note_failure(&mut s); None } } } +/// Arm the retry cooldown and tell the user once. +/// +/// One place, so a new "give up on LAN" branch cannot forget either half. The +/// first version warned only on a failed handshake, which missed the common +/// case entirely: with the phone off Wi-Fi there is no address to hand to a +/// handshake, so the user got a silent 20x slowdown and no cooldown. +fn note_failure(s: &mut Session) { + s.last_failed = Some(Instant::now()); + if !s.warned { + s.warned = true; + warn_user_slow_link(); + } +} + /// Drop the session — the peer changed, or the link went. pub(crate) async fn close() { let mut s = session().lock().await; @@ -167,7 +190,7 @@ pub(crate) async fn close() { /// on, which is the test for whether a notification earns its place. fn warn_user_slow_link() { tokio::spawn(async { - let _ = crate::notify::show_banner( + match crate::notify::show_banner( "Phone files over Bluetooth", "Wi-Fi isn't reachable, so browsing and copying will be slow. \ Put both devices on the same network to speed it up.", @@ -179,6 +202,13 @@ fn warn_user_slow_link() { // dismissed would be worse than the problem it describes. false, ) - .await; + .await + { + // Logged either way: the whole point is to tell the user why + // things got slow, so a notification daemon that refused it is + // worth knowing about rather than assuming it landed. + Ok(_) => tracing::info!("fs-lan: told the user we are on the slow link"), + Err(e) => tracing::warn!("fs-lan: could not show the slow-link notice: {e}"), + } }); } From 97c4c9de0dc47184d1042bf50e486b7d6eef1713 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 18:55:21 +0200 Subject: [PATCH 42/71] feat(fs): move file transfer onto ranged reads, and delete the size cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc §8 step 2. Transfers rode a path that held the whole file in memory on BOTH sides: the phone read it into a ByteArray — partly just to hash the bytes for its token — and the laptop reassembled the chunks into a Vec before writing. That is what made an 835 MB share an OutOfMemoryError, and why a 64 MB cap existed. The cap is now deleted rather than raised, because the reason for it is gone. A share registers a GRANT instead: the URI, a name, a size, and a random token. Nothing is read until the laptop asks, and then it asks in ranges through FS_OPEN / FS_READ / FS_CLOSE, each one written straight to disk. Peak memory is one chunk on both ends whatever the file is. It also inherits the transport choice — Wi-Fi when reachable, Bluetooth otherwise — because it is the same client. Tokens are random, not a content hash. Hashing meant reading the whole file before it could even be offered, which is precisely the buffering this removes. A shared file is authorised differently from a browsed one, so `ShareGrants` is a separate gate rather than another root: browsing is a folder the user picked, while a share-sheet file is a one-off URI grant to this process and the act of sharing IS the authorisation. Scoped to that file, addressed by a token that cannot be guessed, revoked once the laptop closes it. CLOSE is the new delivery signal. The old path knew a file had landed because it had just written it onto the socket; a ranged pull has no such moment, so the peer closing the handle stands in for it, and feeds the same share-queue progress and pacing as before. An EXPIRED handle deliberately does not count — that means the reader went away mid-file, which is the opposite of delivery. One regression caught by testing at size. The offer handler's re-announce guard was a 60 s TTL on COMPLETED pulls, which cannot cover a file in flight: the first 151 MB run took 77 s, so a re-announce arrived after the window, found the token neither queued nor recently pulled, and queued the same file again. It is now an explicit in-flight set that lasts exactly as long as the transfer. Verified end to end with a 151 MB APK — 2.4x the old cap, previously refused outright. Byte-identical by md5 in 73 s; two re-announces mid-transfer were correctly ignored; and the phone's Java heap stayed at 16-23 MB throughout, where the old path would have had to hold all 151 MB at once. 204 daemon + 39 app tests pass; both targets check clean. Co-Authored-By: Claude Opus 5 --- .../a3/core/clipboard/ClipboardFileOut.kt | 116 ++++++-------- .../java/com/vortex/a3/core/fs/FsHandles.kt | 29 +++- .../java/com/vortex/a3/core/fs/FsProto.kt | 2 +- .../java/com/vortex/a3/core/fs/FsRoots.kt | 25 +++ .../java/com/vortex/a3/core/fs/FsServer.kt | 62 +++++++- .../java/com/vortex/a3/core/fs/ShareGrants.kt | 78 ++++++++++ .../java/com/vortex/a3/core/lan/LanServer.kt | 44 ------ .../java/com/vortex/a3/service/ShareQueue.kt | 7 +- .../com/vortex/a3/service/VortexService.kt | 2 +- .../vortex/a3/service/VortexStackClipboard.kt | 17 +- .../com/vortex/a3/service/VortexStackFs.kt | 4 + .../a3/service/VortexStackOfferRetry.kt | 2 +- docs/design/file-browsing.md | 11 ++ linux/daemon/src/core/clipboard_mirror.rs | 5 - .../ui-tauri/src-tauri/src/clipboard_sync.rs | 82 +--------- linux/ui-tauri/src-tauri/src/fs_pull.rs | 147 ++++++++++++++++++ linux/ui-tauri/src-tauri/src/lan.rs | 116 -------------- linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src-tauri/src/worker.rs | 2 + 19 files changed, 418 insertions(+), 334 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt create mode 100644 linux/ui-tauri/src-tauri/src/fs_pull.rs diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt index f8e4543..06a311d 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt @@ -5,84 +5,74 @@ import android.net.Uri import android.provider.OpenableColumns import android.util.Log -/** A file the phone is sending to the laptop (bytes + display name + MIME). */ -data class ClipboardOutgoingFile(val bytes: ByteArray, val name: String, val mime: String) +/** A file the phone is sending to the laptop. */ +data class ClipboardOutgoingFile( + /** What to read when the laptop pulls it. Held as a URI, never as bytes: + * the file is streamed in ranges on demand, so its size no longer has to + * fit in the heap. */ + val uri: Uri, + val name: String, + val mime: String, + /** Best known size, or -1 when the provider will not say. Advisory only — + * the open is what decides. */ + val size: Long, +) /** - * Reads an arbitrary clipboard / shared `content://` URI into a - * [ClipboardOutgoingFile] for phone→laptop FILE sync. Used by both the Quick - * Settings quick-send and the share-sheet target. Returns null if it isn't - * readable or exceeds the LAN size cap. + * Describes an arbitrary clipboard / shared `content://` URI for phone→laptop + * FILE sync. Used by both the Quick Settings quick-send and the share-sheet + * target. + * + * It no longer READS the file. The old version buffered the whole thing to + * compute a content hash for the token and to hand the bytes to the offer path, + * which is what made an 835 MB share allocate 876 MB against a 256 MB heap + * growth limit and throw `OutOfMemoryError` — an `Error`, so the surrounding + * `catch (Exception)` missed it and the process died, taking the BLE/LAN + * service with it. The 64 MB cap existed to keep that from happening. + * + * Now the laptop pulls the file through the ranged-read protocol + * ([com.vortex.a3.core.fs.FsServer]), one bounded chunk at a time, so nothing + * on either side holds more than a chunk and the cap is gone. */ object ClipboardFileReader { - /** Mirrors the Rust `clipboard_mirror::MAX_FILE_BYTES`. */ - const val MAX_FILE_BYTES = 64L * 1024 * 1024 private const val TAG = "ClipboardFileOut" - /** Outcome of a read, so the caller can tell the user something true + /** Outcome of a describe, so the caller can tell the user something true * instead of a generic "couldn't read the shared file". */ sealed class Outcome { data class Ok(val file: ClipboardOutgoingFile) : Outcome() - /** Bigger than [MAX_FILE_BYTES]; [bytes] is the best size we know. */ - data class TooLarge(val bytes: Long) : Outcome() - /** Unreadable, empty, or it would not fit in memory. */ + /** Unreadable or empty. There is no longer a "too large". */ data class Unreadable(val why: String) : Outcome() } /** - * Read [uri] into memory, or explain why not. + * Describe [uri] without reading it, or explain why it cannot be sent. * - * **The size is checked BEFORE the bytes are read.** It used to be checked - * after `readBytes()`, which made the guard unreachable for exactly the - * files it existed to stop: an 835 MB share allocated 876 MB against a - * 256 MB heap growth limit and threw `OutOfMemoryError` at the read. That - * is an `Error`, not an `Exception`, so the old `catch (e: Exception)` did - * not catch it — it escaped `ShareReceiverActivity.onCreate` and killed the - * whole process, taking the BLE/LAN service down with it. The user saw a - * crash and no explanation. - * - * `OutOfMemoryError` is still caught below, because a pre-check can only - * use the size the provider *reports*: `OpenableColumns.SIZE` is absent or - * -1 for plenty of providers, and a wrong one must not be able to kill the - * app either. + * The only I/O here is opening the stream briefly to prove it is readable. + * Discovering at pull time that a file was never readable would mean the + * user sees a share succeed and a transfer fail minutes later, so the cheap + * check is worth one open. */ fun read(context: Context, uri: Uri): Outcome { val cr = context.contentResolver val mime = cr.getType(uri) ?: "application/octet-stream" val name = displayName(context, uri) ?: "file" - - // Pre-flight: refuse before allocating anything. A negative or absent - // size means "provider doesn't know" — fall through and let the - // bounded read below decide. - val reported = reportedSize(context, uri) - if (reported > MAX_FILE_BYTES) { - Log.i(TAG, "file too large ($reported bytes > $MAX_FILE_BYTES) — not sent") - return Outcome.TooLarge(reported) - } + val size = reportedSize(context, uri) return try { - // Bounded even when the provider lied about (or omitted) the size: - // read at most the cap + 1 byte, so an oversized stream is detected - // without ever buffering it whole. - val bytes = cr.openInputStream(uri)?.use { it.readAtMost(MAX_FILE_BYTES + 1) } - when { - bytes == null -> Outcome.Unreadable("no input stream") - bytes.isEmpty() -> Outcome.Unreadable("empty file") - bytes.size > MAX_FILE_BYTES -> { - Log.i(TAG, "file exceeds cap (provider reported $reported) — not sent") - Outcome.TooLarge(maxOf(reported, bytes.size.toLong())) - } - else -> Outcome.Ok(ClipboardOutgoingFile(bytes, name, mime)) - } - } catch (e: OutOfMemoryError) { - // Reachable only when the reported size was wrong/absent. Catching - // an Error is deliberate and narrow: the alternative is the process - // dying and every Vortex feature with it. - Log.w(TAG, "file read ran out of memory: ${e.message}") - Outcome.Unreadable("too large to buffer") + val readable = cr.openFileDescriptor(uri, "r")?.use { pfd -> + // A zero-length file is not worth a transfer, and an empty + // provider read is the usual symptom of a URI we cannot really + // open. `statSize` is -1 when the provider will not say, which + // is not itself a failure. + val st = try { pfd.statSize } catch (_: Exception) { -1L } + st != 0L + } ?: return Outcome.Unreadable("no file descriptor") + if (!readable) return Outcome.Unreadable("empty file") + Outcome.Ok(ClipboardOutgoingFile(uri, name, mime, size)) } catch (e: Exception) { - Log.w(TAG, "file read failed: ${e.message}") + Log.w(TAG, "file not readable: ${e.message}") Outcome.Unreadable(e.message ?: "read failed") } } @@ -98,22 +88,6 @@ object ClipboardFileReader { -1L } - /** Read at most [limit] bytes. Unlike `readBytes()` this never allocates - * more than the caller is prepared to accept. */ - private fun java.io.InputStream.readAtMost(limit: Long): ByteArray { - val cap = limit.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - val out = java.io.ByteArrayOutputStream(minOf(cap, 64 * 1024)) - val buf = ByteArray(64 * 1024) - var total = 0 - while (total < cap) { - val n = read(buf, 0, minOf(buf.size, cap - total)) - if (n <= 0) break - out.write(buf, 0, n) - total += n - } - return out.toByteArray() - } - private fun displayName(context: Context, uri: Uri): String? = try { context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) ?.use { c -> diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt index cc363b4..9f42009 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsHandles.kt @@ -14,7 +14,14 @@ import android.util.Log */ class FsHandles { - private class Handle(val pfd: ParcelFileDescriptor, val size: Long, var lastUsed: Long) + private class Handle( + val pfd: ParcelFileDescriptor, + val size: Long, + var lastUsed: Long, + /** Set when this handle is reading a share-sheet file: its CLOSE is + * what tells the sender the laptop actually has the bytes. */ + val shareToken: String?, + ) private val lock = Any() private var next: Long = 0 @@ -28,7 +35,7 @@ class FsHandles { * the process's descriptors, so the table is bounded and idle entries are * pruned first. */ - fun insert(pfd: ParcelFileDescriptor, size: Long): Long? = synchronized(lock) { + fun insert(pfd: ParcelFileDescriptor, size: Long, shareToken: String? = null): Long? = synchronized(lock) { prune() if (open.size >= MAX_HANDLES) { Log.w(TAG, "fs: handle table full ($MAX_HANDLES); refusing OPEN") @@ -39,7 +46,7 @@ class FsHandles { next += 1 if (next <= 0) next = 1 val id = next - open[id] = Handle(pfd, size, System.nanoTime()) + open[id] = Handle(pfd, size, System.nanoTime(), shareToken) id } @@ -51,10 +58,18 @@ class FsHandles { Pair(h.pfd, h.size) } - /** Close and forget a handle. Silent for an unknown id — see [FsServer]. */ - fun remove(id: Long) = synchronized(lock) { - open.remove(id)?.let { close(it) } - Unit + /** + * Close and forget a handle, returning its share token if it had one. + * + * Only an EXPLICIT close reports a token — [prune] deliberately does not. + * An expired handle means the reader went away mid-file, which is the + * opposite of delivery, and counting it would tell the user a transfer + * succeeded when it was abandoned. + */ + fun remove(id: Long): String? = synchronized(lock) { + val h = open.remove(id) ?: return null + close(h) + h.shareToken } /** Close everything. Called when the link drops: handles cannot outlive diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt index b686e7e..7ec5463 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsProto.kt @@ -13,7 +13,7 @@ import org.json.JSONObject * READ(handle, offset, len) -> bytes * ``` * - * Every transfer today buffers a whole file in memory ([ClipboardBlobStore] + * Every transfer today buffers a whole file in memory (the old blob store * holds bytes keyed by a content token), which is what made an 835 MB share an * `OutOfMemoryError` and why a 64 MB cap exists. Ranged reads remove the cap as * a side effect rather than as a separate change. diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt index 6f933ac..60fe8b4 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsRoots.kt @@ -66,6 +66,19 @@ class FsRoots(private val context: Context) { sealed class Target { data class Doc(val uri: Uri) : Target() data class Local(val file: File) : Target() + /** + * A share-sheet file. Kept apart from [Doc] because it is usually NOT + * a SAF document URI at all — a share commonly hands over a MediaStore + * or FileProvider URI, which has no document id and cannot be asked + * for children. Only opening and stat'ing it makes sense. + */ + data class Shared( + val uri: Uri, + val name: String, + val size: Long, + /** Echoed back on CLOSE so the sender learns the file landed. */ + val token: String, + ) : Target() } /** @@ -151,6 +164,15 @@ class FsRoots(private val context: Context) { // content URI. Dispatching on the first character rather than trying // both keeps the two gates separate, so neither can be reached by a // path shaped for the other. + // A file the user shared through the share sheet, pulled by token. Not + // a browse: it is not under any root and never will be, because the + // authorisation is the share itself rather than a folder grant. + if (path.startsWith(SHARE_PREFIX)) { + if (forWrite) return Result.Err(FsCode.ROFS) + val token = path.removePrefix(SHARE_PREFIX) + val g = ShareGrants.get(token) ?: return Result.Err(FsCode.NOENT) + return Result.Ok(Target.Shared(g.uri, g.name, g.size, token)) + } if (path.startsWith("/")) return resolveLocal(path, forWrite) val uri = try { @@ -289,5 +311,8 @@ class FsRoots(private val context: Context) { companion object { private const val TAG = "VortexFs" + + /** Addresses a share-sheet file rather than a browsable path. */ + const val SHARE_PREFIX = "share:" } } diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt index ac670a6..d6d64c2 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt @@ -28,6 +28,17 @@ class FsServer( private val handles: FsHandles, ) { + /** + * Called with a share token when the peer CLOSEs a share-sheet file it was + * reading — the one unambiguous "the laptop has the bytes" moment on this + * device, and what advances the share queue's progress. + * + * The old transfer got this from having just written the whole file onto + * the socket. A ranged pull has no such moment, so CLOSE stands in for it. + */ + @Volatile + var onShareDelivered: (token: String) -> Unit = {} + /** What one served op produced. The caller turns this into frames — this * class knows nothing about framing or transports. */ sealed class Served { @@ -56,7 +67,14 @@ class FsServer( FsOp.READ -> doRead(ReadReq.from(json())) FsOp.CLOSE -> { val r = CloseReq.from(json()) - handles.remove(r.handle) + handles.remove(r.handle)?.let { token -> + ShareGrants.revoke(token) + try { + onShareDelivered(token) + } catch (e: Exception) { + Log.w(TAG, "onShareDelivered threw: ${e.message}") + } + } // Not BADF for an unknown handle: we expire handles // ourselves, so "already gone" is the state the caller // asked for. @@ -129,6 +147,8 @@ class FsServer( is FsRoots.Result.Ok -> when (val t = res.target) { is FsRoots.Target.Doc -> listChildren(r.id, t.uri, r.cursor) is FsRoots.Target.Local -> listLocal(r.id, t.file, r.cursor) + // A shared file is one file, by construction. + is FsRoots.Target.Shared -> err(r.id, FsCode.INVAL, "not a directory") } } } @@ -202,6 +222,12 @@ class FsServer( return when (val res = roots.resolve(r.path, forWrite = false)) { is FsRoots.Result.Err -> err(r.id, res.code, "refused") is FsRoots.Result.Ok -> when (val t = res.target) { + is FsRoots.Target.Shared -> Served.Meta( + FsReply.Stat( + r.id, + FsEntry(name = t.name, isDir = false, size = t.size, readonly = true, path = r.path), + ), + ) is FsRoots.Target.Local -> if (!t.file.exists()) err(r.id, FsCode.NOENT, "no such file") else Served.Meta(FsReply.Stat(r.id, localEntry(t.file))) @@ -232,9 +258,34 @@ class FsServer( return when (target) { is FsRoots.Target.Local -> openLocal(r.id, target.file) is FsRoots.Target.Doc -> openDoc(r.id, target.uri) + is FsRoots.Target.Shared -> + openShared(r.id, target.uri, target.size, target.token) } } + /** + * Open a share-sheet file. Straight to the resolver: no document query + * first, because the URI may be a MediaStore or FileProvider one that + * answers none of the Document columns. + */ + private fun openShared(id: Int, uri: Uri, declaredSize: Long, token: String): Served { + val pfd = try { + context.contentResolver.openFileDescriptor(uri, "r") + } catch (e: SecurityException) { + // The one-off grant the share gave us has lapsed — Android drops it + // when the sharing task finishes. + return err(id, FsCode.ACCES, "share permission expired") + } catch (e: java.io.FileNotFoundException) { + return err(id, FsCode.NOENT, "shared file is gone") + } catch (e: Exception) { + return err(id, FsCode.IO, e.message ?: "open failed") + } ?: return err(id, FsCode.IO, "provider returned no descriptor") + // Prefer what the descriptor says over what the provider claimed at + // share time: statSize is the length we will actually be able to read. + val size = try { pfd.statSize.coerceAtLeast(0) } catch (_: Exception) { 0 } + return finishOpen(id, pfd, if (size > 0) size else declaredSize.coerceAtLeast(0), token) + } + private fun openLocal(id: Int, f: File): Served { if (f.isDirectory) return err(id, FsCode.ISDIR, "is a directory") if (!f.exists()) return err(id, FsCode.NOENT, "no such file") @@ -277,8 +328,13 @@ class FsServer( return finishOpen(id, pfd, size) } - private fun finishOpen(id: Int, pfd: ParcelFileDescriptor, size: Long): Served { - val handle = handles.insert(pfd, size) + private fun finishOpen( + id: Int, + pfd: ParcelFileDescriptor, + size: Long, + shareToken: String? = null, + ): Served { + val handle = handles.insert(pfd, size, shareToken) if (handle == null) { // Close what we just opened: refusing the request must not also // leak the descriptor that made us refuse it. diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt b/android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt new file mode 100644 index 0000000..8668e08 --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/core/fs/ShareGrants.kt @@ -0,0 +1,78 @@ +package com.vortex.a3.core.fs + +import android.net.Uri +import android.util.Log + +/** + * Files the user has explicitly shared with Vortex, addressable by an opaque + * token so the laptop can pull them through the ranged-read protocol. + * + * This exists because a shared file is authorised differently from a browsed + * one. Browsing is gated by [FsRoots]: a SAF tree the user picked, or all-files + * access. A share-sheet file is neither — it arrives as a one-off URI grant to + * this process, and the *act of sharing* is the authorisation. So it gets its + * own, deliberately narrow gate: exactly the files the user sent, addressed by + * a token they cannot be guessed from, and nothing else. + * + * Tokens are random rather than a content hash. The old store keyed blobs by + * sha256 of the bytes, which meant hashing — and therefore reading — the whole + * file before it could be offered. That is the buffering this change removes, + * so the token cannot depend on the content. + */ +object ShareGrants { + + /** One shared file: what to read, and what to call it. */ + data class Grant(val uri: Uri, val name: String, val mime: String, val size: Long) + + /** + * How many shares stay addressable. Matches the old blob store's ceiling, + * and for the same reason: the laptop pulls one file at a time, so a grant + * evicted before its turn is a file that silently never arrives. Callers + * cap a batch at this (see ShareReceiverActivity.MAX_SHARE_FILES). + * + * Unlike the old store, holding this many costs a URI each rather than a + * file each — 32 entries used to mean up to 2 GB of heap. + */ + const val MAX_ENTRIES = 32 + + private const val TAG = "VortexFs" + + // Insertion-ordered so eviction drops the oldest first. + private val grants = LinkedHashMap() + + /** Register [uri] as shared; returns the token the laptop pulls it by. */ + @Synchronized + fun grant(uri: Uri, name: String, mime: String, size: Long): String { + val token = randomToken() + grants[token] = Grant(uri, name, mime, size) + while (grants.size > MAX_ENTRIES) { + val oldest = grants.keys.iterator().next() + grants.remove(oldest) + } + return token + } + + /** The grant for [token], or null when unknown or evicted. */ + @Synchronized + fun get(token: String): Grant? = if (token.isEmpty()) null else grants[token] + + /** Forget a grant once the laptop has the file. */ + @Synchronized + fun revoke(token: String) { + if (grants.remove(token) != null) Log.i(TAG, "share grant spent") + } + + @Synchronized + fun size(): Int = grants.size + + /** + * 16 bytes of randomness, hex. Unguessable on purpose: this token is the + * only thing standing between a paired laptop and a file the user shared + * with it, and the peer supplies it verbatim. + */ + private fun randomToken(): String { + val b = ByteArray(16) + java.security.SecureRandom().nextBytes(b) + return b.joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt index d04af56..b8279d0 100644 --- a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt @@ -849,50 +849,6 @@ class LanServer( } continue } - // Instant-share file pull: serve the stashed blob - // reliably over TCP as CLIPBOARD_FILE chunks. - if (key == "clipboard_file") { - val token = req.optString(key, "") - // A stashed blob, or — when the token is a - // document URI — a file the laptop picked - // out of a browsed folder. One pull path - // for both: the transfer, the chunking and - // the laptop's save are already proven, and - // a browsed file is not a different kind of - // file just because it was asked for. - val blob = com.vortex.a3.core.clipboard.ClipboardBlobStore - .getByToken(token) - ?: com.vortex.a3.core.files.PhoneFiles - .read(context, token)?.bytes - if (blob == null) { - Log.i(TAG, "bulk-sync: clipboard_file token=$token not found") - status.put(key, "nomatch") - } else { - // Extends the hot window: the laptop - // comes back for the NEXT queued file - // in a fresh round moments from now. - keepLanHot() - sendChunked(FrameType.CLIPBOARD_FILE, blob) - Log.i(TAG, "bulk-sync: clipboard_file sent (${blob.size} bytes)") - status.put(key, "sent") - try { onFileServed(token) } catch (e: Exception) { - Log.w(TAG, "onFileServed listener threw: ${e.message}") - } - } - continue - } - // Folder listing: the value is the document - // URI to look inside, or "" for the roots the - // user has granted. - if (key == "browse") { - val at = req.optString(key, "") - val json = com.vortex.a3.core.files.PhoneFiles.list(context, at) - keepLanHot() - sendChunked(FrameType.PHONE_FILES, json) - Log.i(TAG, "bulk-sync: listing sent (${json.size} bytes)") - status.put(key, "sent") - continue - } // Watermark datasets: the value is "everything // up to " rather than a content hash. val historyFrameType = when (key) { diff --git a/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt index c89c26c..b1684b7 100644 --- a/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt +++ b/android/app/src/main/java/com/vortex/a3/service/ShareQueue.kt @@ -80,11 +80,6 @@ class ShareQueue( return } } - is ClipboardFileReader.Outcome.TooLarge -> { - failed++ - Log.w(TAG, "skipping $uri: over the size cap (${outcome.bytes} bytes)") - showProgress() - } is ClipboardFileReader.Outcome.Unreadable -> { failed++ Log.w(TAG, "skipping $uri: ${outcome.why}") @@ -167,7 +162,7 @@ class ShareQueue( /** * Files in flight at once. * - * Must stay well under `ClipboardBlobStore.MAX_ENTRIES` so a queued + * Must stay well under `ShareGrants.MAX_ENTRIES` so a queued * file's bytes cannot be evicted before the laptop collects them, and * small enough that the OFFER burst does not overrun the BLE notify * path (the same reason the offer sender paces itself). diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt index 0a9dc9a..65cdffa 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexService.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexService.kt @@ -405,7 +405,7 @@ class VortexService : Service() { // cannot be served anyway, so more slots than the store holds // would only queue offers that are certain to fail. extraBufferCapacity = - com.vortex.a3.core.clipboard.ClipboardBlobStore.MAX_ENTRIES, + com.vortex.a3.core.fs.ShareGrants.MAX_ENTRIES, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND, ) diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt index 01c8ae0..c32c5b1 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt @@ -84,15 +84,22 @@ internal fun VortexStack.startClipboardOutbound() { scope.launch { VortexService.clipboardFileBus.collect { file -> if (!com.vortex.a3.core.clipboard.ClipboardSyncSetting.isEnabled()) return@collect - if (file.bytes.isEmpty()) return@collect - val token = com.vortex.a3.core.clipboard.ClipboardBlobStore.stash(file.bytes) + // A grant, not the bytes: the laptop pulls the file in ranges + // through the filesystem protocol, so nothing is buffered here and + // the old 64 MB cap is gone with it. + val token = com.vortex.a3.core.fs.ShareGrants.grant( + file.uri, + file.name, + file.mime, + file.size, + ) val o = org.json.JSONObject() o.put("token", token) - o.put("bytes", file.bytes.size) + o.put("bytes", file.size) o.put("name", file.name) o.put("mime", file.mime) val offer = o.toString().toByteArray(Charsets.UTF_8) - Log.i(VortexStack.TAG, "clipboard file offered to laptop ('${file.name}', ${file.bytes.size} bytes, token=$token)") + Log.i(VortexStack.TAG, "clipboard file offered to laptop ('${file.name}', ${file.size} bytes, token=$token)") // Tracked until the laptop has actually FETCHED the bytes: the OFFER // is a fire-and-forget BLE notify that goes nowhere on a dead link, // and even a delivered one can sit unfetched. Retries, warms the LAN @@ -100,7 +107,7 @@ internal fun VortexStack.startClipboardOutbound() { offerFileToLaptop(token, file.name, offer) // Big file → bring up Wi-Fi Direct for a high-speed direct pull. Small // files stay on the router path (the ~6s Wi-Fi switch isn't worth it). - if (file.bytes.size >= 4 * 1024 * 1024) maybeStartWifiDirect() + if (file.size >= 4 * 1024 * 1024) maybeStartWifiDirect() } } } diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt index 7c5f0fc..d3e6190 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt @@ -24,6 +24,10 @@ internal fun VortexStack.startFsServer() { val handles = FsHandles() val server = FsServer(ctx, roots, handles) fsHandles = handles + // A share-sheet file the laptop just finished reading. Same completion + // signal the old bulk-sync path got from writing the whole blob onto the + // socket: advances the batch's progress and releases the next queued file. + server.onShareDelivered = { token -> noteFileServed(token) } // Wi-Fi path. The laptop prefers it and falls back to BLE, so BOTH // transports serve from this one server and one handle table: a handle is diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt index 67e6c90..2867b6c 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackOfferRetry.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.withTimeoutOrNull * So every offer is tracked until the laptop has actually FETCHED it: retried * while it can't be delivered, watched for a pull once it has been, and * surfaced as a toast when it ends up nowhere. The stashed blob is untouched - * either way — [com.vortex.a3.core.clipboard.ClipboardBlobStore] keeps it + * either way — [com.vortex.a3.core.fs.ShareGrants] keeps it * addressable, so a later re-share of the same file is free. */ diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 80fc4c8..2b4d2a6 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -247,6 +247,17 @@ This is where these features usually fail, and it is all daemon-side: OPEN over Wi-Fi must still be readable by a READ that fell back to BLE. 2. **Rework large-file transfer onto ranged reads.** Removes `MAX_FILE_BYTES` and the buffer-the-whole-file crash. Ships value before any mount exists. + **Done.** A share now registers a *grant* (a URI plus a random token) instead + of reading the file, and the laptop pulls it through `FS_OPEN`/`FS_READ`/ + `FS_CLOSE` straight to disk. `MAX_FILE_BYTES` is deleted on both sides. + Verified on the device with a 151 MB APK — 2.4x the old cap, so previously + refused outright: byte-identical in 73 s, and the phone's Java heap stayed at + 16-23 MB throughout, where the old path would have had to hold all 151 MB. + + A share-sheet file is authorised differently from a browsed one, so it gets + its own gate ([`ShareGrants`]): the act of sharing IS the authorisation, + scoped to that one file, addressed by an unguessable token, revoked when the + laptop closes it. It is not, and cannot become, a root. 3. **Daemon cache layer** — metadata, readahead, content budget. 4. **WebDAV loopback gateway**, both OSes. 5. **`FS_WRITE` / `FS_SETMETA`** for real, once read-only is solid. diff --git a/linux/daemon/src/core/clipboard_mirror.rs b/linux/daemon/src/core/clipboard_mirror.rs index ef30895..9a0155d 100644 --- a/linux/daemon/src/core/clipboard_mirror.rs +++ b/linux/daemon/src/core/clipboard_mirror.rs @@ -17,11 +17,6 @@ pub const MAX_CLIPBOARD_TEXT_CHARS: usize = 65_536; /// frame stays under the BLE notify MTU (same reason images are chunked). pub const MAX_SINGLE_FRAME_TEXT_BYTES: usize = 400; -/// Max bytes for a phone→laptop FILE pulled over LAN (reliable TCP). Files -/// ride the same offer+pull path as images but can be much larger; this bounds -/// memory and transfer time. ~64 MiB covers documents, photos, short clips. -pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; - /// "Blob available, pull it over LAN" signal (phone→laptop). The laptop fetches /// the bytes by `token` via the next bulk-sync (served as CLIPBOARD_IMAGE /// chunks). When `name`/`mime` are EMPTY it's a clipboard IMAGE (PNG); when set diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 2e59e8e..2d0bf61 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -604,7 +604,7 @@ fn expand_home(raw: &str, home: &std::path::Path) -> Option { /// A non-clobbering path in `dir` for `name`: if it exists, append " (1)", /// " (2)", … before the extension (same as a browser download). -fn unique_path(dir: &std::path::Path, name: &str) -> PathBuf { +pub(crate) fn unique_path(dir: &std::path::Path, name: &str) -> PathBuf { let first = dir.join(name); if !first.exists() { return first; @@ -627,75 +627,6 @@ fn unique_path(dir: &std::path::Path, name: &str) -> PathBuf { first // give up after 10k — overwrite } -/// Apply a fully-received FILE shared from the phone (instant-share style, NOT the -/// clipboard): save it under its original name in the folder [`receive_root`] -/// picks. Bytes are never logged; only size + name. -/// Returns the saved path on success (for the transfer panel), `None` on error. -/// -/// `subdir` is the one folder level a capture adds below that root -/// (`Screenshots` / `Photos`, from [`Offer::subdir`] — a fixed table, never -/// the wire value), and it is also what marks the file as a capture: with it -/// set the root is the picture folder, without it the download folder. -pub(crate) async fn apply_synced_file( - _app: &AppHandle, - name: &str, - _mime: &str, - bytes: Vec, - subdir: Option<&str>, -) -> Option { - // Sanitise to a single path component (no traversal / separators). - let safe = std::path::Path::new(name) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "vortex-file".to_string()); - let Some(dir) = receive_root(subdir).map(|d| receive_dir(&d, subdir)) else { - tracing::warn!("received file: no HOME — dropped"); - return None; - }; - let size = bytes.len(); - let safe2 = safe.clone(); - let saved = tokio::task::spawn_blocking(move || -> std::io::Result { - std::fs::create_dir_all(&dir)?; - let path = unique_path(&dir, &safe2); - // Write to a temporary name, then rename into place. - // - // Writing straight to the final name means a failure part-way — the - // disk filling up is the realistic one — leaves a TRUNCATED file - // sitting under the name the user expects, looking complete. A rename - // within the same directory is atomic, so the file either appears whole - // or does not appear at all. - let tmp = path.with_extension(format!( - "{}.vortex-part", - path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default() - )); - if let Err(e) = std::fs::write(&tmp, &bytes) { - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - if let Err(e) = std::fs::rename(&tmp, &path) { - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - Ok(path) - }) - .await; - let path = match saved { - Ok(Ok(p)) => p, - Ok(Err(e)) => { - tracing::warn!("received file write failed: {e}"); - return None; - } - Err(e) => { - tracing::warn!("received file task join: {e}"); - return None; - } - }; - // No per-file toast: the ongoing transfer notification (see `transfers`) - // is the single in-place progress indicator. - tracing::info!(bytes = size, name = %safe, "file received from phone → {}", path.display()); - Some(path) -} /// Where a received file goes: the download folder itself, or one named /// level below it. Kept separate from the name sanitising above so it can be @@ -757,6 +688,8 @@ async fn flush_file_batch(batch: Vec) { // inside one debounce window. .filter(|o| { !queued.contains(&o.token) + // Being streamed right now: dequeued, not yet pulled. + && !crate::fs_pull::is_in_flight(&o.token) && !pulled_recently(&o.token) && seen.insert(o.token.clone()) }) @@ -797,10 +730,11 @@ async fn flush_file_batch(batch: Vec) { } } crate::lan::note_queue_progress(); - tracing::info!(count, "phone file offer(s) accepted → LAN pull nudged"); - if let Some(nudge) = crate::SYNC_NUDGE.get() { - nudge.notify_one(); - } + tracing::info!(count, "phone file offer(s) accepted → streaming pull"); + // The ranged-read puller, not the heartbeat: it streams each file straight + // to disk instead of reassembling it in memory, and drains the batch on its + // own rather than one file per heartbeat round. + crate::fs_pull::nudge(); } /// BLE image-offer consumer: clipboard images stash a pull token immediately; diff --git a/linux/ui-tauri/src-tauri/src/fs_pull.rs b/linux/ui-tauri/src-tauri/src/fs_pull.rs new file mode 100644 index 0000000..ebe71a8 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/fs_pull.rs @@ -0,0 +1,147 @@ +//! Pull the files the phone has offered, through the ranged-read protocol. +//! +//! Replaces the bulk-sync `clipboard_file` path, which moved a file by holding +//! all of it in memory on BOTH sides: the phone read it into a `ByteArray` to +//! hash for its token, and the laptop reassembled the chunks into a `Vec` +//! before writing. That is what made an 835 MB share an `OutOfMemoryError` on +//! the phone and why a 64 MB cap existed at all. +//! +//! Here the file is streamed: [`crate::fs_link::read_all`] issues bounded +//! ranged reads and each one is written straight to disk, so peak memory is one +//! chunk regardless of size. It also inherits the transport choice — Wi-Fi when +//! reachable, Bluetooth otherwise — for free, because it is the same client. + +use std::io::{Seek, SeekFrom, Write}; + +/// Wake the puller. Called when an offer is accepted, and again after each +/// file, so a batch drains without waiting on a heartbeat tick. +pub(crate) fn nudge() { + if let Some(n) = NUDGE.get() { + n.notify_one(); + } +} + +static NUDGE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Start the drain loop. One at a time on purpose: the phone serves from a +/// single link, and several concurrent pulls would interleave ranged reads over +/// one socket without arriving any sooner. +pub(crate) fn spawn() { + let notify = std::sync::Arc::new(tokio::sync::Notify::new()); + let _ = NUDGE.set(notify.clone()); + tokio::spawn(async move { + loop { + notify.notified().await; + while let Some((token, name, _mime, id)) = pop_front() { + pull_one(&token, &name, id).await; + } + } + }); +} + +/// Tokens currently being streamed. +/// +/// The phone re-announces an offer it has not seen fetched, so the offer +/// handler must be able to tell "not started" from "in progress". Its existing +/// guard is a 60 s TTL on *completed* pulls, which cannot cover this: a pull is +/// no longer in the queue but not yet complete, and a big file takes longer +/// than any fixed window — the first 151 MB test ran 77 s and duplicated +/// itself. Membership here lasts exactly as long as the transfer, whatever +/// that turns out to be. +static IN_FLIGHT: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// True while `token` is being streamed, so a re-announce is ignored rather +/// than queued a second time. +pub(crate) fn is_in_flight(token: &str) -> bool { + IN_FLIGHT + .lock() + .map(|g| g.iter().any(|t| t == token)) + .unwrap_or(false) +} + +fn mark_in_flight(token: &str) { + if let Ok(mut g) = IN_FLIGHT.lock() { + g.push(token.to_string()); + } +} + +fn clear_in_flight(token: &str) { + if let Ok(mut g) = IN_FLIGHT.lock() { + g.retain(|t| t != token); + } +} + +fn pop_front() -> Option<(String, String, String, u64)> { + crate::PENDING_FILE_OFFERS + .get() + .and_then(|m| m.lock().ok().and_then(|mut g| g.pop_front())) +} + +async fn pull_one(token: &str, name: &str, id: u64) { + mark_in_flight(token); + // Sanitise to a single path component. The name comes from the phone, and + // a `../` in it would otherwise choose where on this laptop the file lands. + let safe = std::path::Path::new(name) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "vortex-file".to_string()); + let Some(dir) = crate::clipboard_sync::downloads_dir() else { + tracing::warn!("file pull: no HOME — dropped"); + crate::transfers::fail(id); + return; + }; + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::warn!("file pull: cannot create {}: {e}", dir.display()); + crate::transfers::fail(id); + return; + } + let path = crate::clipboard_sync::unique_path(&dir, &safe); + let mut file = match std::fs::File::create(&path) { + Ok(f) => f, + Err(e) => { + tracing::warn!("file pull: cannot create {}: {e}", path.display()); + crate::transfers::fail(id); + return; + } + }; + + let started = std::time::Instant::now(); + // Seek to the offset we are handed rather than appending: `read_all` is + // sequential today, but its sink contract carries an offset so a future + // pipelined reader cannot silently write bytes out of order. + let sink = |offset: u64, bytes: &[u8]| -> std::io::Result<()> { + file.seek(SeekFrom::Start(offset))?; + file.write_all(bytes) + }; + let addr = format!("share:{token}"); + match crate::fs_link::read_all(&addr, sink).await { + Ok(n) => { + // Mark it pulled BEFORE completing, so a re-announce racing this + // moment cannot queue the same file again. + crate::clipboard_sync::note_pulled(token); + crate::transfers::complete(id); + let secs = started.elapsed().as_secs_f64(); + tracing::info!( + bytes = n, + name = %safe, + "file received from phone in {secs:.1}s → {}", + path.display() + ); + } + Err(code) => { + // Leave no half-written file behind: a truncated download in the + // user's folder looks like a real one and is worse than nothing. + drop(file); + let _ = std::fs::remove_file(&path); + tracing::warn!(name = %safe, "file pull failed (errno {code})"); + crate::transfers::fail(id); + } + } + // Cleared only after `note_pulled` has run on the success path, so there is + // no instant where the token is neither in flight nor recently pulled — a + // re-announce landing in that gap would queue the file all over again. On + // failure it is deliberately NOT noted, so a later re-announce can retry. + clear_in_flight(token); + crate::lan::note_queue_progress(); +} diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index aa75c85..363a488 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -708,19 +708,6 @@ pub(crate) async fn try_lan_reconnect( if let Some(token) = &requested_img_token { bulk_obj["clipboard_image"] = serde_json::Value::String(token.clone()); } - // Instant-share file pull: request the FRONT queued file this round (the rest - // follow on subsequent nudged rounds). - let requested_file_token: Option = crate::PENDING_FILE_OFFERS - .get() - .and_then(|m| m.lock().ok().and_then(|g| g.front().map(|(t, ..)| t.clone()))); - if let Some(token) = &requested_file_token { - bulk_obj["clipboard_file"] = serde_json::Value::String(token.clone()); - } - // A folder the UI is waiting to see. Rides the round that is happening - // anyway rather than opening a session of its own. - if let Some(at) = crate::phone_files::browse_request() { - bulk_obj["browse"] = serde_json::Value::String(at); - } let bulk_request = bulk_obj.to_string(); match run_lan_reconnect( socket_addr, @@ -811,66 +798,6 @@ pub(crate) async fn try_lan_reconnect( } } } - vortex_l3_daemon::core::ble::frame::ty::CLIPBOARD_FILE => { - // Instant-share file pull → save to Downloads. Pop the - // FRONT queued offer for its name/mime/id; if more - // remain, nudge so the next one pulls immediately. - let meta = crate::PENDING_FILE_OFFERS - .get() - .and_then(|m| m.lock().ok().and_then(|mut g| g.pop_front())); - if let Some((token, name, mime, id, kind)) = meta { - // Before anything else: this token's bytes have - // arrived, so a re-announce must not re-queue it. - crate::clipboard_sync::note_pulled(&token); - note_queue_progress(); - let offer = crate::clipboard_sync::Offer { - kind, - ..Default::default() - }; - match crate::clipboard_sync::apply_synced_file( - app, - &name, - &mime, - json.clone(), - offer.subdir(), - ) - .await - { - Some(path) => { - // The pill outlives the batch by a few - // seconds; tell it where to go when - // someone clicks it. - crate::transfers::note_saved(&path); - crate::transfers::complete(id); - // Remember where a CAPTURE landed, so - // the phone deleting its original can - // be mirrored. Shares are not tracked: - // the phone has no original to lose. - if offer.is_capture() { - crate::capture_ledger::record(&token, &path); - } - // A capture arrives unannounced, so it - // gets a notification the user can act - // on; a share the user just made does - // not (the pill already says where it - // went, and ten files would be ten - // notifications). - if offer.is_capture() - || offer.kind == crate::phone_files::FETCHED_KIND - { - crate::file_consent::notify_received(path, &offer.kind) - .await; - } - } - None => crate::transfers::fail(id), - } - } - if files_queued() { - if let Some(nudge) = crate::SYNC_NUDGE.get() { - nudge.notify_one(); - } - } - } other => tracing::warn!( "bulk-sync delivered unknown dataset 0x{other:02x}; ignoring" ), @@ -891,49 +818,6 @@ pub(crate) async fn try_lan_reconnect( } } } - // We asked for a file and the phone said it couldn't serve it — - // its blob store keeps only the last 32, so a token can be - // evicted before we get to it. Nothing will ever arrive for that - // entry: drop it, fail its pill, and move to the next. Left - // queued it would be re-requested on every round for the rest of - // the session, blocking every file behind it (and, on the - // Wi-Fi Direct path, never letting us restore Wi-Fi). - if let Some(req) = &requested_file_token { - if outcome - .bulk_status - .as_ref() - .is_some_and(|s| s.unservable("clipboard_file")) - { - // Pop only if the front is still the entry we asked - // about, so a batch accepted mid-round is never dropped. - let dead = crate::PENDING_FILE_OFFERS.get().and_then(|m| { - m.lock().ok().and_then(|mut g| { - let front_matches = - g.front().is_some_and(|(t, ..)| t == req); - if front_matches { g.pop_front() } else { None } - }) - }); - if let Some((_, name, _, id, _)) = dead { - note_queue_progress(); - crate::transfers::fail(id); - tracing::warn!( - name = %name, - status = outcome - .bulk_status - .as_ref() - .and_then(|s| s.get("clipboard_file")) - .unwrap_or("?"), - "phone can no longer serve this file (token evicted?); \ - dropping it from the pull queue" - ); - if files_queued() { - if let Some(nudge) = crate::SYNC_NUDGE.get() { - nudge.notify_one(); - } - } - } - } - } // Wi-Fi Direct: once every queued file is pulled over the group // link, hop back to the normal Wi-Fi; otherwise pull the next now. if wd_active() { diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 8b2ad21..3ae4802 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -84,6 +84,7 @@ mod share; mod file_consent; mod fs_cli; mod fs_lan; +mod fs_pull; mod contacts; mod desktop_apps; mod diagnostics; diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index 8bf31db..a09b996 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -512,6 +512,8 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { // Wi-Fi is preferred for the same frames; this hands it the // credentials a TCP+IK session needs (design doc §6). crate::fs_lan::init(identity.clone(), peer_store.clone()); + // Drains accepted phone file offers by streaming each one to disk. + crate::fs_pull::spawn(); let ble_raw_tx = crate::peer_handoff::spawn_dispatcher( app.clone(), peer_store.clone(), From 12f71a5197e9c9076b8ba29c128433d31d90696f Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 19:01:46 +0200 Subject: [PATCH 43/71] feat(fs): browse the laptop's files from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other direction, and it needed no new frames: the protocol is symmetric, so the phone sends the same ops it already answers and reads the same replies. `FsClient` is the consumer half — pipelined and id-correlated, because a browse issues listings while a download is still running and replies may arrive in any order. `download` streams in ranges straight to a file, so pulling a 4 GB video costs one chunk of memory, the same property that removed the size cap in the other direction. `LaptopFilesScreen` walks folders, shows sizes, and saves into Downloads without overwriting (" (n)" like a browser). Three details worth naming: * The screen never CONSTRUCTS a path. It sends back the opaque `path` of an entry it was given, which is what lets the same code work whether the far side is a real filesystem or, as on this phone, SAF document URIs. * A dropped link fails every waiter immediately instead of leaving the UI parked for the full 20 s timeout on each — the answer is already known. * Errors are errno-shaped all the way to the toast, so "not permitted" and "the laptop did not answer" say different things. They send the user to completely different places, and a generic failure message would send them nowhere. `sealAndNotify` grew a `sub` parameter. Every other frame type is self-describing and leaves it 0; FS_REQ is the exception because its op lives there, so a request sent without it is unreadable. Runs over BLE only. The laptop prefers Wi-Fi for this traffic in the other direction, and can because the phone LISTENS on TCP and the laptop dials it — there is no listener the other way, so a phone-initiated LAN session has nothing to connect to. Listings are small and fine; a large file runs at ~40 KiB/s. Closing that gap means giving the laptop a listener, which is the natural companion to the caching step. Builds; not yet exercised against the laptop — the phone was locked when I tried and driving the UI needs it unlocked. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/core/ble/GattServer.kt | 30 +- .../java/com/vortex/a3/core/fs/FsClient.kt | 236 +++++++++++++++ .../com/vortex/a3/service/VortexStackFs.kt | 14 + .../main/java/com/vortex/a3/ui/VortexRoot.kt | 10 +- .../com/vortex/a3/ui/screens/HomeScreen.kt | 9 + .../vortex/a3/ui/screens/LaptopFilesScreen.kt | 270 ++++++++++++++++++ docs/design/file-browsing.md | 14 + 7 files changed, 580 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt create mode 100644 android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt index 68fd829..1609db2 100644 --- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt @@ -194,6 +194,12 @@ class GattServer( @Volatile var onFsRequest: (peerStaticPub: ByteArray, op: Byte, payload: ByteArray) -> Unit = { _, _, _ -> } + /** Invoked for a reply to a request WE sent: FS_META / FS_DATA / FS_ERR. + * Replies carry no `sub` — the frame type says which of the three it is + * and the request id inside correlates it. */ + @Volatile var onFsReply: (peerStaticPub: ByteArray, frameType: Byte, payload: ByteArray) -> Unit = + { _, _, _ -> } + /** Invoked when a device (the laptop) ENABLES notifications on the * AUDIO_SIGNAL characteristic — i.e. the BLE notify path just became * deliverable. VortexStack uses this to flush any notifications that @@ -502,6 +508,10 @@ class GattServer( logTag: String, logSuccess: Boolean = false, verbose: Boolean = false, + // Almost every frame type is self-describing and leaves this 0. FS_REQ + // is the exception: its op lives here, so the payload cannot be read + // without it. + sub: Byte = 0x00, ): Boolean { val peerHex = peerStaticPub.toHex() val device = peerToDevice[peerHex] ?: run { @@ -530,7 +540,7 @@ class GattServer( Log.e(TAG, "$logTag: AEAD seal failed", e) return false } - sendAudioSignal(device, Frame(frameType, 0x00, ct.copyOf(n))) + sendAudioSignal(device, Frame(frameType, sub, ct.copyOf(n))) } if (notifyOk) { if (logSuccess) Log.i(TAG, "$logTag: notified ${device.address}") @@ -558,6 +568,11 @@ class GattServer( fun sendNotesSyncEncrypted(peerStaticPub: ByteArray, chunkPayload: ByteArray): Boolean = sealAndNotify(peerStaticPub, FrameType.NOTES_SYNC, chunkPayload, "sendNotesSync") + /** One filesystem REQUEST to the laptop (FS_REQ 0x50), for browsing the + * laptop's files from the phone. The op rides in the frame's `sub`. */ + fun sendFsRequest(peerStaticPub: ByteArray, op: Byte, payload: ByteArray): Boolean = + sealAndNotify(peerStaticPub, FrameType.FS_REQ, payload, "sendFsRequest", sub = op) + /** One filesystem reply — FS_META (0x51), FS_DATA (0x52) or FS_ERR (0x53). * Replies carry no `sub`: the frame type says which of the three this is, * and the request id inside the payload correlates it. */ @@ -1054,7 +1069,10 @@ class GattServer( frame.type != FrameType.CLIPBOARD_TEXT && frame.type != FrameType.NOTES_SYNC && frame.type != FrameType.PEER_HANDOFF && - frame.type != FrameType.FS_REQ + frame.type != FrameType.FS_REQ && + frame.type != FrameType.FS_META && + frame.type != FrameType.FS_DATA && + frame.type != FrameType.FS_ERR ) { Log.w(TAG, "AudioSignal WRITE: unexpected frame type ${frame.type}") return @@ -1184,6 +1202,14 @@ class GattServer( Log.w(TAG, "onNotesSyncReceived threw: ${e.message}") } } + FrameType.FS_META, FrameType.FS_DATA, FrameType.FS_ERR -> { + // A reply to something we asked the laptop for. + try { + onFsReply(peerPub, frame.type, jsonBytes) + } catch (e: Exception) { + Log.w(TAG, "onFsReply threw: ${e.message}") + } + } FrameType.FS_REQ -> { // Laptop→phone filesystem op. The op rides in the // frame's `sub`, so pass it on: FS_REQ is the one diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt new file mode 100644 index 0000000..862f1cd --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt @@ -0,0 +1,236 @@ +package com.vortex.a3.core.fs + +import android.util.Log +import com.vortex.a3.core.ble.FrameType +import java.io.File +import java.io.RandomAccessFile +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** + * The phone's end of the ranged-filesystem protocol as a CONSUMER: browsing the + * laptop's shared folders and pulling files off it. + * + * The mirror image of [FsServer], and the counterpart of Rust `fs_link`'s + * client half. The protocol is symmetric, so this needs no new frames — it + * sends the same FS_REQ ops the laptop sends us and reads the same replies. + * + * Requests are pipelined and correlated by id: a browse issues a listing while + * a download is still running, and replies may arrive in any order. + * + * Transport is BLE. The laptop prefers Wi-Fi for the same traffic in the other + * direction, but it can do that because the phone LISTENS on TCP and the laptop + * dials it; there is no listener the other way, so a phone-initiated LAN + * session has nothing to connect to. Listings are small and fine over BLE; + * pulling a large file this way is slow, and closing that gap means giving the + * laptop a listener. + */ +object FsClient { + + /** Failure of one request, errno-shaped so callers can say something true. + * [FsCode] values, or [TIMEOUT] when the laptop never answered. */ + class FsException(val code: Int, message: String) : Exception(message) + + /** No reply inside [REQUEST_TIMEOUT_MS]. Distinct from any server code so + * "the laptop went away" never reads as "the file is missing". */ + const val TIMEOUT = -1 + + /** + * How long a request waits. Generous because the far side may be reading a + * cold disk, but finite: a UI blocked forever on a laptop that went to + * sleep is this feature's worst outcome. + */ + private const val REQUEST_TIMEOUT_MS = 20_000L + + private const val TAG = "VortexFs" + + /** Set by VortexStack: sends one FS_REQ, returns false if there is no link. */ + @Volatile + var sender: ((op: Byte, payload: ByteArray) -> Boolean)? = null + + private val lock = Any() + private var nextId = 1 + private val inflight = HashMap>() + + private sealed class Reply { + data class Meta(val json: JSONObject) : Reply() + data class Data(val d: FsData) : Reply() + data class Err(val e: FsErr) : Reply() + } + + /** Feed a reply frame in. Wired to `GattServer.onFsReply`. */ + fun onReply(frameType: Byte, payload: ByteArray) { + val reply: Reply = when (frameType) { + FrameType.FS_DATA -> Reply.Data(decodeData(payload) ?: run { + Log.w(TAG, "fs: truncated FS_DATA") + return + }) + FrameType.FS_ERR -> { + val o = runCatching { JSONObject(String(payload, Charsets.UTF_8)) }.getOrNull() + ?: return + Reply.Err(FsErr.from(o)) + } + FrameType.FS_META -> { + val o = runCatching { JSONObject(String(payload, Charsets.UTF_8)) }.getOrNull() + ?: return + Reply.Meta(o) + } + else -> return + } + val id = when (reply) { + is Reply.Data -> reply.d.id + is Reply.Err -> reply.e.id + is Reply.Meta -> reply.json.optInt("id") + } + val waiter = synchronized(lock) { inflight.remove(id) } + if (waiter == null) { + // A reply to a request that already timed out, or an id we never + // issued. Dropped, but logged: silently ignoring these hides a + // desynchronised protocol. + Log.i(TAG, "fs: reply for unknown id=$id") + return + } + waiter.complete(reply) + } + + /** Drop every waiter — the link went, so nothing in flight can be answered. */ + fun reset() { + val waiters = synchronized(lock) { + val all = inflight.values.toList() + inflight.clear() + all + } + // Fail them rather than leaving callers parked on the timeout: the + // answer is already known. + waiters.forEach { it.complete(Reply.Err(FsErr(0, FsCode.IO, "link went away"))) } + } + + private suspend fun roundTrip(op: Byte, id: Int, payload: ByteArray): Reply { + val d = CompletableDeferred() + synchronized(lock) { inflight[id] = d } + val send = sender + if (send == null || !send(op, payload)) { + synchronized(lock) { inflight.remove(id) } + throw FsException(FsCode.IO, "no link to the laptop") + } + val reply = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { d.await() } + if (reply == null) { + synchronized(lock) { inflight.remove(id) } + throw FsException(TIMEOUT, "the laptop did not answer") + } + if (reply is Reply.Err) throw FsException(reply.e.code, reply.e.msg) + return reply + } + + private fun newId(): Int = synchronized(lock) { + // Wrapping is fine: ids only need to be unique among what is in flight, + // and 0 is reserved for "no particular request" in FS_ERR. + nextId += 1 + if (nextId <= 0) nextId = 1 + nextId + } + + /** One page of a directory. Empty path is the laptop's synthetic root. */ + suspend fun list(path: String, cursor: Int = 0): Pair, Int?> { + val id = newId() + val r = roundTrip(FsOp.LIST, id, ListReq(id, path, cursor).toJson().toString().toByteArray()) + val o = (r as? Reply.Meta)?.json ?: throw FsException(FsCode.IO, "unexpected reply") + val arr = o.optJSONArray("entries") + val out = ArrayList(arr?.length() ?: 0) + for (i in 0 until (arr?.length() ?: 0)) out.add(FsEntry.from(arr!!.getJSONObject(i))) + val next = if (o.has("cursor") && !o.isNull("cursor")) o.optInt("cursor") else null + return out to next + } + + /** Every page of a directory, followed to the end. */ + suspend fun listAll(path: String): List { + val out = ArrayList() + var cursor: Int? = 0 + var pages = 0 + while (cursor != null) { + val (page, next) = list(path, cursor) + out.addAll(page) + cursor = next + // A peer that keeps handing back a cursor without advancing would + // loop us forever; stop rather than spin. + if (++pages > 1000) break + } + return out + } + + /** + * Download [path] to [dest], streaming in ranges. + * + * Peak memory is one chunk however big the file is — the same property that + * removed the transfer size cap in the other direction. [onProgress] gets + * bytes-so-far and the total (or -1 when unknown). + */ + suspend fun download( + path: String, + dest: File, + onProgress: (done: Long, total: Long) -> Unit = { _, _ -> }, + ): File { + val openId = newId() + val opened = roundTrip( + FsOp.OPEN, + openId, + OpenReq(openId, path).toJson().toString().toByteArray(), + ) + val o = (opened as? Reply.Meta)?.json ?: throw FsException(FsCode.IO, "unexpected reply") + val handle = o.optLong("handle") + val size = o.optLong("size", -1) + + dest.parentFile?.mkdirs() + var offset = 0L + try { + RandomAccessFile(dest, "rw").use { out -> + out.setLength(0) + while (true) { + val id = newId() + val r = roundTrip( + FsOp.READ, + id, + ReadReq(id, handle, offset, MAX_READ_LEN).toJson().toString().toByteArray(), + ) + val d = (r as? Reply.Data)?.d + ?: throw FsException(FsCode.IO, "expected data") + if (d.bytes.isNotEmpty()) { + // Seek to the offset we were given rather than + // appending: the reply carries one so a reader that + // pipelines later cannot write bytes out of order. + out.seek(d.offset) + out.write(d.bytes) + offset = d.offset + d.bytes.size + onProgress(offset, size) + } + if (d.eof) break + if (d.bytes.isEmpty()) { + // No EOF and no bytes: the far side is not advancing, + // and retrying would spin forever. + throw FsException(FsCode.IO, "transfer stalled") + } + } + } + } catch (e: Exception) { + // No half-written file left behind: a truncated download looks like + // a real one and is worse than none. + dest.delete() + closeQuietly(handle) + throw e + } + closeQuietly(handle) + return dest + } + + private suspend fun closeQuietly(handle: Long) { + try { + val id = newId() + roundTrip(FsOp.CLOSE, id, CloseReq(id, handle).toJson().toString().toByteArray()) + } catch (e: Exception) { + // The far side expires idle handles anyway; failing to close is not + // worth failing a completed download over. + Log.i(TAG, "fs: close failed harmlessly: ${e.message}") + } + } +} diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt index d3e6190..e1587cf 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt @@ -55,6 +55,17 @@ internal fun VortexStack.startFsServer() { // handle table the LAN side was still pointing at. lanServer?.fsServe = serve + // The other direction: browsing the LAPTOP's files from this phone. Same + // ops, same frames — the protocol is symmetric — so the client needs only a + // way to send and a way to be handed replies. + com.vortex.a3.core.fs.FsClient.sender = { op, payload -> + val peer = activePeerPub ?: peerStore.list().firstOrNull()?.peerStaticPub + peer != null && gattServer?.sendFsRequest(peer, op, payload) == true + } + gattServer?.onFsReply = { _, type, payload -> + com.vortex.a3.core.fs.FsClient.onReply(type, payload) + } + gattServer?.onFsRequest = { peerPub, op, payload -> // Off the GATT callback thread, always. A document provider can stall // for seconds — a cloud-backed one indefinitely — and blocking here @@ -85,4 +96,7 @@ internal fun VortexStack.startFsServer() { * notice. */ internal fun VortexStack.stopFsServer() { fsHandles?.clear() + // Nothing in flight can be answered once the link is gone; fail the waiters + // now rather than leaving the UI parked until each one times out. + com.vortex.a3.core.fs.FsClient.reset() } diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index 7977ea8..69a7588 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -145,6 +145,7 @@ fun VortexRoot( ) { var showSettings by remember { mutableStateOf(false) } var showNotes by remember { mutableStateOf(false) } + var showLaptopFiles by remember { mutableStateOf(false) } remember { com.vortex.a3.core.notes.NoteStore.init(activity); 0 } // Shared smart-switch setting (persisted + cross-device LWW). remember { SmartSwitchSetting.init(activity); 0 } @@ -197,7 +198,13 @@ fun VortexRoot( val allFilesOn = remember(showSettings) { com.vortex.a3.core.fs.FsRoots(activity).allFilesGranted() } - if (showNotes) { + if (showLaptopFiles) { + // Its own BackHandler walks up the folder stack first, so + // Back only leaves the screen from the top level. + com.vortex.a3.ui.screens.LaptopFilesScreen( + onBack = { showLaptopFiles = false }, + ) + } else if (showNotes) { // System back pops to Home instead of leaving the app. // NotesScreen's own handlers (close the editor) compose // later, so they still win while the editor is open. @@ -290,6 +297,7 @@ fun VortexRoot( onRequestBatteryWhitelist = actions.onRequestBatteryWhitelist, onOpenSettings = { showSettings = true }, onOpenNotes = { showNotes = true }, + onOpenLaptopFiles = { showLaptopFiles = true }, onOpenEarbudsPicker = actions.onOpenEarbudsPicker, onPickEarbud = actions.onPickEarbud, onRescanEarbuds = actions.onRescanEarbuds, diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt index 6af5337..94ce790 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Laptop +import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Smartphone import androidx.compose.material.icons.outlined.StickyNote2 @@ -98,6 +99,7 @@ fun HomeScreen( onRequestBatteryWhitelist: () -> Unit, onOpenSettings: () -> Unit, onOpenNotes: () -> Unit, + onOpenLaptopFiles: () -> Unit, onOpenEarbudsPicker: () -> Unit, onPickEarbud: (BluetoothDeviceRow) -> Unit, onRescanEarbuds: () -> Unit, @@ -204,6 +206,13 @@ fun HomeScreen( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + IconButton(onClick = onOpenLaptopFiles) { + Icon( + imageVector = Icons.Outlined.FolderOpen, + contentDescription = "Laptop files", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } IconButton(onClick = onOpenSettings) { Icon( imageVector = Icons.Outlined.Settings, diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt new file mode 100644 index 0000000..0e1d100 --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt @@ -0,0 +1,270 @@ +package com.vortex.a3.ui.screens + +import android.os.Environment +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vortex.a3.core.fs.FsClient +import com.vortex.a3.core.fs.FsCode +import com.vortex.a3.core.fs.FsEntry +import java.io.File +import kotlinx.coroutines.launch + +/** + * Browse the laptop's shared folders and pull files down. + * + * Deliberately thin: everything it shows comes from [FsClient], and the laptop + * decides what is visible through its own roots config. This screen never + * constructs a path — it sends back the opaque `path` of an entry it was given, + * which is what lets the same code work whether the far side is a real + * filesystem or something else entirely. + */ +@Composable +fun LaptopFilesScreen(onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // The trail of folders we descended, so Back walks up one level rather than + // leaving the screen from three levels deep. + var stack by remember { mutableStateOf(listOf>()) } // (path, title) + var entries by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var downloading by remember { mutableStateOf(null) } + var progress by remember { mutableStateOf(0f) } + + val path = stack.lastOrNull()?.first ?: "" + val title = stack.lastOrNull()?.second ?: "Laptop files" + + LaunchedEffect(path) { + loading = true + error = null + try { + entries = FsClient.listAll(path).sortedWith( + // Folders first, then case-insensitive by name — what every + // file manager does, and cheap to do here rather than asking + // the far side to sort. + compareBy({ !it.isDir }, { it.name.lowercase() }), + ) + } catch (e: FsClient.FsException) { + entries = emptyList() + error = explain(e) + } catch (e: Exception) { + entries = emptyList() + error = e.message ?: "Could not read that folder" + } + loading = false + } + + BackHandler { + if (stack.isNotEmpty()) stack = stack.dropLast(1) else onBack() + } + + Column( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { if (stack.isNotEmpty()) stack = stack.dropLast(1) else onBack() }) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + if (downloading != null) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + "Downloading ${downloading}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.size(6.dp)) + // Indeterminate when the laptop did not report a size: a bar + // stuck at 0% would read as broken. + if (progress >= 0f) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } + + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + error != null -> Box( + Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + error!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + entries.isEmpty() -> Box( + Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "This folder is empty", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> LazyColumn(modifier = Modifier.fillMaxSize()) { + items(entries, key = { it.path.ifEmpty { it.name } }) { e -> + EntryRow(e) { + if (e.isDir) { + stack = stack + (e.path to e.name) + } else if (downloading == null) { + downloading = e.name + progress = if (e.size > 0) 0f else -1f + scope.launch { + val dest = File( + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS, + ), + uniqueName(e.name), + ) + val msg = try { + FsClient.download(e.path, dest) { done, total -> + progress = if (total > 0) { + (done.toDouble() / total).toFloat() + } else { + -1f + } + } + "Saved to Downloads/${dest.name}" + } catch (ex: FsClient.FsException) { + explain(ex) + } catch (ex: Exception) { + ex.message ?: "Download failed" + } + downloading = null + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +@Composable +private fun EntryRow(e: FsEntry, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (e.isDir) Icons.Outlined.Folder else Icons.Outlined.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) { + Text( + e.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!e.isDir) { + Text( + humanSize(e.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** Say what actually went wrong. The codes are errno-shaped, so each one has a + * true sentence — "not permitted" and "the laptop did not answer" send the + * user to completely different places. */ +private fun explain(e: FsClient.FsException): String = when (e.code) { + FsCode.NOENT -> "That file is no longer there" + FsCode.ACCES -> "The laptop is not sharing that folder" + FsCode.NOTSUP -> "The laptop does not support that" + FsCode.ISDIR -> "That is a folder" + FsClient.TIMEOUT -> "The laptop did not answer — is it awake and in range?" + else -> "Could not read that (${e.message})" +} + +private fun humanSize(bytes: Long): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.0f KB".format(bytes / 1024.0) + bytes < 1024L * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024 * 1024)) +} + +/** Never overwrite something already in Downloads: append " (n)" like every + * browser does, so a second pull of the same name is not a silent loss. */ +private fun uniqueName(name: String): String { + val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!File(dir, name).exists()) return name + val dot = name.lastIndexOf('.') + val stem = if (dot > 0) name.substring(0, dot) else name + val ext = if (dot > 0) name.substring(dot) else "" + var i = 1 + while (File(dir, "$stem ($i)$ext").exists()) i++ + return "$stem ($i)$ext" +} diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 2b4d2a6..074b102 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -266,6 +266,20 @@ This is where these features usually fail, and it is all daemon-side: Steps 1–2 are worth doing regardless of whether the mount ever ships, which is the main argument for this ordering. +## 8b. Browsing the laptop from the phone + +The protocol is symmetric, so this needed no new frames: the phone sends the +same ops it answers. `FsClient` is the consumer half (pipelined, id-correlated, +20 s timeout), `LaptopFilesScreen` browses and downloads to `Downloads/`, and +the laptop's roots config decides what is visible. + +**It runs over BLE only.** The laptop prefers Wi-Fi for the same traffic in the +other direction, and it can because the PHONE listens on TCP and the laptop +dials it. There is no listener the other way, so a phone-initiated LAN session +has nothing to connect to. Listings are small and fine over BLE; pulling a large +file this way runs at ~40 KiB/s. Closing that gap means giving the laptop a +listener — worth doing, and the natural companion to step 3. + ## 9. Open questions - **Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer, From a7e7b66d0954dc7901e219ea23c173e624769806 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 19:44:43 +0200 Subject: [PATCH 44/71] fix(fs): let a filesystem reply come back on either transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsing the laptop from the phone never worked, for two reasons that only a live run could have shown. Both are fixed here, and the feature is verified end to end: the phone listed the laptop's home directory, descended two levels, and downloaded a 1 KB file (one read) and a 299 KB file (several ranged reads), both byte-identical by md5. **A reply need not return on the transport that carried the request.** The laptop's client picks per send and prefers Wi-Fi, so a request arriving over BLE was answered over TCP — and the phone's LAN server had no route for FS_META / FS_DATA / FS_ERR, so it dropped every one. The browser then sat until its 20 s timeout and reported "the laptop did not answer" while the laptop had in fact answered instantly. The phone now accepts replies on both paths. Worth stating plainly: transports are chosen per frame, so anything that correlates a reply to a request has to be transport-agnostic. **The laptop could not list its own root when it served exactly one.** `do_list` skips the synthetic level in that case — rightly, a directory holding one entry is a click for nothing — but then resolved the still-empty path, which is INVAL. One root is the DEFAULT configuration, so out of the box the phone got "path refused" for the only listing it can start from. Now the single root's own path is substituted, with a regression test that would have caught it. Two notes on the session. The first live attempt failed on a stale binary rather than a bug, which cost a round of debugging; and hunting for the header button with screenshots caught the lock screen, so those images were deleted rather than left in the scratchpad. 205 daemon + 39 app tests pass; both targets check clean. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/core/lan/LanServer.kt | 28 ++++++++++++ .../java/com/vortex/a3/service/VortexStack.kt | 5 +++ .../com/vortex/a3/service/VortexStackFs.kt | 7 +++ docs/design/file-browsing.md | 4 ++ linux/daemon/src/core/fs_server.rs | 44 ++++++++++++++++++- 5 files changed, 87 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt index b8279d0..872293c 100644 --- a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt @@ -113,6 +113,18 @@ class LanServer( */ var fsServe: ((op: Byte, payload: ByteArray) -> Pair)? = null + /** + * A reply to a filesystem request WE sent, arriving over this socket. + * + * Needed because a reply does not necessarily come back on the transport + * that carried the request: the laptop's client prefers Wi-Fi for FS + * traffic, so a request sent over BLE is answered over TCP. Without this + * the phone dropped every such reply and its browser sat until the 20 s + * timeout, reporting "the laptop did not answer" while the laptop had in + * fact answered immediately. + */ + var onFsReply: ((frameType: Byte, payload: ByteArray) -> Unit)? = null + /** Fired after an instant-share FILE blob has been written to the peer, * with the content token it pulled by. Closes the loop the outgoing-offer * watchdog waits on: an offer is only really done once the laptop has the @@ -961,6 +973,22 @@ class LanServer( status.toString().toByteArray(Charsets.UTF_8), ) } + frame.type == FrameType.FS_META || + frame.type == FrameType.FS_DATA || + frame.type == FrameType.FS_ERR -> { + val plain = runCatching { + aeadOpen(pair.receiver, frame.payload) + }.getOrNull() + if (plain == null) { + Log.w(TAG, "fs: reply AEAD decrypt failed") + continue + } + try { + onFsReply?.invoke(frame.type, plain) + } catch (e: Exception) { + Log.w(TAG, "onFsReply threw: ${e.message}") + } + } frame.type == FrameType.FS_REQ -> { // Ranged filesystem op over Wi-Fi. The laptop // prefers this transport because BLE caps a notify diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index e58f3ae..333c61e 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -91,6 +91,10 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * and `restartBleComponents` replaces the server (and its handle table) * without touching the LAN side. Whoever starts second installs it. */ internal var fsServeFn: ((Byte, ByteArray) -> Pair)? = null + + /** Route for FS replies arriving over LAN. Same late-binding problem as + * [fsServeFn]: the LAN server does not exist yet when BLE starts. */ + internal var fsReplyFn: ((Byte, ByteArray) -> Unit)? = null /** Buffers phone→laptop notifications that fail to send while BLE is down; * flushed when the peer re-subscribes to AUDIO_SIGNAL. */ internal val notificationOutbox = com.vortex.a3.core.notif.NotificationOutbox() @@ -1190,6 +1194,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // BLE started first, so the serve function already exists; install it // now that there is a LAN server to hang it on. lan.fsServe = fsServeFn + lan.onFsReply = fsReplyFn } /** diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt index e1587cf..4d6db05 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt @@ -65,6 +65,13 @@ internal fun VortexStack.startFsServer() { gattServer?.onFsReply = { _, type, payload -> com.vortex.a3.core.fs.FsClient.onReply(type, payload) } + // The same replies can arrive over Wi-Fi instead: the laptop's client picks + // its transport per send, so the one that carried our request is not + // necessarily the one that answers it. + lanServer?.onFsReply = { type, payload -> + com.vortex.a3.core.fs.FsClient.onReply(type, payload) + } + fsReplyFn = { type, payload -> com.vortex.a3.core.fs.FsClient.onReply(type, payload) } gattServer?.onFsRequest = { peerPub, op, payload -> // Off the GATT callback thread, always. A document provider can stall diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 074b102..fc835df 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -273,6 +273,10 @@ same ops it answers. `FsClient` is the consumer half (pipelined, id-correlated, 20 s timeout), `LaptopFilesScreen` browses and downloads to `Downloads/`, and the laptop's roots config decides what is visible. +**Verified on the device** (2026-09-06): listed the laptop's home directory, +descended two levels, and downloaded both a 1 KB file (one read) and a 299 KB +file (multiple ranged reads) — both byte-identical by md5. + **It runs over BLE only.** The laptop prefers Wi-Fi for the same traffic in the other direction, and it can because the PHONE listens on TCP and the laptop dials it. There is no listener the other way, so a phone-initiated LAN session diff --git a/linux/daemon/src/core/fs_server.rs b/linux/daemon/src/core/fs_server.rs index fbb0dbe..97bc930 100644 --- a/linux/daemon/src/core/fs_server.rs +++ b/linux/daemon/src/core/fs_server.rs @@ -207,7 +207,19 @@ fn do_list(roots: &p::Roots, r: &p::ListReq) -> Served { // With exactly one root, a synthetic level above it would be a folder // the user has to click through every time for no information. } - let path = match resolve_or(roots, &r.path, false, r.id) { + // Which path to actually list. The single-root case above deliberately does + // NOT interpose a synthetic level, so the root's own path has to be + // substituted for the empty request here. Falling through with the empty + // path reached `resolve("")`, which is INVAL — so a peer asking for the + // root of a one-root device got "path refused" and could not browse at all. + // That is the DEFAULT configuration, and it is what the phone's browser hit + // on its first run. + let requested: String = if (r.path == "/" || r.path.is_empty()) && roots.list().len() == 1 { + roots.list()[0].path.to_string_lossy().to_string() + } else { + r.path.clone() + }; + let path = match resolve_or(roots, &requested, false, r.id) { Ok(p) => p, Err(s) => return s, }; @@ -497,6 +509,36 @@ mod tests { use super::*; use crate::core::fs_proto::Root; + /// The empty path against a ONE-root device must list that root. + /// + /// It used to answer INVAL: the single-root branch skips the synthetic + /// listing (rightly — a level with one entry is a click for nothing) but + /// then resolved the still-empty path. One root is the default config, so + /// the default device could not be browsed at all. + #[test] + fn empty_path_lists_the_only_root() { + let dir = scratch("one-root"); + std::fs::write(dir.join("a.txt"), b"hi").expect("write"); + let roots = p::Roots::new(vec![Root { + path: dir.clone(), + writable: false, + }]); + let handles = FsHandles::new(); + let req = serde_json::to_vec(&serde_json::json!({"id": 1, "path": "", "cursor": 0})) + .expect("json"); + match serve(&roots, &handles, p::op::LIST, &req) { + Served::Meta(FsReply::List { entries, .. }) => { + assert!( + entries.iter().any(|e| e.name == "a.txt"), + "expected the root's contents, got {entries:?}" + ); + } + Served::Err(e) => panic!("expected a listing, got error {}: {}", e.code, e.msg), + _ => panic!("expected a listing"), + } + let _ = std::fs::remove_dir_all(&dir); + } + fn scratch(name: &str) -> PathBuf { let p = std::env::temp_dir().join(format!("vortex-fsserver-{name}")); let _ = std::fs::remove_dir_all(&p); From cdf7aa6e831d510cd9940de24519bd28ef2dac29 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 21:01:11 +0200 Subject: [PATCH 45/71] fix(fs): keep the file browser's header out from under the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The back button was there all along — an arrow in the header, wired to the same action as the gesture — but it rendered UNDERNEATH the status bar, landing behind the clock. Invisible enough to read as missing, and hard to hit: a synthetic tap on it was swallowed outright, which is how this was first noticed while driving the screen over adb. The cause is not this screen. targetSdk 36 makes edge-to-edge mandatory, and nothing in the app applies window insets, so every screen draws under the system bars. The home screen's own header overlaps the clock the same way. Fixed here because this is where it was reported; the rest of the app has the same problem and the same one-line remedy, which is worth doing deliberately rather than as a drive-by that shifts every screen's layout at once. Padding goes after the background so the status bar still sits on our colour instead of a bare gap. Back behaviour is unchanged and was already correct: gesture and button run the same lambda, walking up one folder and leaving the screen only from the top level. The comments now say so, since having two entry points to one action is exactly the kind of thing that drifts. Not verified on the device — the phone dropped off adb before I could re-run it. Co-Authored-By: Claude Opus 5 --- .../vortex/a3/ui/screens/LaptopFilesScreen.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt index 0e1d100..82b4bc8 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/LaptopFilesScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -92,17 +93,32 @@ fun LaptopFilesScreen(onBack: () -> Unit) { loading = false } + // Back — gesture or button — walks UP one folder and only leaves the screen + // from the top. Registered here rather than in the caller (as Notes and + // Settings do) precisely because it is not a plain dismiss: the caller does + // not know how deep the browse is. BackHandler { if (stack.isNotEmpty()) stack = stack.dropLast(1) else onBack() } Column( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + // targetSdk 36 makes edge-to-edge mandatory, and nothing in this app + // compensates — so without this the header draws UNDER the status + // bar: the back arrow lands behind the clock, where it is hard to + // see and hard to hit (a synthetic tap on it is swallowed + // outright). The background is applied before the padding so the + // bar still sits on our colour rather than a bare gap. + .systemBarsPadding(), ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Same action as the back gesture, so the two never disagree about + // what "back" means at a given depth. IconButton(onClick = { if (stack.isNotEmpty()) stack = stack.dropLast(1) else onBack() }) { Icon( Icons.AutoMirrored.Outlined.ArrowBack, From 46b91d57e924d981dc480bc6290563be537d900b Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 21:15:15 +0200 Subject: [PATCH 46/71] fix(ui): keep the home screen's header out from under the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect as the file browser, on the screen the user sees first. targetSdk 36 makes edge-to-edge mandatory and nothing here opted in, so the header drew UNDER the status bar: the Notes / Laptop files / Settings icons sat in the same band as the clock, where the system takes the touch. The failure mode is nastier than a cosmetic overlap. The icons render perfectly and simply do not respond, so it reads as a dead button rather than a mispositioned one — the report was "it's hard to click the folder icon", not "the header is too high". It also caught me out while driving the screen over adb: my taps on that row did nothing and I put it down to bad coordinates. Background before padding, so the status bar still sits on our colour rather than a bare strip. Verified on the device: the header now clears the clock, and a tap at the folder icon's own centre opens the browser — the exact tap that was being swallowed. Notes and Settings still have this; they carry only a back arrow in that band, so it bites less, and the same one-line change fixes them when wanted. Co-Authored-By: Claude Opus 5 --- .../main/java/com/vortex/a3/ui/screens/HomeScreen.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt index 94ce790..7c6baf7 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/HomeScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -164,7 +165,15 @@ fun HomeScreen( Column( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + // targetSdk 36 makes edge-to-edge mandatory and nothing here opted + // in, so this header drew UNDER the status bar: the Notes / Laptop + // files / Settings icons sat in the same band as the clock, where + // the system consumes the touch. They rendered fine and simply did + // not respond, which reads as a broken button rather than a + // mispositioned one. Background before padding, so the status bar + // still sits on our colour instead of a bare strip. + .systemBarsPadding(), ) { Row( modifier = Modifier From 0f661b6e1713ee04c7fa8519e586ad33837e481d Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 21:24:20 +0200 Subject: [PATCH 47/71] feat(fs): the phone's browsing rides Wi-Fi too, after the first request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filesystem traffic from the phone ran entirely over BLE, at ~40 KiB/s. It now runs over the LAN session for everything after the opening request: 18.4 MB downloaded in 46 s (~409 KiB/s), byte-identical, an order of magnitude faster and a rate BLE cannot physically reach. The phone cannot open a LAN session — the laptop runs no listener, which is why this direction was BLE-only. It does not need one. The session the LAPTOP opens to deliver its reply is a plain bidirectional socket, and the laptop's dispatcher serves an FS_REQ arriving on it whichever side sent it. So the first request of a browse goes over BLE, the laptop's own reply brings the session up as a side effect, and the phone sends everything after that down it — including every ranged read of a download. The laptop needed no changes at all. The sender binds to the connection that has actually carried an FS frame rather than to whichever is newest: the laptop also opens short-lived heartbeat sessions, and a request sent down one of those would die with it. Cleared on teardown under the same CAS discipline as the audio session writer, so a connection on its way out cannot strip a newer one of its writer. Not as fast as the laptop→phone direction (931 KiB/s), because this loop is strictly sequential — each 48 KiB read waits a full round trip. Pipelining is the obvious next gain and needs no protocol change: requests already carry ids and replies may arrive in any order. Verified on the device end to end. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/core/lan/LanServer.kt | 49 +++++++++++++++++++ .../com/vortex/a3/service/VortexStackFs.kt | 15 +++++- docs/design/file-browsing.md | 15 +++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt index 872293c..26baaa2 100644 --- a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt @@ -125,6 +125,39 @@ class LanServer( */ var onFsReply: ((frameType: Byte, payload: ByteArray) -> Unit)? = null + /** + * Writer for the LAN session the laptop is using for filesystem traffic, + * or null when there is none open. + * + * Bound to the connection that has actually carried an FS frame, not to + * whichever connection is newest: the laptop also opens short-lived + * heartbeat sessions, and publishing one of those would send a request down + * a socket about to close. + */ + @Volatile + private var fsWriter: ((op: Byte, payload: ByteArray) -> Unit)? = null + + /** + * Send one FS_REQ over the live LAN session. False when there is none, so + * the caller can fall back to BLE. + * + * This works because the session is a plain bidirectional socket: the + * laptop dials it and serves whatever arrives on it, whichever side asked. + * The phone cannot open one itself — the laptop has no listener — so the + * first request of a browse still goes over BLE, and the laptop's own reply + * is what brings the LAN session up for everything after it. + */ + fun fsSend(op: Byte, payload: ByteArray): Boolean { + val w = fsWriter ?: return false + return try { + w(op, payload) + true + } catch (e: Exception) { + Log.w(TAG, "fs: LAN send failed (${e.message}); caller falls back") + false + } + } + /** Fired after an instant-share FILE blob has been written to the peer, * with the content token it pulled by. Closes the loop the outgoing-offer * watchdog waits on: an offer is only really done once the laptop has the @@ -718,6 +751,13 @@ class LanServer( } finally { outLock.unlock() } } + // Writes an FS_REQ on THIS connection. Published only once an + // FS frame has arrived here (below), so it can never be a + // heartbeat socket. + val fsOut: (Byte, ByteArray) -> Unit = { op, payloadBytes -> + lockedSealAndWrite(FrameType.FS_REQ, op, payloadBytes) + } + val writer: suspend (com.vortex.a3.core.earbuds.AudioOpFrame) -> Result = { outFrame -> try { @@ -983,6 +1023,11 @@ class LanServer( Log.w(TAG, "fs: reply AEAD decrypt failed") continue } + // This socket is demonstrably the laptop's FS + // session, so it is the one to send our own + // requests on — Wi-Fi instead of BLE for everything + // after the first. + fsWriter = fsOut try { onFsReply?.invoke(frame.type, plain) } catch (e: Exception) { @@ -1160,6 +1205,10 @@ class LanServer( // its writer. com.vortex.a3.core.earbuds.EarbudsSwitchHolder .clearSessionWriter(peerPubFinal, writer) + // Same CAS discipline: only clear the FS slot if this + // connection still owns it, or we would strip a newer + // session of its writer on our way out. + if (fsWriter === fsOut) fsWriter = null } } } catch (e: Exception) { diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt index 4d6db05..20c6683 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackFs.kt @@ -59,8 +59,19 @@ internal fun VortexStack.startFsServer() { // ops, same frames — the protocol is symmetric — so the client needs only a // way to send and a way to be handed replies. com.vortex.a3.core.fs.FsClient.sender = { op, payload -> - val peer = activePeerPub ?: peerStore.list().firstOrNull()?.peerStaticPub - peer != null && gattServer?.sendFsRequest(peer, op, payload) == true + // Wi-Fi first, exactly as the laptop does for the same frames: a 48 KiB + // read is one TCP frame against ~96 paced BLE fragments. + // + // The first request of a browse still goes over BLE, and cannot not: + // the phone has no way to dial the laptop, which runs no listener. What + // it can do is answer on the session the laptop opens to deliver its + // reply — that socket is bidirectional and the laptop serves whatever + // arrives on it — so BLE carries the opening request and Wi-Fi carries + // the rest, including every ranged read of a download. + lanServer?.fsSend(op, payload) == true || run { + val peer = activePeerPub ?: peerStore.list().firstOrNull()?.peerStaticPub + peer != null && gattServer?.sendFsRequest(peer, op, payload) == true + } } gattServer?.onFsReply = { _, type, payload -> com.vortex.a3.core.fs.FsClient.onReply(type, payload) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index fc835df..7a308c4 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -277,7 +277,20 @@ the laptop's roots config decides what is visible. descended two levels, and downloaded both a 1 KB file (one read) and a 299 KB file (multiple ranged reads) — both byte-identical by md5. -**It runs over BLE only.** The laptop prefers Wi-Fi for the same traffic in the +**The opening request rides BLE; everything after it rides Wi-Fi.** The phone +cannot dial the laptop — the laptop runs no listener — so it cannot open a LAN +session itself. What it can do is answer on the session the LAPTOP opens to +deliver its reply: that socket is bidirectional, and the laptop's dispatcher +serves an `FS_REQ` arriving on it whichever side sent it. So the first request +of a browse goes over BLE, the laptop's reply brings the session up, and the +phone sends everything after it there — including every ranged read of a +download. Measured: 18.4 MB in 46 s (~409 KiB/s) against ~40 KiB/s on BLE. + +The phone binds its sender to the connection that has actually carried an FS +frame, not the newest one, because the laptop also opens short-lived heartbeat +sessions and a request sent down one of those would die with it. + +**A note on what remains BLE-only.** The laptop prefers Wi-Fi for the same traffic in the other direction, and it can because the PHONE listens on TCP and the laptop dials it. There is no listener the other way, so a phone-initiated LAN session has nothing to connect to. Listings are small and fine over BLE; pulling a large From a9e7b9b8788ecbc69af7ee16b6390a3084aa491e Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 21:44:51 +0200 Subject: [PATCH 48/71] perf(fs): pipeline ranged reads, in both clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both download loops issued one read and waited a full round trip before sending the next, so throughput was bounded by latency rather than by the link. Each now keeps READ_WINDOW (4) reads outstanding. Measured on the device, same files as before: * laptop pulling from the phone: 151 MB, 73 s -> 17.7 s (2.07 -> 8.1 MB/s) * phone pulling from the laptop: 18.4 MB, 46 s -> 8 s (0.4 -> 2.3 MB/s) Both byte-identical by md5. Replies are consumed in ISSUE order, which is also offset order, so the file is still written front to back and progress only moves forward. Out-of-order arrival is already legal — ids exist for exactly that — this simply declines to care. Each in-flight read carries the offset it asked for rather than trusting a running counter: a short read would otherwise shift every later chunk silently. Only when the size is known. Without one there is nothing to size a window against and speculative reads past the end would be waste on a link this feature exists to stop wasting, so that path stays sequential and follows EOF. The window is deliberately small. The gain is hiding the round trip, not the disk — the peer serves reads under a single lock either way — and on a BLE fallback each 48 KiB reply is ~96 paced notify fragments, so a large window would flood a link that cannot absorb it. Concurrency was safe to add: the Rust server takes the handle-table lock across seek+read, so overlapping reads on one handle serialise rather than interleave. Checked before writing the client, not after. Fixes a leak the concurrency would have exposed: the phone's `roundTrip` removed its in-flight id on timeout and on send failure, but not on CANCELLATION — and cancelling siblings is exactly what happens when one read of a batch fails. It now drops the id in a `finally`. 205 daemon + 39 app tests pass; both targets check clean. Co-Authored-By: Claude Opus 5 --- .../java/com/vortex/a3/core/fs/FsClient.kt | 120 +++++++++++++----- linux/ui-tauri/src-tauri/src/fs_link.rs | 94 +++++++++++--- 2 files changed, 162 insertions(+), 52 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt index 862f1cd..99ae48b 100644 --- a/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt +++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsClient.kt @@ -5,6 +5,8 @@ import com.vortex.a3.core.ble.FrameType import java.io.File import java.io.RandomAccessFile import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject @@ -43,6 +45,19 @@ object FsClient { */ private const val REQUEST_TIMEOUT_MS = 20_000L + /** + * Ranged reads kept in flight at once. + * + * The gain is hiding the round trip, not the disk: the laptop serves reads + * under one lock, so they do not overlap there, but each request otherwise + * waited a full RTT before the next was even sent. Four is deliberately + * modest — it covers the latency without committing much: the replies are + * 48 KiB each, and on a BLE fallback every one of those is ~96 paced + * notify fragments, so a large window would flood a link that cannot + * absorb it. + */ + private const val READ_WINDOW = 4 + private const val TAG = "VortexFs" /** Set by VortexStack: sends one FS_REQ, returns false if there is no link. */ @@ -109,18 +124,30 @@ object FsClient { private suspend fun roundTrip(op: Byte, id: Int, payload: ByteArray): Reply { val d = CompletableDeferred() synchronized(lock) { inflight[id] = d } - val send = sender - if (send == null || !send(op, payload)) { - synchronized(lock) { inflight.remove(id) } - throw FsException(FsCode.IO, "no link to the laptop") - } - val reply = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { d.await() } - if (reply == null) { + try { + val send = sender ?: throw FsException(FsCode.IO, "no link to the laptop") + if (!send(op, payload)) throw FsException(FsCode.IO, "no link to the laptop") + val reply = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { d.await() } + ?: throw FsException(TIMEOUT, "the laptop did not answer") + if (reply is Reply.Err) throw FsException(reply.e.code, reply.e.msg) + return reply + } finally { + // Always, including on CANCELLATION. Pipelining made that matter: + // when one read of a batch fails the rest are cancelled, and each + // used to leave its id in the map for the life of the process. synchronized(lock) { inflight.remove(id) } - throw FsException(TIMEOUT, "the laptop did not answer") } - if (reply is Reply.Err) throw FsException(reply.e.code, reply.e.msg) - return reply + } + + /** One ranged read, as a suspending call the download loop can overlap. */ + private suspend fun readAt(handle: Long, offset: Long, len: Int): FsData { + val id = newId() + val r = roundTrip( + FsOp.READ, + id, + ReadReq(id, handle, offset, len).toJson().toString().toByteArray(), + ) + return (r as? Reply.Data)?.d ?: throw FsException(FsCode.IO, "expected data") } private fun newId(): Int = synchronized(lock) { @@ -186,29 +213,58 @@ object FsClient { try { RandomAccessFile(dest, "rw").use { out -> out.setLength(0) - while (true) { - val id = newId() - val r = roundTrip( - FsOp.READ, - id, - ReadReq(id, handle, offset, MAX_READ_LEN).toJson().toString().toByteArray(), - ) - val d = (r as? Reply.Data)?.d - ?: throw FsException(FsCode.IO, "expected data") - if (d.bytes.isNotEmpty()) { - // Seek to the offset we were given rather than - // appending: the reply carries one so a reader that - // pipelines later cannot write bytes out of order. - out.seek(d.offset) - out.write(d.bytes) - offset = d.offset + d.bytes.size - onProgress(offset, size) + if (size > 0) { + // Pipelined: keep [READ_WINDOW] reads outstanding so the + // next request is already on the wire while the current + // reply is still coming back. Only when the size is known — + // without it there is no way to tell how many reads to + // issue, and speculative ones past the end would be waste + // on a link this feature exists to stop wasting. + coroutineScope { + val inflight = ArrayDeque>() + var nextOffset = 0L + while (offset < size) { + while (inflight.size < READ_WINDOW && nextOffset < size) { + val at = nextOffset + nextOffset += MAX_READ_LEN + inflight.addLast(async { readAt(handle, at, MAX_READ_LEN) }) + } + // Consumed in ISSUE order, which is also offset + // order, so the file is written front to back and + // progress only ever moves forward. Replies may + // still arrive in any order; this just declines to + // care. + val d = inflight.removeFirst().await() + if (d.bytes.isNotEmpty()) { + out.seek(d.offset) + out.write(d.bytes) + offset = d.offset + d.bytes.size + onProgress(offset, size) + } else if (!d.eof) { + throw FsException(FsCode.IO, "transfer stalled") + } + if (d.eof && inflight.isEmpty()) break + } + // A short file, or one that shrank under us: drop the + // rest rather than awaiting reads past its end. + inflight.forEach { it.cancel() } } - if (d.eof) break - if (d.bytes.isEmpty()) { - // No EOF and no bytes: the far side is not advancing, - // and retrying would spin forever. - throw FsException(FsCode.IO, "transfer stalled") + } else { + // Unknown size: sequential, following EOF. + while (true) { + val d = readAt(handle, offset, MAX_READ_LEN) + if (d.bytes.isNotEmpty()) { + out.seek(d.offset) + out.write(d.bytes) + offset = d.offset + d.bytes.size + onProgress(offset, size) + } + if (d.eof) break + if (d.bytes.isEmpty()) { + // No EOF and no bytes: the far side is not + // advancing, and retrying would spin forever. + throw FsException(FsCode.IO, "transfer stalled") + } } } } diff --git a/linux/ui-tauri/src-tauri/src/fs_link.rs b/linux/ui-tauri/src-tauri/src/fs_link.rs index 3eacbbe..ccc97e2 100644 --- a/linux/ui-tauri/src-tauri/src/fs_link.rs +++ b/linux/ui-tauri/src-tauri/src/fs_link.rs @@ -34,6 +34,14 @@ use vortex_l3_daemon::core::fs_server::{self, FsHandles, Served}; #[allow(dead_code)] const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Ranged reads kept in flight by [`read_all`]. +/// +/// Hides the round trip, not the disk: the peer serves reads under one lock so +/// they do not overlap there. Modest on purpose — replies are 48 KiB each, and +/// on a BLE fallback every one is ~96 paced notify fragments, so a large window +/// would flood a link that cannot absorb it. +const READ_WINDOW: usize = 4; + /// A reply, as delivered to whoever is waiting on a request id. #[derive(Debug)] pub enum Reply { @@ -384,31 +392,77 @@ pub(crate) async fn read_all( path: &str, mut sink: impl FnMut(u64, &[u8]) -> std::io::Result<()>, ) -> Result { + use futures::stream::{FuturesOrdered, StreamExt}; + let (handle, size) = open(path, false).await?; let mut offset = 0u64; - let result = loop { - let (bytes, eof) = match read(handle, offset, p::MAX_READ_LEN).await { - Ok(v) => v, - Err(c) => break Err(c), - }; - if !bytes.is_empty() { - if let Err(e) = sink(offset, &bytes) { - tracing::warn!("fs: sink failed at offset {offset}: {e}"); + + // Pipelined when the size is known: keep [`READ_WINDOW`] reads outstanding + // so the next request is on the wire while the current reply is still + // arriving. Sequentially, every chunk paid a full round trip before the + // next was even sent, which is most of the cost on a link this fast. + // + // `FuturesOrdered` yields in ISSUE order, which is also offset order, so + // the sink is still called front to back and a caller that simply appends + // stays correct. Each future carries the offset it asked for rather than + // trusting a running counter — a short read would otherwise silently shift + // every later chunk. + // + // Without a size there is nothing to size a window against, and + // speculative reads past the end would be pure waste, so that case stays + // sequential and follows EOF. + let result = if size > 0 { + let mut pending = FuturesOrdered::new(); + let mut next = 0u64; + loop { + while pending.len() < READ_WINDOW && next < size { + let at = next; + pending.push_back(async move { (at, read(handle, at, p::MAX_READ_LEN).await) }); + next = next.saturating_add(p::MAX_READ_LEN as u64); + } + let Some((at, res)) = pending.next().await else { + break Ok(offset); + }; + let (bytes, eof) = match res { + Ok(v) => v, + Err(c) => break Err(c), + }; + if !bytes.is_empty() { + if let Err(e) = sink(at, &bytes) { + tracing::warn!("fs: sink failed at offset {at}: {e}"); + break Err(code::IO); + } + offset = at + bytes.len() as u64; + } else if !eof { + tracing::warn!(at, "fs: read stalled without EOF"); break Err(code::IO); } - offset += bytes.len() as u64; - } - if eof { - break Ok(offset); - } - if bytes.is_empty() { - // No EOF flag and no bytes: the peer is not making progress and a - // retry loop here would spin forever. - tracing::warn!(offset, "fs: read stalled without EOF"); - break Err(code::IO); + if eof { + break Ok(offset); + } } - if size > 0 && offset >= size { - break Ok(offset); + } else { + loop { + let (bytes, eof) = match read(handle, offset, p::MAX_READ_LEN).await { + Ok(v) => v, + Err(c) => break Err(c), + }; + if !bytes.is_empty() { + if let Err(e) = sink(offset, &bytes) { + tracing::warn!("fs: sink failed at offset {offset}: {e}"); + break Err(code::IO); + } + offset += bytes.len() as u64; + } + if eof { + break Ok(offset); + } + if bytes.is_empty() { + // No EOF flag and no bytes: the peer is not making progress and + // a retry loop here would spin forever. + tracing::warn!(offset, "fs: read stalled without EOF"); + break Err(code::IO); + } } }; close(handle).await; From fdb8fea4f83cfc44ffe85999f8ca70d880379a10 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Sun, 6 Sep 2026 21:45:12 +0200 Subject: [PATCH 49/71] docs(fs): record the pipelining measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The throughput figures in §7 and §8b were from the sequential loops. Both are now the measured pipelined ones, and the readahead bullet says what is actually done (4 reads in flight) versus what is not (reading ahead of the request, which is the part a mount will need). Co-Authored-By: Claude Opus 5 --- docs/design/file-browsing.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 7a308c4..aa5521c 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -207,7 +207,10 @@ This is where these features usually fail, and it is all daemon-side: repeatedly. Without a cache, every icon refresh is a round trip. - **Readahead.** Sequential reads (copying, media playback) should pull ahead of the requested range; a strict 63 KiB request/response ping-pong will never - saturate Wi-Fi. + saturate Wi-Fi. *Partly done:* both clients keep 4 ranged reads in flight, + which turns the round trip from a per-chunk cost into an overlapped one — + 2.07 to 8.1 MB/s laptop-side, 0.4 to 2.3 MB/s phone-side. Reading *ahead* of + what was asked for is still to come, and is what a mount will need. - **Coalescing and a concurrency cap.** Thumbnailers fire dozens of parallel reads; unbounded, they will starve the link and the BLE session with it. - **Content cache with a byte budget**, not an entry count — one 2 GB video must @@ -284,7 +287,7 @@ deliver its reply: that socket is bidirectional, and the laptop's dispatcher serves an `FS_REQ` arriving on it whichever side sent it. So the first request of a browse goes over BLE, the laptop's reply brings the session up, and the phone sends everything after it there — including every ranged read of a -download. Measured: 18.4 MB in 46 s (~409 KiB/s) against ~40 KiB/s on BLE. +download. Measured: 18.4 MB in 8 s (~2.3 MB/s) against ~40 KiB/s on BLE. The phone binds its sender to the connection that has actually carried an FS frame, not the newest one, because the laptop also opens short-lived heartbeat From 9d0f7a2153ddb7bcb9b9277b0dc2656ac8feeac9 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Mon, 7 Sep 2026 07:56:38 +0200 Subject: [PATCH 50/71] feat(fs): mount the phone's storage over FUSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc §8 step 6, Linux half — and ahead of steps 3-5, because on this OS it supersedes the WebDAV gateway rather than competing with it: GVFS and KIO mount `davs://` inside the file manager's own process, so `cp`, `mpv` and anything not built on KIO cannot see the files. A FUSE mount is a path, so everything can. `--fs-mount` / `--fs-umount` put it at `$XDG_RUNTIME_DIR/vortex/phone`, read-only. Not automatic: mounting costs a kernel session, and a mount pointing at a phone that is not here is worse than no mount. The load-bearing decision is that nothing blocks the FUSE session thread. Each operation is handed to the async runtime and its reply object (which fuser makes `Send` for exactly this) is answered when the phone answers. Serving inline would cost a full round trip per operation in series, and a file manager opening a folder issues dozens at once. It is also what makes the kernel's own readahead work for us: a sequential reader triggers several `read` calls at once, and they overlap on the wire instead of queueing. Three of §7's items fall out of it: * metadata cache — attributes and entries carry a 5 s TTL, so a repeat stat never reaches this process, and a listing seeds the attribute cache for every entry, which is what makes the lookup+getattr storm after a readdir free. Both caches sweep expired entries past a threshold: inodes are never recycled, so unswept they would hold every listing for the life of a process that runs for days. * readahead — the kernel's, per above; ours is still open. * concurrency cap — a semaphore of 8 over every request, so a thumbnailer cannot queue megabytes of image data ahead of the next listing, or starve the BLE session carrying everything else. `fs_link::send` now reports failure instead of dropping the frame, so a request that reaches neither transport fails at once with EHOSTDOWN rather than waiting out the 20 s timeout. Twenty seconds per operation on a phone that is not there is indistinguishable from a hung file manager (§6). Child addresses are opaque — a SAF document URI, not a path — so `lookup` resolves a name through the parent's listing rather than joining it onto the parent's address, and inode numbers are interned rather than derived and never recycled: a file manager holds them across a refresh. `fuser` with default features off: `libfuse` would need libfuse3 headers and pkg-config at build time, which every packaging target would then carry. Without it, fuser mounts through the kernel and falls back to `fusermount3`, already present anywhere FUSE works. The mount is detached on the tray's Quit, because a FUSE mount outlives its server process and answers ENOTCONN afterwards — `df` errors and every file manager shows a broken entry. Tested: 11 unit tests against a fake peer (pagination, inode stability, a listing answering the lookups after it, reassembly of a 100 KiB read across three protocol reads, offsets, errno mapping), plus a real kernel mount over that fake peer driven by ordinary `std::fs` calls — read_dir, a 100 KiB file byte-identical through the page cache, a stat, and a write refused. That one needs /dev/fuse so it is `#[ignore]`d; run it with `cargo test --lib fs_mount -- --ignored`. Not verified: the Windows build. `cargo check --target x86_64-pc-windows-msvc` fails here in a dependency's build script for want of `lib.exe`, before reaching our code. Every reference to the module is `cfg(target_os = "linux")` and the crate dependency is in the Linux target section. Co-Authored-By: Claude Opus 5 (1M context) --- docs/design/file-browsing.md | 68 +- linux/ui-tauri/src-tauri/Cargo.lock | 48 +- linux/ui-tauri/src-tauri/Cargo.toml | 7 + linux/ui-tauri/src-tauri/src/fs_cli.rs | 38 +- linux/ui-tauri/src-tauri/src/fs_link.rs | 53 +- linux/ui-tauri/src-tauri/src/fs_mount.rs | 1167 ++++++++++++++++++++++ linux/ui-tauri/src-tauri/src/lib.rs | 4 + 7 files changed, 1360 insertions(+), 25 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/fs_mount.rs diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index aa5521c..bccc3d8 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -108,6 +108,11 @@ Design notes: ## 4. Desktop presentation: WebDAV first, native VFS as the exit +*Linux went straight to the native VFS (v2) and skipped WebDAV.* The reasoning +is under v2 below; in short, on Linux WebDAV buys strictly less than FUSE for +comparable work, so the "cheapest path" argument for doing it first does not +survive contact with it. Windows still has the choice open. + ### v1 — WebDAV on loopback One implementation serving both OSes: @@ -131,7 +136,8 @@ not go away and is the main reason v1 may not be the end state. ### v2 — native virtual filesystem -- **Linux:** FUSE. Straightforward, gives a real mount. +- **Linux:** FUSE. Straightforward, gives a real mount. **Done** — + [`fs_mount.rs`], mounted at `$XDG_RUNTIME_DIR/vortex/phone`. - **Windows:** **ProjFS** (Projected File System), shipped in Windows 10 1809+ with **no third-party install** — it is what VFS for Git uses. This is the key fact that beats WebDAV: a real filesystem, no size limits, proper seeking. @@ -139,6 +145,16 @@ not go away and is the main reason v1 may not be the end state. More code (two presentation implementations), but no artificial ceilings, and the phone side is untouched by the switch. +**Why Linux skipped WebDAV.** GVFS and KIO mount `davs://` *inside the file +manager's own process*, so only that program's file dialogs can see the files — +`cp`, `mpv`, `ffprobe`, a text editor's Open box, anything not built on KIO, +cannot. A FUSE mount is a path in the filesystem, so everything can. Against +that, the platform-neutrality argument for WebDAV-first only pays off on +Windows, where it also runs into the ~50 MB `FileSizeLimitInBytes` cap. Linux +needs no gateway process, no port, and no auth story at all: the mount is a +directory only the mounting user can see (FUSE's default `Owner` access mode), +which is a smaller attack surface than a loopback HTTP server. + ### Rejected: SFTP + sshfs What KDE Connect uses, and excellent on Linux. On Windows it needs WinFsp + @@ -195,6 +211,10 @@ ride it, and it is how the daemon knows the phone is there at all. So: - Wi-Fi (LAN, or Wi-Fi Direct for bulk) is required for content. - With no usable network, the mount reports an honest, immediate error rather than hanging — a file manager blocked on a dead read is the worst outcome. + *Done:* a request that reaches neither transport fails at once with + `EHOSTDOWN` ("Host is down") instead of waiting out the 20 s reply timeout. + Twenty seconds per operation on a phone that is simply not here is + indistinguishable from a hung file manager. - Wi-Fi Direct is already used for large transfers and applies here unchanged. --- @@ -204,15 +224,28 @@ ride it, and it is how the daemon knows the phone is there at all. So: This is where these features usually fail, and it is all daemon-side: - **Metadata cache with invalidation.** File managers stat everything in view, - repeatedly. Without a cache, every icon refresh is a round trip. + repeatedly. Without a cache, every icon refresh is a round trip. *Done for + the mount, mostly by the kernel:* attributes and directory entries carry a + 5 s TTL, so a repeat `stat` inside that window never even reaches our + process. The other half is ours — a listing seeds the attribute cache for + every entry in it, which is what makes the `lookup` + `getattr` storm that + follows a `readdir` cost nothing. Invalidation is the TTL expiring; there is + no push notification of a change on the phone, and 5 s is the compromise. - **Readahead.** Sequential reads (copying, media playback) should pull ahead of the requested range; a strict 63 KiB request/response ping-pong will never saturate Wi-Fi. *Partly done:* both clients keep 4 ranged reads in flight, which turns the round trip from a per-chunk cost into an overlapped one — 2.07 to 8.1 MB/s laptop-side, 0.4 to 2.3 MB/s phone-side. Reading *ahead* of - what was asked for is still to come, and is what a mount will need. + what was asked for arrives with the mount, and again from the kernel rather + than from us: a sequential reader makes the kernel issue several `read` calls + at once, and because the mount answers every one off-thread instead of + blocking, they overlap on the wire. A daemon-side readahead of its own is + still open, and is what would help the *first* read of a file. - **Coalescing and a concurrency cap.** Thumbnailers fire dozens of parallel - reads; unbounded, they will starve the link and the BLE session with it. + reads; unbounded, they will starve the link and the BLE session with it. *Cap + done:* the mount holds a semaphore of 8 over every request it sends, so a + folder of photos cannot queue megabytes of image data ahead of the next + listing. Coalescing overlapping ranges is not done. - **Content cache with a byte budget**, not an entry count — one 2 GB video must not evict a whole tree's metadata. - **Honest errors.** Every failure path returns a definite error quickly. @@ -264,7 +297,27 @@ This is where these features usually fail, and it is all daemon-side: 3. **Daemon cache layer** — metadata, readahead, content budget. 4. **WebDAV loopback gateway**, both OSes. 5. **`FS_WRITE` / `FS_SETMETA`** for real, once read-only is solid. -6. **FUSE + ProjFS**, if the Windows WebDAV limits bite. +6. **FUSE + ProjFS**, if the Windows WebDAV limits bite. **Linux done** + ([`fs_mount.rs`]), ahead of steps 3-5 and instead of WebDAV on this OS — + see §4. `--fs-mount` / `--fs-umount` put the phone's storage at + `$XDG_RUNTIME_DIR/vortex/phone`, read-only, and every program on the machine + can read it. + + The load-bearing decision is that **nothing blocks the FUSE session + thread**: each operation is handed to the async runtime and its reply object + (which fuser makes `Send` for exactly this) is answered when the phone + answers. Serving inline instead would cost one full round trip per operation + in series, and a file manager opening a folder issues dozens at once. + + What is not there yet: writes (step 5 — the mount is `ro`, so the kernel + refuses them without a round trip), a content cache, coalescing, and + `statfs` numbers (there is no protocol op for free space, and inventing one + for a read-only mount would be a lie a file manager acts on). + + Verified against a real kernel mount over a fake peer — `read_dir`, a + 100 KiB file read back byte-identical through the page cache, a `stat`, and + a write refused. That test needs `/dev/fuse`, so it is `#[ignore]`d and run + with `cargo test --lib fs_mount -- --ignored`. Steps 1–2 are worth doing regardless of whether the mount ever ships, which is the main argument for this ordering. @@ -308,7 +361,10 @@ listener — worth doing, and the natural companion to step 3. reopen, or the file manager will see spurious I/O errors after a Doze kill. - **Multi-peer:** with several paired phones, is the mount per-phone (a mount point each) or does it follow the active peer? Per-phone is more predictable - but multiplies mounts. + but multiplies mounts. *Answered by construction for now:* the protocol + client takes no peer — it sends to whichever session is up — so the single + mount follows the active peer. A per-phone mount is not expressible until the + client is addressed by peer, which is the real prerequisite here. - **Thumbnails:** let the desktop generate them by reading bytes (simple, heavy on the link), or ask the phone for MediaStore thumbnails (fast, needs another op)? diff --git a/linux/ui-tauri/src-tauri/Cargo.lock b/linux/ui-tauri/src-tauri/Cargo.lock index 9f471ee..9cadf3e 100644 --- a/linux/ui-tauri/src-tauri/Cargo.lock +++ b/linux/ui-tauri/src-tauri/Cargo.lock @@ -409,7 +409,7 @@ dependencies = [ "libc", "log", "macaddr", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "pin-project", @@ -1421,6 +1421,26 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fuser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82b6597d216503555ead6b358f341ef748869bf5c6fbae6a0cb9dd231baecfd" +dependencies = [ + "bitflags 2.11.1", + "libc", + "log", + "memchr", + "nix 0.31.3", + "num_enum", + "page_size", + "parking_lot", + "pkg-config", + "ref-cast", + "smallvec", + "zerocopy", +] + [[package]] name = "futures" version = "0.3.32" @@ -2817,6 +2837,19 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "8.0.0" @@ -3190,6 +3223,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.18.3" @@ -5379,6 +5422,7 @@ dependencies = [ "arboard", "ashpd", "bluer", + "fuser", "futures", "gstreamer", "gstreamer-app", @@ -6530,7 +6574,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand", "serde", diff --git a/linux/ui-tauri/src-tauri/Cargo.toml b/linux/ui-tauri/src-tauri/Cargo.toml index 034d2c2..19206e5 100644 --- a/linux/ui-tauri/src-tauri/Cargo.toml +++ b/linux/ui-tauri/src-tauri/Cargo.toml @@ -139,3 +139,10 @@ zbus = { version = "5", default-features = false, features = ["tokio"] } # The tray, spoken as StatusNotifierItem. Linux-only by nature, which is why # it lives here and not above. ksni = "0.3" +# The phone's storage as a real mount (`fs_mount`). Default features are empty +# on purpose: with `libfuse` this would need libfuse3 headers and pkg-config at +# BUILD time, which every packaging target would then have to carry. Without +# it, fuser mounts through the kernel directly and falls back to the setuid +# `fusermount3` binary — a runtime dependency already present anywhere FUSE +# works at all. +fuser = { version = "0.18", default-features = false } diff --git a/linux/ui-tauri/src-tauri/src/fs_cli.rs b/linux/ui-tauri/src-tauri/src/fs_cli.rs index 5f4c2ee..8ca6c3d 100644 --- a/linux/ui-tauri/src-tauri/src/fs_cli.rs +++ b/linux/ui-tauri/src-tauri/src/fs_cli.rs @@ -1,10 +1,12 @@ //! Command-line exercise of the filesystem protocol (design doc §8 step 1: //! "No mount yet — validate over the existing session with a CLI"). //! -//! There is no mount adapter yet and no UI for browsing, so without this the -//! only way to reach [`crate::fs_link`] would be to build one of those first -//! and debug two new things at once. These flags drive the client directly over -//! whatever session is already up, which is the same reason `--mirror` exists. +//! There is no UI for browsing yet, so without this the only way to reach +//! [`crate::fs_link`] would be to build one first and debug two new things at +//! once. These flags drive the client directly over whatever session is already +//! up, which is the same reason `--mirror` exists. `--fs-mount` is here for a +//! second reason as well: mounting the phone is a deliberate act, not something +//! that should happen behind the user's back on every connect. //! //! Results go to the app log (`~/.cache/vortex/vortex.log`), not the invoking //! terminal: single-instance forwards the argv to the *running* process, which @@ -132,9 +134,37 @@ pub(crate) async fn get(remote: String, local: String) { } } +/// `--fs-mount` / `--fs-umount` — put the phone's storage on the filesystem. +/// +/// The mount is not automatic: it costs a `fusermount3` and a kernel session, +/// and a mount pointing at a phone that is not here is worse than no mount. So +/// it is driven explicitly, and from here until there is a button for it. +#[cfg(target_os = "linux")] +pub(crate) async fn mount() { + match crate::fs_mount::mount().await { + Ok(dir) => tracing::info!("fs-cli: mounted at {}", dir.display()), + Err(e) => tracing::warn!("fs-cli: mount failed: {e}"), + } +} + /// Route an `--fs-*` flag. Returns false when `argv` holds none, so the caller /// can fall through to its other flags. pub(crate) fn dispatch(argv: &[String]) -> bool { + #[cfg(target_os = "linux")] + { + if argv.iter().any(|a| a == "--fs-umount") { + if crate::fs_mount::is_mounted() { + crate::fs_mount::unmount(); + } else { + tracing::info!("fs-cli: nothing mounted"); + } + return true; + } + if argv.iter().any(|a| a == "--fs-mount") { + tauri::async_runtime::spawn(mount()); + return true; + } + } if let Some(pos) = argv.iter().position(|a| a == "--fs-ls") { // Optional: no path means the synthetic root listing the peer's shares. let path = argv.get(pos + 1).cloned().unwrap_or_default(); diff --git a/linux/ui-tauri/src-tauri/src/fs_link.rs b/linux/ui-tauri/src-tauri/src/fs_link.rs index ccc97e2..3d93aff 100644 --- a/linux/ui-tauri/src-tauri/src/fs_link.rs +++ b/linux/ui-tauri/src-tauri/src/fs_link.rs @@ -7,9 +7,9 @@ //! through [`vortex_l3_daemon::core::fs_server`], gated by the roots config, //! and answered with `FS_META` / `FS_DATA` / `FS_ERR`. This is what lets the //! phone browse the laptop. -//! * **Client.** [`request`] issues an op to the phone and awaits its reply, -//! correlated by request id. This is what the mount adapter (FUSE / ProjFS) -//! will sit on top of. +//! * **Client.** [`round_trip`] issues an op to the phone and awaits its reply, +//! correlated by request id. `fs_mount` (FUSE, Linux) and [`crate::fs_pull`] +//! (file transfer) sit on top of it. //! //! Requests are **pipelined**: a file manager stats everything in view at once, //! so a request/response lock would feel broken. Each in-flight id owns a @@ -34,6 +34,15 @@ use vortex_l3_daemon::core::fs_server::{self, FsHandles, Served}; #[allow(dead_code)] const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Locally-generated code for "there is no link to the peer". +/// +/// `EHOSTDOWN`, so a mount adapter can hand the file manager an accurate +/// message rather than a generic I/O error. NOT part of [`p::code`]: it is +/// never sent and never received, because a peer that could answer would not +/// be down. It only travels from [`send`] to whoever asked. +#[allow(dead_code)] +pub(crate) const NO_LINK: i32 = 112; + /// Ranged reads kept in flight by [`read_all`]. /// /// Hides the round trip, not the disk: the peer serves reads under one lock so @@ -175,17 +184,27 @@ async fn serve_request(state: Arc, f: RawFrame) { Served::Data(bytes) => (ty::FS_DATA, bytes), Served::Err(e) => (ty::FS_ERR, serde_json::to_vec(&e).unwrap_or_default()), }; - send(&state, ty_byte, 0, payload).await; + if send(&state, ty_byte, 0, payload).await.is_err() { + // The peer asked over a link that has since gone. Nothing to do but + // drop it: it will not be waiting on a reply it can receive. + tracing::debug!("fs: dropped a reply, no link"); + } } -async fn send(state: &State, ty_byte: u8, sub: u8, payload: Vec) { +/// Put one frame on the best transport available. +/// +/// `Err` means it reached neither, which a client must be told rather than +/// left to discover through the 20 s timeout: a file manager blocked for 20 s +/// per operation on a phone that is simply not here is this feature's worst +/// outcome (design doc §6, "an honest, immediate error rather than hanging"). +async fn send(state: &State, ty_byte: u8, sub: u8, payload: Vec) -> Result<(), ()> { // Wi-Fi first, Bluetooth second (design doc §6). The two carry identical // frames, so this is only a routing choice — but a 48 KiB read is one TCP // frame and ~96 paced BLE fragments, which is the difference between a // copy taking a second and taking minutes. if let Some(w) = crate::fs_lan::writer().await { match w(ty_byte, sub, payload.clone()).await { - Ok(()) => return, + Ok(()) => return Ok(()), Err(e) => { // The session looked alive and wasn't — a phone that changed // network, or a socket the peer dropped. Fall through to BLE @@ -198,22 +217,25 @@ async fn send(state: &State, ty_byte: u8, sub: u8, payload: Vec) { } let w = { state.writer.lock().await.clone() }; let Some(w) = w else { - tracing::debug!("fs: no writer (link down); dropping a reply"); - return; + tracing::debug!("fs: no writer (link down)"); + return Err(()); }; if let Err(e) = w(ty_byte, sub, payload).await { tracing::warn!("fs: send 0x{ty_byte:02x} failed: {e}"); + return Err(()); } + Ok(()) } // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- -// Nothing calls the client half yet: its consumers are the mount adapter -// (FUSE / ProjFS) and the reworked file transfer, both of which land in later -// commits. Each item below carries `#[allow(dead_code)]` rather than the module -// carrying a blanket one, so genuine dead code here is still reported. +// The `#[allow(dead_code)]` on each item below, rather than a blanket one on the +// module, so genuine dead code here is still reported. They are needed because +// not every op has a consumer on every platform: `write` waits on design doc +// §8 step 5, and the FUSE consumer of the read path is Linux-only until ProjFS +// lands. /// Issue one op to the phone and await its reply. /// @@ -231,7 +253,12 @@ async fn round_trip(op: u8, id: u32, payload: Vec) -> Result { let mut g = state.inflight.lock().map_err(|_| code::IO)?; g.insert(id, tx); } - send(&state, ty::FS_REQ, op, payload).await; + if send(&state, ty::FS_REQ, op, payload).await.is_err() { + if let Ok(mut g) = state.inflight.lock() { + g.remove(&id); + } + return Err(NO_LINK); + } match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { Ok(Ok(reply)) => Ok(reply), // Sender dropped: the session ended under us. diff --git a/linux/ui-tauri/src-tauri/src/fs_mount.rs b/linux/ui-tauri/src-tauri/src/fs_mount.rs new file mode 100644 index 0000000..acdbd06 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/fs_mount.rs @@ -0,0 +1,1167 @@ +//! The phone's storage as a real filesystem, via FUSE (design doc §8 step 6). +//! +//! Dolphin, Nautilus, `cp`, mpv and every thumbnailer already know how to talk +//! to a filesystem, so the cheapest way to make the phone's files usable is to +//! be one. This module is the Linux **mount adapter**: it turns kernel FUSE +//! operations into the ranged-filesystem protocol and back. The phone is +//! untouched by it — that is the whole point of §2's "the phone serves a dumb, +//! narrow protocol; the laptop does everything clever". +//! +//! # Why FUSE and not the WebDAV gateway first +//! +//! The doc sequenced WebDAV ahead of this because one gateway serves both +//! operating systems. On Linux it buys nothing FUSE does not: GVFS/KIO mount +//! `davs://` in *their* process, so only their own file dialogs see the files — +//! `cp`, `mpv` and every non-KIO program do not. Windows' WebClient also caps a +//! file at ~50 MB, and escaping a 64 MB cap into a 50 MB one would be absurd. +//! A FUSE mount is a real path in the filesystem with no ceiling, and ProjFS +//! gives Windows the same later. +//! +//! # Concurrency, which is the load-bearing design decision +//! +//! FUSE hands us one request at a time on one thread. Answering each one +//! inline — issue the request, block on the phone's reply, return — would make +//! the mount as slow as the round trip *times* the number of operations, and a +//! file manager stats every visible file at once. So every operation is +//! immediately handed to the async runtime and its `Reply` object (which is +//! `Send`, deliberately) is answered from there. The session thread does +//! nothing but parse and dispatch. +//! +//! That is also what makes the kernel's own readahead work for us: a sequential +//! reader triggers several `read` calls at once, and because we never block, +//! they overlap on the wire instead of queueing. +//! +//! Two brakes on it: a semaphore caps how many requests may be on the link at +//! once (a thumbnailer will otherwise fire dozens and starve the BLE session +//! with them), and each `read` splits into at most [`READ_WINDOW`] pipelined +//! ranged reads. +//! +//! # Why a `FsRemote` trait +//! +//! The interesting bugs here are ours — inode identity, cache staleness, +//! reassembling a short read — and none of them need a phone to reproduce. The +//! trait lets the tests below drive the whole filesystem against a fake tree in +//! memory; [`LinkRemote`] is the one-line production implementation. + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::future::Future; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime}; + +use fuser::{ + Errno, FileAttr, FileType, FopenFlags, Generation, INodeNo, KernelConfig, MountOption, + OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, + ReplyOpen, ReplyStatfs, Request, +}; +use vortex_l3_daemon::core::fs_proto::{self as p, code}; + +/// How long the kernel may trust an attribute or a directory entry. +/// +/// This is the metadata cache design doc §7 asks for, and the kernel gives it +/// to us for free: within the TTL a `stat` never reaches this process, let +/// alone the phone. Five seconds is long enough to survive a file manager's +/// stat storm and short enough that a file changed on the phone shows up while +/// the user is still looking at the folder. +const ATTR_TTL: Duration = Duration::from_secs(5); + +/// How long *we* keep a directory listing. +/// +/// Separate from [`ATTR_TTL`] because it serves a different purpose: a listing +/// is how a child's opaque address is discovered at all (see [`Inner::listing`]), +/// so it is consulted on paths the kernel cache never reaches. +const DIR_TTL: Duration = Duration::from_secs(5); + +/// Requests allowed on the link at once. +/// +/// Not a throughput knob — a fairness one. A thumbnailer opening a folder of +/// photos will issue as many reads as there are files, and an unbounded queue +/// of them would delay every listing behind megabytes of image data and, on a +/// BLE fallback, starve the session that carries everything else. +const MAX_INFLIGHT: usize = 8; + +/// Ranged reads pipelined inside ONE FUSE read. +/// +/// The kernel asks for up to 128 KiB; the protocol caps a read at 48 KiB. Those +/// pieces are issued together rather than one after another, for the same +/// reason [`crate::fs_link::read_all`] does it. +const READ_WINDOW: usize = 4; + +/// The mount root. FUSE fixes this at 1; the phone's synthetic root (`""`, the +/// path that lists what it shares) lives here. +const ROOT_INO: u64 = 1; + +/// Entries we will hold for one directory. A guard against a peer that pages +/// forever, not a real limit — 200k files in one folder is already pathological. +const MAX_DIR_ENTRIES: usize = 200_000; + +/// Cached directories, and cached attributes, before expired ones are swept. +/// +/// The caches are keyed by inode and inodes are never recycled, so without a +/// sweep a long browse would hold every listing it ever fetched for the life of +/// the process — which for this app is days. Sweeping on insert past a +/// threshold keeps it bounded without a timer, and the cost lands on the +/// operation that grew the map. +const CACHE_SWEEP_AT: usize = 4_096; + +// --------------------------------------------------------------------------- +// The remote half +// --------------------------------------------------------------------------- + +/// The peer-facing operations the mount needs. +/// +/// Returns `impl Future + Send` rather than using `async fn` in the trait so +/// the futures can be `tokio::spawn`ed, which is the entire concurrency model +/// above. +pub(crate) trait FsRemote: Send + Sync + 'static { + fn list( + &self, + path: String, + cursor: u32, + ) -> impl Future, Option), i32>> + Send; + fn stat(&self, path: String) -> impl Future> + Send; + /// Returns `(handle, size at open time)`. + fn open(&self, path: String) -> impl Future> + Send; + /// Returns `(bytes, eof)`. A short result is normal. + fn read( + &self, + handle: u64, + offset: u64, + len: u32, + ) -> impl Future, bool), i32>> + Send; + fn close(&self, handle: u64) -> impl Future + Send; +} + +/// The production remote: the protocol client over whatever transport is up. +pub(crate) struct LinkRemote; + +// The trait declares `-> impl Future + Send`; an impl may satisfy that with a +// plain `async fn`, and the compiler still checks the future is `Send`. +impl FsRemote for LinkRemote { + async fn list( + &self, + path: String, + cursor: u32, + ) -> Result<(Vec, Option), i32> { + crate::fs_link::list(&path, cursor).await + } + async fn stat(&self, path: String) -> Result { + crate::fs_link::stat(&path).await + } + async fn open(&self, path: String) -> Result<(u64, u64), i32> { + // Never for writing: the mount is read-only (design doc §8 step 5). + crate::fs_link::open(&path, false).await + } + async fn read(&self, handle: u64, offset: u64, len: u32) -> Result<(Vec, bool), i32> { + crate::fs_link::read(handle, offset, len).await + } + async fn close(&self, handle: u64) { + crate::fs_link::close(handle).await + } +} + +// --------------------------------------------------------------------------- +// Inode identity +// --------------------------------------------------------------------------- + +/// Inode numbers ↔ peer addresses. +/// +/// A peer address is an opaque token, not a path — on Android it is a document +/// URI — so an inode number cannot be derived from one and the mapping has to +/// be remembered. Numbers are **never recycled**: a file manager holds inode +/// numbers across a refresh and reusing one would silently show it the wrong +/// file. The table therefore only grows, which is fine at a few dozen bytes per +/// entry for a session's worth of browsing. +#[derive(Default)] +struct Inodes { + by_ino: HashMap, + by_path: HashMap, + next: u64, +} + +impl Inodes { + fn new() -> Self { + let mut t = Self { + next: ROOT_INO + 1, + ..Default::default() + }; + // The peer's synthetic root: the empty path is what lists its shares. + t.by_ino.insert(ROOT_INO, String::new()); + t.by_path.insert(String::new(), ROOT_INO); + t + } + + fn intern(&mut self, path: &str) -> u64 { + if let Some(&ino) = self.by_path.get(path) { + return ino; + } + let ino = self.next; + self.next += 1; + self.by_ino.insert(ino, path.to_string()); + self.by_path.insert(path.to_string(), ino); + ino + } + + fn path(&self, ino: u64) -> Option<&str> { + self.by_ino.get(&ino).map(String::as_str) + } +} + +/// A file the kernel has open, keyed by the handle we handed back from `open`. +struct OpenFile { + /// The peer's handle. Ours is a separate number so a peer handle of 0 (or a + /// reused one) cannot collide with "no handle". + remote: u64, + /// Size as of `open`. Reads are clamped to it so we never ask the phone for + /// a range past the end just because the kernel rounded up to a page. + size: u64, +} + +struct Inner { + remote: R, + inodes: Mutex, + /// Listings by directory inode. + dirs: Mutex)>>, + /// Attributes by inode, seeded from listings. + attrs: Mutex>, + files: Mutex>, + next_fh: AtomicU64, + gate: tokio::sync::Semaphore, +} + +impl Inner { + fn new(remote: R) -> Self { + Self { + remote, + inodes: Mutex::new(Inodes::new()), + dirs: Mutex::new(HashMap::new()), + attrs: Mutex::new(HashMap::new()), + files: Mutex::new(HashMap::new()), + next_fh: AtomicU64::new(1), + gate: tokio::sync::Semaphore::new(MAX_INFLIGHT), + } + } + + // Every lock here is a `std::sync::Mutex` held for a single map operation + // and never across an `await`. Keeping that discipline is why the helpers + // are this granular. + + fn intern(&self, path: &str) -> u64 { + self.inodes + .lock() + .map(|mut t| t.intern(path)) + .unwrap_or(ROOT_INO) + } + + fn path_of(&self, ino: u64) -> Option { + self.inodes + .lock() + .ok() + .and_then(|t| t.path(ino).map(str::to_string)) + } + + fn cached_dir(&self, ino: u64) -> Option> { + let g = self.dirs.lock().ok()?; + let (at, entries) = g.get(&ino)?; + (at.elapsed() < DIR_TTL).then(|| entries.clone()) + } + + fn cached_attr(&self, ino: u64) -> Option { + let g = self.attrs.lock().ok()?; + let (at, entry) = g.get(&ino)?; + (at.elapsed() < ATTR_TTL).then(|| entry.clone()) + } + + fn store_attr(&self, ino: u64, entry: &p::FsEntry) { + if let Ok(mut g) = self.attrs.lock() { + if g.len() >= CACHE_SWEEP_AT { + g.retain(|_, (at, _)| at.elapsed() < ATTR_TTL); + } + g.insert(ino, (Instant::now(), entry.clone())); + } + } + + /// Run one peer request under the concurrency cap. + async fn gated(&self, f: impl Future) -> T { + // `acquire` only fails on a closed semaphore, and we never close it; + // proceeding uncapped beats failing the operation. + let _permit = self.gate.acquire().await; + f.await + } + + /// A directory's entries, from cache or from the peer. + /// + /// Also where child inodes are minted and the attribute cache is seeded: + /// a file manager follows every `readdir` with a `lookup` and a `getattr` + /// per entry, and answering those from the listing we already have is the + /// difference between one round trip per folder and one per file. + async fn listing(&self, ino: u64) -> Result, i32> { + if let Some(entries) = self.cached_dir(ino) { + return Ok(entries); + } + let path = self.path_of(ino).ok_or(code::NOENT)?; + let mut all: Vec = Vec::new(); + let mut cursor = 0u32; + loop { + let (page, next) = self.gated(self.remote.list(path.clone(), cursor)).await?; + all.extend(page); + match next { + // A peer that keeps handing back the same cursor is not making + // progress; stopping with a partial listing beats looping. + Some(c) if c != cursor && all.len() < MAX_DIR_ENTRIES => cursor = c, + _ => break, + } + } + // An entry with no address cannot be opened or listed, so it would + // appear as a permanently broken row. Drop it and say so once. + let before = all.len(); + all.retain(|e| !e.path.is_empty()); + if all.len() != before { + tracing::warn!( + dropped = before - all.len(), + "fs-mount: listing had entries with no address" + ); + } + for e in &all { + let child = self.intern(&e.path); + self.store_attr(child, e); + } + if let Ok(mut g) = self.dirs.lock() { + if g.len() >= CACHE_SWEEP_AT { + g.retain(|_, (at, _)| at.elapsed() < DIR_TTL); + } + g.insert(ino, (Instant::now(), all.clone())); + } + Ok(all) + } + + /// Resolve one name inside a directory. + /// + /// Goes through the parent's listing rather than joining the name onto the + /// parent's path, because a child's address is opaque: under SAF a name is + /// simply not addressable, and constructing `parent/name` would produce a + /// path the phone cannot resolve. + async fn lookup_child(&self, parent: u64, name: &str) -> Result<(u64, p::FsEntry), i32> { + let entries = self.listing(parent).await?; + let entry = entries + .into_iter() + .find(|e| e.name == name) + .ok_or(code::NOENT)?; + let ino = self.intern(&entry.path); + self.store_attr(ino, &entry); + Ok((ino, entry)) + } + + /// One inode's attributes. + async fn entry_of(&self, ino: u64) -> Result { + if ino == ROOT_INO { + return Ok(root_entry()); + } + if let Some(e) = self.cached_attr(ino) { + return Ok(e); + } + let path = self.path_of(ino).ok_or(code::NOENT)?; + let entry = self.gated(self.remote.stat(path)).await?; + self.store_attr(ino, &entry); + Ok(entry) + } + + /// Read `size` bytes at `offset` from an open file, as one contiguous run. + /// + /// Splits into protocol-sized pieces and keeps [`READ_WINDOW`] of them in + /// flight. `FuturesOrdered` yields in issue order, which is also offset + /// order, so the pieces concatenate directly — and each future carries the + /// offset it asked for, so a short piece is *detected* rather than silently + /// shifting everything after it. On a gap we return the prefix: a FUSE read + /// must be contiguous from `offset`, and a short reply is a legal answer. + async fn read_range(&self, remote: u64, offset: u64, size: u32) -> Result, i32> { + use futures::stream::{FuturesOrdered, StreamExt}; + + let end = offset.saturating_add(size as u64); + let mut out: Vec = Vec::new(); + let mut pending = FuturesOrdered::new(); + let mut next = offset; + let mut expect = offset; + loop { + while pending.len() < READ_WINDOW && next < end { + let at = next; + let len = (end - at).min(p::MAX_READ_LEN as u64) as u32; + pending.push_back(async move { + (at, self.gated(self.remote.read(remote, at, len)).await) + }); + next = at + len as u64; + } + let Some((at, res)) = pending.next().await else { + break; + }; + let (bytes, eof) = res?; + if at != expect { + // An earlier piece came back short, so this one starts past the + // end of what we have. Anything further would land at the wrong + // file offset. + break; + } + expect = at + bytes.len() as u64; + out.extend_from_slice(&bytes); + if eof || bytes.is_empty() { + break; + } + } + Ok(out) + } +} + +// --------------------------------------------------------------------------- +// Translation +// --------------------------------------------------------------------------- + +/// The mount root's own attributes. +/// +/// Synthetic rather than a `STAT` of the empty path: the peer's root is a list +/// of what it shares, not a directory it can stat, and `ls` of the mount point +/// must work regardless. `UNIX_EPOCH` rather than "now" so the kernel does not +/// see the root's mtime change on every remount. +fn root_entry() -> p::FsEntry { + p::FsEntry { + name: "/".to_string(), + path: String::new(), + is_dir: true, + size: 0, + mtime: 0, + readonly: true, + } +} + +/// A protocol entry as a kernel `stat`. +/// +/// Permissions are fixed rather than reported by the peer: the mount is +/// read-only until design doc §8 step 5 lands, and a writable-looking mode bit +/// would only get a copy half-way through before the phone refused it. `nlink` +/// of 2 for a directory is the usual lie (`.` and `..`) — the real subdirectory +/// count would cost a listing per stat. +fn attr_of(ino: u64, e: &p::FsEntry, uid: u32, gid: u32) -> FileAttr { + let mtime = mtime_of(e.mtime); + FileAttr { + ino: INodeNo(ino), + size: if e.is_dir { 0 } else { e.size }, + blocks: e.size.div_ceil(512), + atime: mtime, + mtime, + ctime: mtime, + crtime: mtime, + kind: if e.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }, + perm: if e.is_dir { 0o555 } else { 0o444 }, + nlink: if e.is_dir { 2 } else { 1 }, + uid, + gid, + rdev: 0, + blksize: 4096, + flags: 0, + } +} + +/// Seconds since the epoch as a `SystemTime`, tolerating the 0 the protocol +/// uses for "the peer cannot tell" and the negative values a badly-set phone +/// clock can produce. +fn mtime_of(secs: i64) -> SystemTime { + if secs >= 0 { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs as u64) + } else { + SystemTime::UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs()) + } +} + +/// A protocol code as an errno. +/// +/// The reason [`code`] is errno-shaped in the first place: this is meant to be +/// a rename, not a translation. What the file manager shows the user comes +/// straight from here, so [`crate::fs_link::NO_LINK`] mapping to `EHOSTDOWN` +/// ("Host is down") rather than a generic I/O error is the difference between +/// an accurate message and a puzzling one. +fn errno_of(c: i32) -> Errno { + match c { + code::NOENT => Errno::ENOENT, + code::ACCES => Errno::EACCES, + code::BADF => Errno::EBADF, + code::INVAL => Errno::EINVAL, + code::NOTSUP => Errno::ENOTSUP, + code::ISDIR => Errno::EISDIR, + code::ROFS => Errno::EROFS, + crate::fs_link::NO_LINK => Errno::EHOSTDOWN, + // Includes `code::IO`, and anything a future peer invents. + _ => Errno::EIO, + } +} + +// --------------------------------------------------------------------------- +// The filesystem +// --------------------------------------------------------------------------- + +struct PhoneFs { + inner: Arc>, + rt: tokio::runtime::Handle, +} + +/// Hand `body` the runtime and let it answer whenever the phone does. +/// +/// Every operation goes through here, which is what keeps the FUSE session +/// thread free to dispatch the next one. Nothing waits on the result: the +/// `Reply` carries the request id, so the answer finds its way back on its own. +macro_rules! detach { + ($fs:expr, |$inner:ident| $body:block) => {{ + let $inner = $fs.inner.clone(); + $fs.rt.spawn(async move { $body }); + }}; +} + +impl fuser::Filesystem for PhoneFs { + fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> std::io::Result<()> { + // Readahead is the one §7 item the kernel implements for us: it turns a + // sequential reader into several overlapping `read` calls, and because + // we never block one, they overlap on the wire too. Ask for as much as + // it will give (it clamps and reports what it took). + let readahead = config.set_max_readahead(1024 * 1024).unwrap_or_else(|max| { + let _ = config.set_max_readahead(max); + max + }); + // Background requests are how many of those may be outstanding. Ours + // are answered off-thread, so a deeper queue costs nothing here. + let _ = config.set_max_background(MAX_INFLIGHT as u16 * 2); + tracing::info!(readahead, "fs-mount: kernel session up"); + Ok(()) + } + + fn lookup(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + // Names arrive from the kernel as bytes and reach us over JSON, so a + // name that is not UTF-8 cannot round-trip in the first place; matching + // lossily just makes it miss rather than panic. + let name = name.to_string_lossy().to_string(); + let (uid, gid) = (req.uid(), req.gid()); + let parent = parent.0; + detach!(self, |inner| { + match inner.lookup_child(parent, &name).await { + Ok((ino, e)) => reply.entry(&ATTR_TTL, &attr_of(ino, &e, uid, gid), Generation(0)), + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn getattr(&self, req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { + let (uid, gid) = (req.uid(), req.gid()); + let ino = ino.0; + detach!(self, |inner| { + match inner.entry_of(ino).await { + Ok(e) => reply.attr(&ATTR_TTL, &attr_of(ino, &e, uid, gid)), + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn readdir( + &self, + _req: &Request, + ino: INodeNo, + _fh: fuser::FileHandle, + offset: u64, + mut reply: ReplyDirectory, + ) { + let ino = ino.0; + detach!(self, |inner| { + let entries = match inner.listing(ino).await { + Ok(v) => v, + Err(c) => return reply.error(errno_of(c)), + }; + // `.` and `..` occupy the first two slots so the rest of the + // indices line up with the listing. `..` points at this directory + // for the root, and at *this* directory elsewhere too: the parent + // is not knowable from an opaque address, and no caller resolves + // `..` through us — the kernel remembers the path it walked. + for (i, (child, kind, name)) in std::iter::once((ino, FileType::Directory, ".".into())) + .chain(std::iter::once((ino, FileType::Directory, "..".into()))) + .chain(entries.iter().map(|e| { + ( + inner.intern(&e.path), + if e.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }, + e.name.clone(), + ) + })) + .enumerate() + .skip(offset as usize) + { + // The offset we hand back is where to RESUME, hence i + 1. + if reply.add(INodeNo(child), i as u64 + 1, kind, &name) { + break; + } + } + reply.ok(); + }); + } + + fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + // The kernel enforces `ro` at the mount before reaching us, so this is + // belt-and-braces for a caller that got here another way. + if flags.acc_mode() != OpenAccMode::O_RDONLY { + return reply.error(Errno::EROFS); + } + let ino = ino.0; + detach!(self, |inner| { + let Some(path) = inner.path_of(ino) else { + return reply.error(Errno::ENOENT); + }; + match inner.gated(inner.remote.open(path)).await { + Ok((remote, size)) => { + let fh = inner.next_fh.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = inner.files.lock() { + g.insert(fh, OpenFile { remote, size }); + } + // No flags: keeping the page cache is what lets the kernel + // serve a re-read without us, and read ahead of the reader. + reply.opened(fuser::FileHandle(fh), FopenFlags::empty()); + } + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn read( + &self, + _req: &Request, + _ino: INodeNo, + fh: fuser::FileHandle, + offset: u64, + size: u32, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyData, + ) { + let fh = fh.0; + detach!(self, |inner| { + let Some((remote, file_size)) = inner + .files + .lock() + .ok() + .and_then(|g| g.get(&fh).map(|f| (f.remote, f.size))) + else { + return reply.error(Errno::EBADF); + }; + // Past the end is an empty read, not an error — and clamping here + // saves a round trip for the page-sized overshoot the kernel makes + // at the end of every file. + if offset >= file_size { + return reply.data(&[]); + } + let want = (file_size - offset).min(size as u64) as u32; + match inner.read_range(remote, offset, want).await { + Ok(bytes) => reply.data(&bytes), + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn release( + &self, + _req: &Request, + _ino: INodeNo, + fh: fuser::FileHandle, + _flags: OpenFlags, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + let fh = fh.0; + detach!(self, |inner| { + let handle = inner.files.lock().ok().and_then(|mut g| g.remove(&fh)); + // Answer the kernel first: `close()` cannot fail from here and the + // caller should not wait on a phone round trip to return from it. + reply.ok(); + if let Some(f) = handle { + inner.gated(inner.remote.close(f.remote)).await; + } + }); + } + + fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) { + // Zeroed on purpose. There is no protocol op for free space, and a made + // up figure would be a lie a file manager acts on. Zero free on a + // read-only mount is at least the truth: nothing can be written here. + // Revisit with design doc §8 step 5, which is when a real number starts + // to matter. + reply.statfs(0, 0, 0, 0, 0, 4096, 255, 4096); + } +} + +// --------------------------------------------------------------------------- +// Mount lifecycle +// --------------------------------------------------------------------------- + +/// The live session. Dropping it unmounts, which is what cleans up on a normal +/// app exit. +static SESSION: OnceLock>> = OnceLock::new(); + +fn session_slot() -> &'static Mutex> { + SESSION.get_or_init(|| Mutex::new(None)) +} + +/// Where the phone's files appear. +/// +/// Under `XDG_RUNTIME_DIR` because the session lifetime is exactly right: the +/// directory goes away at logout, so a crashed app cannot leave a stale mount +/// point in the user's home. One fixed name rather than one per phone, because +/// the protocol client sends to whichever peer is *active* — a per-phone mount +/// is not expressible until it takes a peer (design doc §9). +pub(crate) fn mount_point() -> PathBuf { + let base = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join("vortex").join("phone") +} + +/// Mount the phone's storage. Returns the mount point. +/// +/// Idempotent: a second call while mounted returns the same path rather than +/// tearing the mount down under whoever is using it. +pub(crate) async fn mount() -> Result { + if let Ok(g) = session_slot().lock() { + if g.is_some() { + return Ok(mount_point()); + } + } + let dir = mount_point(); + let rt = tokio::runtime::Handle::current(); + // `Session::new` runs `fusermount3` and waits for the kernel's INIT, so it + // does not belong on an async thread. + tokio::task::spawn_blocking(move || mount_blocking(rt, dir)) + .await + .map_err(|e| format!("mount task failed: {e}"))? +} + +fn mount_blocking(rt: tokio::runtime::Handle, dir: PathBuf) -> Result { + let bg = spawn_session(rt, &dir, LinkRemote)?; + if let Ok(mut g) = session_slot().lock() { + *g = Some(bg); + } + tracing::info!(path = %dir.display(), "fs-mount: mounted"); + Ok(dir) +} + +/// Mount `remote` at `dir` and start serving it. +/// +/// Generic over the remote so the integration test at the bottom of this file +/// can mount its fake peer for real — kernel, session thread and all — which is +/// the only way to check that what we tell the kernel is what a program reading +/// the mount actually sees. +fn spawn_session( + rt: tokio::runtime::Handle, + dir: &PathBuf, + remote: R, +) -> Result { + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + // A previous run that died without unmounting leaves the path occupied, and + // the mount would fail with a confusing EBUSY. Only ever our own private + // path under XDG_RUNTIME_DIR, and a no-op when nothing is mounted there. + let _ = std::process::Command::new("fusermount3") + .args(["-quz", &dir.to_string_lossy()]) + .stderr(std::process::Stdio::null()) + .status(); + + let fs = PhoneFs { + inner: Arc::new(Inner::new(remote)), + rt, + }; + let mut config = fuser::Config::default(); + config.mount_options = vec![ + // What `mount` and `df` call it. + MountOption::FSName("vortex".into()), + MountOption::Subtype("vortex".into()), + // Read-only until design doc §8 step 5. Enforced by the kernel, so a + // write is refused without a round trip to the phone. + MountOption::RO, + MountOption::NoSuid, + MountOption::NoDev, + MountOption::NoExec, + ]; + // One session thread is enough: it only parses a request and hands it to + // the runtime, so it is never the thing that is busy. + fuser::Session::new(fs, dir, &config) + .map_err(|e| format!("mounting {} failed: {e}", dir.display()))? + .spawn() + .map_err(|e| format!("session thread failed: {e}")) +} + +/// Unmount, if mounted. +pub(crate) fn unmount() { + let taken = session_slot().lock().ok().and_then(|mut g| g.take()); + if let Some(bg) = taken { + // `umount_and_join` waits for the session loop to finish, which needs + // the kernel to have released the mount — a blocking call, so keep it + // off the async threads. + std::thread::spawn(move || match bg.umount_and_join() { + Ok(()) => tracing::info!("fs-mount: unmounted"), + Err(e) => tracing::warn!("fs-mount: unmount failed: {e}"), + }); + } +} + +/// Detach the mount on the process's way out. +/// +/// A FUSE mount whose server process has died is not gone — it stays in the +/// mount table answering `ENOTCONN`, which makes `df` error and leaves a broken +/// entry in every file manager. So the deliberate-quit path detaches it first. +/// +/// `fusermount3 -z` rather than [`unmount`] because this runs microseconds +/// before `exit()`: the lazy form returns immediately and lets the kernel +/// finish when the last user of the mount goes away, where waiting for the +/// session thread to join would simply be killed half-way. +pub(crate) fn unmount_on_exit() { + if !is_mounted() { + return; + } + let dir = mount_point(); + let _ = std::process::Command::new("fusermount3") + .args(["-quz", &dir.to_string_lossy()]) + .stderr(std::process::Stdio::null()) + .status(); +} + +/// Whether the phone's files are currently mounted. +pub(crate) fn is_mounted() -> bool { + session_slot().lock().map(|g| g.is_some()).unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A peer with a fixed tree, so the filesystem logic can be tested without + /// a phone, a kernel or a link. Counts requests: most of what this module + /// does is avoid making them. + struct FakePeer { + /// path → entries, for directories. + dirs: HashMap>, + /// path → contents, for files. + files: HashMap>, + lists: std::sync::atomic::AtomicU32, + stats: std::sync::atomic::AtomicU32, + reads: std::sync::atomic::AtomicU32, + } + + fn dir(name: &str, path: &str) -> p::FsEntry { + p::FsEntry { + name: name.into(), + path: path.into(), + is_dir: true, + size: 0, + mtime: 1_700_000_000, + readonly: true, + } + } + + fn file(name: &str, path: &str, size: u64) -> p::FsEntry { + p::FsEntry { + name: name.into(), + path: path.into(), + is_dir: false, + size, + mtime: 1_700_000_000, + readonly: true, + } + } + + impl FakePeer { + fn new() -> Self { + // 100 KiB, so a read of it spans three protocol reads. + let big: Vec = (0..100 * 1024).map(|i| (i % 251) as u8).collect(); + let mut dirs = HashMap::new(); + dirs.insert( + String::new(), + vec![dir("DCIM", "/sdcard/DCIM"), file("a.txt", "/sdcard/a.txt", 5)], + ); + dirs.insert( + "/sdcard/DCIM".to_string(), + vec![file("big.bin", "/sdcard/DCIM/big.bin", big.len() as u64)], + ); + let mut files = HashMap::new(); + files.insert("/sdcard/a.txt".to_string(), b"hello".to_vec()); + files.insert("/sdcard/DCIM/big.bin".to_string(), big); + Self { + dirs, + files, + lists: Default::default(), + stats: Default::default(), + reads: Default::default(), + } + } + } + + impl FsRemote for Arc { + fn list( + &self, + path: String, + cursor: u32, + ) -> impl Future, Option), i32>> + Send { + let me = self.clone(); + async move { + me.lists.fetch_add(1, Ordering::Relaxed); + let all = me.dirs.get(&path).ok_or(code::NOENT)?; + // One entry per page, so pagination is exercised rather than + // assumed. + let at = cursor as usize; + match all.get(at) { + Some(e) => Ok(( + vec![e.clone()], + (at + 1 < all.len()).then_some(cursor + 1), + )), + None => Ok((vec![], None)), + } + } + } + + fn stat(&self, path: String) -> impl Future> + Send { + let me = self.clone(); + async move { + me.stats.fetch_add(1, Ordering::Relaxed); + me.dirs + .values() + .flatten() + .find(|e| e.path == path) + .cloned() + .ok_or(code::NOENT) + } + } + + fn open(&self, path: String) -> impl Future> + Send { + let me = self.clone(); + async move { + let bytes = me.files.get(&path).ok_or(code::NOENT)?; + // The handle IS the path's index; enough to read it back. + let idx = me.files.keys().position(|k| k == &path).unwrap() as u64; + Ok((idx + 1, bytes.len() as u64)) + } + } + + fn read( + &self, + handle: u64, + offset: u64, + len: u32, + ) -> impl Future, bool), i32>> + Send { + let me = self.clone(); + async move { + me.reads.fetch_add(1, Ordering::Relaxed); + let key = me + .files + .keys() + .nth(handle as usize - 1) + .cloned() + .ok_or(code::BADF)?; + let bytes = &me.files[&key]; + let at = (offset as usize).min(bytes.len()); + let to = (at + len as usize).min(bytes.len()); + Ok((bytes[at..to].to_vec(), to >= bytes.len())) + } + } + + async fn close(&self, _handle: u64) {} + } + + fn fs() -> (Arc>>, Arc) { + let peer = Arc::new(FakePeer::new()); + (Arc::new(Inner::new(peer.clone())), peer) + } + + #[tokio::test] + async fn listing_follows_pagination_to_the_end() { + let (fs, peer) = fs(); + let entries = fs.listing(ROOT_INO).await.unwrap(); + assert_eq!( + entries.iter().map(|e| e.name.as_str()).collect::>(), + ["DCIM", "a.txt"] + ); + // Two pages plus the one that reports the end. + assert_eq!(peer.lists.load(Ordering::Relaxed), 2); + } + + #[tokio::test] + async fn a_listing_answers_the_lookups_that_follow_it() { + let (fs, peer) = fs(); + fs.listing(ROOT_INO).await.unwrap(); + let before = peer.lists.load(Ordering::Relaxed); + let (ino, e) = fs.lookup_child(ROOT_INO, "a.txt").await.unwrap(); + assert_eq!(e.path, "/sdcard/a.txt"); + // The point of the exercise: no further round trips, of any kind. + assert_eq!(peer.lists.load(Ordering::Relaxed), before); + assert_eq!(peer.stats.load(Ordering::Relaxed), 0); + assert_eq!(fs.entry_of(ino).await.unwrap().name, "a.txt"); + assert_eq!(peer.stats.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn an_inode_is_stable_and_never_reused() { + let (fs, _) = fs(); + let (first, _) = fs.lookup_child(ROOT_INO, "a.txt").await.unwrap(); + let (again, _) = fs.lookup_child(ROOT_INO, "a.txt").await.unwrap(); + assert_eq!(first, again, "the same file must keep its inode number"); + let (other, _) = fs.lookup_child(ROOT_INO, "DCIM").await.unwrap(); + assert_ne!(first, other); + assert_ne!(first, ROOT_INO); + } + + #[tokio::test] + async fn a_missing_name_is_noent_not_a_hang() { + let (fs, _) = fs(); + assert_eq!( + fs.lookup_child(ROOT_INO, "nope").await.unwrap_err(), + code::NOENT + ); + } + + #[tokio::test] + async fn the_root_stats_without_asking_the_peer() { + let (fs, peer) = fs(); + let e = fs.entry_of(ROOT_INO).await.unwrap(); + assert!(e.is_dir); + assert_eq!(peer.stats.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn a_read_larger_than_the_protocol_limit_is_split_and_reassembled() { + let (fs, peer) = fs(); + let (_, e) = fs.lookup_child(ROOT_INO, "DCIM").await.unwrap(); + let sub = fs.intern(&e.path); + let (_, big) = fs.lookup_child(sub, "big.bin").await.unwrap(); + let (handle, size) = fs.remote.open(big.path.clone()).await.unwrap(); + assert_eq!(size, 100 * 1024); + + // One kernel-sized read: 128 KiB clamped to the file, three protocol + // reads of 48/48/4 KiB. + let bytes = fs.read_range(handle, 0, size as u32).await.unwrap(); + assert_eq!(bytes.len(), size as usize); + assert_eq!(peer.reads.load(Ordering::Relaxed), 3); + let expected: Vec = (0..100 * 1024).map(|i| (i % 251) as u8).collect(); + assert_eq!(bytes, expected, "reassembled in the wrong order"); + } + + #[tokio::test] + async fn a_read_at_an_offset_starts_there() { + let (fs, _) = fs(); + let (handle, size) = fs + .remote + .open("/sdcard/DCIM/big.bin".to_string()) + .await + .unwrap(); + let at = 70 * 1024; + let bytes = fs.read_range(handle, at, (size - at) as u32).await.unwrap(); + let expected: Vec = (at..size).map(|i| (i % 251) as u8).collect(); + assert_eq!(bytes, expected); + } + + #[tokio::test] + async fn reading_past_the_end_yields_nothing() { + let (fs, _) = fs(); + let (handle, size) = fs.remote.open("/sdcard/a.txt".to_string()).await.unwrap(); + assert!(fs.read_range(handle, size, 4096).await.unwrap().is_empty()); + } + + #[test] + fn a_directory_and_a_file_translate_to_the_right_stat() { + let d = attr_of(7, &dir("DCIM", "/sdcard/DCIM"), 1000, 1000); + assert_eq!(d.kind, FileType::Directory); + assert_eq!(d.perm, 0o555); + assert_eq!(d.ino, INodeNo(7)); + let f = attr_of(8, &file("a.txt", "/sdcard/a.txt", 5), 1000, 1000); + assert_eq!(f.kind, FileType::RegularFile); + assert_eq!(f.perm, 0o444, "the mount is read-only"); + assert_eq!(f.size, 5); + assert_eq!(f.blocks, 1); + assert_eq!( + f.mtime, + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) + ); + } + + #[test] + fn an_unknown_mtime_is_the_epoch_not_a_panic() { + assert_eq!(mtime_of(0), SystemTime::UNIX_EPOCH); + // A phone with a clock set before 1970 must not take the mount down. + assert!(mtime_of(-86_400) < SystemTime::UNIX_EPOCH); + } + + #[test] + fn every_protocol_code_reaches_the_user_as_itself() { + assert_eq!(errno_of(code::NOENT), Errno::ENOENT); + assert_eq!(errno_of(code::ACCES), Errno::EACCES); + assert_eq!(errno_of(code::ROFS), Errno::EROFS); + assert_eq!(errno_of(code::ISDIR), Errno::EISDIR); + assert_eq!(errno_of(crate::fs_link::NO_LINK), Errno::EHOSTDOWN); + assert_eq!(errno_of(code::IO), Errno::EIO); + assert_eq!(errno_of(9999), Errno::EIO, "an unknown code is still an error"); + } + + /// The whole thing, for real: a kernel mount over the fake peer, driven by + /// ordinary `std::fs` calls. + /// + /// `#[ignore]` because it needs `/dev/fuse` and `fusermount3`, which a + /// container or a build box may not have, and a test that cannot run there + /// should not look like a failure. Run it with: + /// + /// ```text + /// cargo test --lib fs_mount -- --ignored --nocapture + /// ``` + /// + /// Multi-threaded on purpose: the syscalls block a thread while the FUSE + /// operations they trigger are answered on another. + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs /dev/fuse and fusermount3"] + async fn a_real_mount_answers_ordinary_file_calls() { + let peer = Arc::new(FakePeer::new()); + let dir = std::env::temp_dir().join(format!("vortex-fuse-{}", std::process::id())); + let session = spawn_session(tokio::runtime::Handle::current(), &dir, peer.clone()) + .expect("mount failed — is /dev/fuse available?"); + + let at = dir.clone(); + let seen = tokio::task::spawn_blocking(move || { + let mut names: Vec = std::fs::read_dir(&at) + .expect("read_dir") + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + names.sort(); + let small = std::fs::read_to_string(at.join("a.txt")).expect("read a.txt"); + let big = std::fs::read(at.join("DCIM").join("big.bin")).expect("read big.bin"); + let meta = std::fs::metadata(at.join("DCIM")).expect("stat DCIM"); + // Writes must be refused by the kernel, without reaching the peer. + let write = std::fs::write(at.join("nope.txt"), b"x"); + (names, small, big, meta.is_dir(), write.is_err()) + }) + .await + .unwrap(); + + let (names, small, big, dcim_is_dir, write_refused) = seen; + assert_eq!(names, ["DCIM", "a.txt"]); + assert_eq!(small, "hello"); + assert!(dcim_is_dir); + assert!(write_refused, "the mount must be read-only"); + let expected: Vec = (0..100 * 1024).map(|i| (i % 251) as u8).collect(); + assert_eq!(big, expected, "100 KiB came back wrong through the kernel"); + + tokio::task::spawn_blocking(move || { + let _ = session.umount_and_join(); + let _ = std::fs::remove_dir(&dir); + }) + .await + .unwrap(); + } +} diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 3ae4802..1950edd 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -84,6 +84,10 @@ mod share; mod file_consent; mod fs_cli; mod fs_lan; +// The phone's storage as a FUSE mount. Linux-only by nature: the Windows half +// of design doc §8 step 6 is ProjFS, a different API for the same protocol. +#[cfg(target_os = "linux")] +mod fs_mount; mod fs_pull; mod contacts; mod desktop_apps; From 61d3b7fc1319ca91161983d1ca223534bbf5e573 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Mon, 7 Sep 2026 20:11:05 +0200 Subject: [PATCH 51/71] fix(fs): answer the ops the kernel actually asks, and remount over a corpse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by mounting a phone for real. Every path walk asked us `access`, every `close` asked `flush`, and Dolphin and `cp -a` ask `getxattr`/`listxattr`. Unimplemented, each answers ENOSYS, which the kernel copes with but fuser logs as "[Not Implemented]" — so an ordinary `ls` wrote warnings into the app's log and spent a round trip through the session thread to answer a question with a local answer. `flush` is nothing to do on a read-only mount, there are no xattrs and cannot be (the protocol carries a name, a kind, a size and an mtime), and `default_permissions` hands the access check to the kernel, which has the mode bits we reported and stops sending `access` altogether. Warnings to zero, and a 52 MB read from 8.6 s to 7.4 s. The stale-mount recovery ran too late to work. A mount whose server process was SIGKILLed — a crash, or install_linux.sh restarting the app — stays in the mount table with nothing behind it, answering ENOTCONN to every syscall. That includes the `stat` inside `create_dir_all`, which therefore fails with EEXIST: the directory is there, it just cannot be looked at. So mounting failed with "cannot create /run/user/1000/vortex/phone: File exists" before reaching the `fusermount3 -quz` that exists precisely to clear this. Clearing first, then creating. Live results are in the design doc: a 52 MB video md5-identical at 7.1 MB/s and readable by ffprobe, a 481 MB APK at 7.3 MB/s with the phone's heap flat, a 3.4 GB zip listed and read correctly at offset 3.4e9, and `touch` refused by the kernel. Co-Authored-By: Claude Opus 5 (1M context) --- docs/design/file-browsing.md | 31 ++++++++++++ linux/ui-tauri/src-tauri/src/fs_mount.rs | 61 ++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index bccc3d8..3c65cfd 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -319,6 +319,37 @@ This is where these features usually fail, and it is all daemon-side: a write refused. That test needs `/dev/fuse`, so it is `#[ignore]`d and run with `cargo test --lib fs_mount -- --ignored`. + **Verified on the device** (2026-09-07, over Wi-Fi, all-files root). The + phone's shared storage appeared at `/run/user/1000/vortex/phone` as + `fuse.vortex (ro,nosuid,nodev,noexec,default_permissions)` with real names + and mtimes; a cold listing took ~300 ms and a subdirectory 12 ms. + + * A 52 MB 4K video: `md5sum` matched the phone's in 7.4 s (7.1 MB/s), and + `ffprobe` read its codec, resolution and duration — a real seeking + consumer, not just a sequential one. + * A 481 MB APK: `cat | md5sum` matched in 66 s (7.3 MB/s) while the phone's + Java heap went 30.7 MB → 17.6 MB (a GC ran) and its native heap sat at + 12.1 MB. Nothing is buffered, at 7.5x the size of the cap this feature + started out working around. + * A 3.4 GB ROM zip listed with its true size, and the last 64 KiB read at + offset 3,396,354,250 matched the phone's md5 of the same range — past + 2^31, so the 64-bit offsets survive the whole stack. + * `touch` in the mount: "Read-only file system", refused by the kernel + without a round trip. + + Two things that only a live run surfaced. Every path walk was asking us + `access` and every `close` a `flush`, and `getxattr`/`listxattr` on top — + fuser logs each as "[Not Implemented]", so an ordinary `ls` wrote warnings + into the app's log and paid a session round trip to answer "yes". Answering + them locally (and handing permission checks to the kernel with + `default_permissions`, which is what stops `access` being sent at all) took + that to zero and the 52 MB read from 8.6 s to 7.4 s. And the stale-mount + recovery was in the wrong order: a mount whose server was SIGKILLed (a + crash, or the installer restarting the app) stays in the table answering + `ENOTCONN`, which includes the `stat` inside `create_dir_all` — so + *creating* the mount point failed with EEXIST before the code that clears + the corpse ever ran. + Steps 1–2 are worth doing regardless of whether the mount ever ships, which is the main argument for this ordering. diff --git a/linux/ui-tauri/src-tauri/src/fs_mount.rs b/linux/ui-tauri/src-tauri/src/fs_mount.rs index acdbd06..8dddeff 100644 --- a/linux/ui-tauri/src-tauri/src/fs_mount.rs +++ b/linux/ui-tauri/src-tauri/src/fs_mount.rs @@ -54,7 +54,7 @@ use std::time::{Duration, Instant, SystemTime}; use fuser::{ Errno, FileAttr, FileType, FopenFlags, Generation, INodeNo, KernelConfig, MountOption, OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, - ReplyOpen, ReplyStatfs, Request, + ReplyOpen, ReplyStatfs, ReplyXattr, Request, }; use vortex_l3_daemon::core::fs_proto::{self as p, code}; @@ -690,6 +690,45 @@ impl fuser::Filesystem for PhoneFs { }); } + // ── Ops answered without asking the phone ──────────────────────────── + // + // Left unimplemented, each of these answers `ENOSYS`, which the kernel + // handles but fuser logs as "[Not Implemented]" — a warning per call in + // the app's log, several per file opened. Answering them here costs + // nothing and the answers are all knowable locally. + + /// Nothing to flush: the mount is read-only, and a read has no state on the + /// peer beyond the handle `release` will close. + fn flush( + &self, + _req: &Request, + _ino: INodeNo, + _fh: fuser::FileHandle, + _lock_owner: fuser::LockOwner, + reply: ReplyEmpty, + ) { + reply.ok(); + } + + /// No extended attributes, and no way to have any: the protocol carries a + /// name, a kind, a size and an mtime, and nothing else. `ENODATA` is the + /// answer for "this attribute is not set", which is the truth for every + /// name that could be asked about. + fn getxattr(&self, _req: &Request, _ino: INodeNo, _name: &OsStr, _size: u32, reply: ReplyXattr) { + reply.error(Errno::ENODATA); + } + + /// An empty list, not an error — `cp -a` and Dolphin both ask, and a + /// failure here reads to them as a file they could not fully inspect. + fn listxattr(&self, _req: &Request, _ino: INodeNo, size: u32, reply: ReplyXattr) { + // The two-call protocol: size 0 asks how much room to allocate. + if size == 0 { + reply.size(0); + } else { + reply.data(&[]); + } + } + fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) { // Zeroed on purpose. There is no protocol op for free space, and a made // up figure would be a lie a file manager acts on. Zero free on a @@ -765,14 +804,20 @@ fn spawn_session( dir: &PathBuf, remote: R, ) -> Result { - std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - // A previous run that died without unmounting leaves the path occupied, and - // the mount would fail with a confusing EBUSY. Only ever our own private - // path under XDG_RUNTIME_DIR, and a no-op when nothing is mounted there. + // FIRST, before touching the path at all: a previous run that died without + // unmounting (a crash, a SIGKILL, the installer restarting the app) leaves + // the mount in the table with no server behind it, and every syscall on it + // answers ENOTCONN. That includes the `stat` inside `create_dir_all`, which + // therefore fails with EEXIST — the directory is there, it just cannot be + // looked at. Clearing the corpse first is what makes a remount work. + // + // Only ever our own private path under XDG_RUNTIME_DIR, and a no-op when + // nothing is mounted there. let _ = std::process::Command::new("fusermount3") .args(["-quz", &dir.to_string_lossy()]) .stderr(std::process::Stdio::null()) .status(); + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; let fs = PhoneFs { inner: Arc::new(Inner::new(remote)), @@ -789,6 +834,12 @@ fn spawn_session( MountOption::NoSuid, MountOption::NoDev, MountOption::NoExec, + // Let the KERNEL do permission checks, from the mode bits we report. + // Without it the kernel asks us (`access`) on every path walk, which + // is a round trip through the session thread to answer "yes" — the + // mount is visible to its owner alone and read-only, so there is + // nothing for us to decide that the mode bits do not already say. + MountOption::DefaultPermissions, ]; // One session thread is enough: it only parses a request and hands it to // the runtime, so it is never the thing that is busy. From 2a311c85437fe3d0e719d2ff5c4d19d7299d72f6 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Mon, 7 Sep 2026 20:22:07 +0200 Subject: [PATCH 52/71] feat(ui): a folder button on the phone's card opens its files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beside "Connected", so it sits with the state it depends on: the mount is served over the live session, and the button is only there while the card says the phone is reachable. One click does both halves — `open_phone_files` mounts on demand and hands the path to `xdg-open`. Asking the user to mount first would be a step that exists only because the code is in two pieces, and `mount` is idempotent, so a second click reveals the same window rather than remounting under it. Mounting is the part that can fail: a phone can go between the card last saying Connected and the click. So the failure is shown on the button — tinted for a few seconds with the reason in its tooltip — rather than swallowed like the ring button's, where a lost heartbeat genuinely does not matter. `tokio::process` for the spawn, not `std`, for the reason `handoff::open_url` gives: a `std` Child dropped without `wait()` is a zombie for the life of a process that runs for days. `vx-mini`, a 22px sibling of `vx-ring`: the 36px ring button would tower over the 13px line of text it belongs to. Windows gets the command as an `UNSUPPORTED` stub through `platform_unsupported`, like camera and earbuds — the frontend is one bundle on every platform, so the command has to exist there or the button fails with "command not found" instead of something a user can read. It has nothing to open until ProjFS lands. Tooltip translated in all three locales, in `peers` beside `switch_tip` rather than in a new section for one string. Verified: `vue-tsc -b` and the Rust build clean, and `xdg-open` on the mount opens Dolphin at the phone's storage. Co-Authored-By: Claude Opus 5 (1M context) --- docs/design/file-browsing.md | 6 ++ linux/ui-tauri/src-tauri/src/fs_mount.rs | 25 +++++++++ linux/ui-tauri/src-tauri/src/lib.rs | 5 ++ .../src-tauri/src/platform_unsupported.rs | 9 +++ linux/ui-tauri/src/lib/locales/en.json | 1 + linux/ui-tauri/src/lib/locales/ru.json | 1 + linux/ui-tauri/src/lib/locales/uz.json | 1 + linux/ui-tauri/src/pages/home/Devices.vue | 55 +++++++++++++++++++ 8 files changed, 103 insertions(+) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 3c65cfd..c0667b1 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -319,6 +319,12 @@ This is where these features usually fail, and it is all daemon-side: a write refused. That test needs `/dev/fuse`, so it is `#[ignore]`d and run with `cargo test --lib fs_mount -- --ignored`. + Reachable from the UI: a folder button beside "Connected" on the phone's + card mounts on demand and hands the path to `xdg-open`. Mounting is what can + fail — the phone may have gone since the card last said Connected — so the + button holds the error for a few seconds with the reason in its tooltip, + rather than opening a file manager onto nothing. + **Verified on the device** (2026-09-07, over Wi-Fi, all-files root). The phone's shared storage appeared at `/run/user/1000/vortex/phone` as `fuse.vortex (ro,nosuid,nodev,noexec,default_permissions)` with real names diff --git a/linux/ui-tauri/src-tauri/src/fs_mount.rs b/linux/ui-tauri/src-tauri/src/fs_mount.rs index 8dddeff..ea33f5d 100644 --- a/linux/ui-tauri/src-tauri/src/fs_mount.rs +++ b/linux/ui-tauri/src-tauri/src/fs_mount.rs @@ -863,6 +863,31 @@ pub(crate) fn unmount() { } } +/// Open the phone's storage in the desktop file manager. +/// +/// Mounts on demand: the button IS the request, so asking the user to mount +/// first would be a step that exists only because the code is in two pieces. +/// Idempotent, because [`mount`] is — clicking twice reveals the same window +/// rather than remounting under it. +/// +/// Returns the path so the UI can name it in a tooltip; the interesting half of +/// the result is the error, which is what a phone that is not reachable looks +/// like from here. +#[tauri::command] +pub async fn open_phone_files() -> Result { + let dir = mount().await?; + let path = dir.to_string_lossy().to_string(); + // `tokio::process`, not `std::process`: a `std` `Child` dropped without + // `wait()` stays a zombie for the parent's whole life, and this app runs + // for days. Same reason `handoff::open_url` does it this way. + tokio::process::Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| format!("cannot open the file manager: {e}"))?; + tracing::info!(%path, "fs-mount: opened in the file manager"); + Ok(path) +} + /// Detach the mount on the process's way out. /// /// A FUSE mount whose server process has died is not gone — it stays in the diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 1950edd..50c72d2 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -63,6 +63,10 @@ use platform_unsupported as proximity; use platform_unsupported as earbuds; #[cfg(not(target_os = "linux"))] use platform_unsupported as laptop_cast; +// Browsing the phone's files needs a mount adapter, and the Windows one +// (ProjFS) is not written yet — design doc §8 step 6. +#[cfg(not(target_os = "linux"))] +use platform_unsupported as fs_mount; mod clipboard; mod clipboard_hotkey; mod clipboard_window; @@ -658,6 +662,7 @@ pub fn run() { universal_control::uc_running, universal_control::uc_set_placement, universal_control::uc_get_placement, + fs_mount::open_phone_files, ]) .build(tauri::generate_context!()) .expect("error while building Vortex Tauri") diff --git a/linux/ui-tauri/src-tauri/src/platform_unsupported.rs b/linux/ui-tauri/src-tauri/src/platform_unsupported.rs index 5694468..8be221b 100644 --- a/linux/ui-tauri/src-tauri/src/platform_unsupported.rs +++ b/linux/ui-tauri/src-tauri/src/platform_unsupported.rs @@ -176,3 +176,12 @@ pub(crate) fn persist_peer_earbuds(_state: &vortex_l3_daemon::core::appstate::Ap /// is nothing to unlock here — Windows has no programmatic unlock at all, which /// `SessionControl::can_unlock` already reports — so the value is dropped. pub(crate) fn note_phone_unlocked(_unlocked: Option) {} + +// ── Browsing the phone's files (FUSE on Linux) ──────────────────────────── + +/// The protocol is portable; the mount adapter is not. Windows needs ProjFS +/// (design doc §8 step 6), so until that exists the button has nothing to open. +#[tauri::command] +pub async fn open_phone_files() -> Result { + Err(UNSUPPORTED.to_string()) +} diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index 57026b8..e538e8d 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -70,6 +70,7 @@ "forget_body": "Stop trusting {name}? You'll need to re-pair to connect again.", "forget_confirm": "Forget", "switch_tip": "Switch to another paired phone", + "browse_tip": "Browse the phone\u2019s files", "switch_scanning": "Looking for your other phones…", "switch_pick": "Switch to which phone?", "switch_none": "No other paired phone nearby.", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index 3db8c80..1bc2667 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -70,6 +70,7 @@ "forget_body": "Перестать доверять {name}? Чтобы вновь подключиться, потребуется повторное сопряжение.", "forget_confirm": "Забыть", "switch_tip": "Переключиться на другой телефон", + "browse_tip": "Просмотр файлов телефона", "switch_scanning": "Поиск других ваших телефонов…", "switch_pick": "На какой телефон переключиться?", "switch_none": "Рядом нет другого сопряжённого телефона.", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index ac0d4d4..dbb51a6 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -70,6 +70,7 @@ "forget_body": "{name} bilan aloqa uziladi. Qayta ulanish uchun yana pairing qilinadi.", "forget_confirm": "Unutish", "switch_tip": "Boshqa telefonga o'tish", + "browse_tip": "Telefon fayllarini ko'rish", "switch_scanning": "Boshqa telefonlaringiz qidirilmoqda…", "switch_pick": "Qaysi telefonga o'tamiz?", "switch_none": "Yaqinda boshqa ulangan telefon yo'q.", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index f7287a6..8f20a57 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -17,6 +17,7 @@ import { BellRing, SwitchCamera, TabletSmartphone, + FolderOpen, } from "lucide-vue-next"; import { activeEarbuds, @@ -92,6 +93,28 @@ async function ringPhone() { } } +// Browse the phone's storage: mounts it (FUSE) and opens the mount point in +// the file manager. Mounting is what can actually fail — a phone that has gone +// since the card last said "Connected" — so the failure is shown on the button +// rather than swallowed, with the reason in its tooltip. +const filesOpening = ref(false); +const filesError = ref(""); +let filesErrorTimer: ReturnType | undefined; +async function openPhoneFiles() { + if (filesOpening.value) return; + filesOpening.value = true; + filesError.value = ""; + try { + await invoke("open_phone_files"); + } catch (e) { + filesError.value = String(e); + clearTimeout(filesErrorTimer); + filesErrorTimer = setTimeout(() => (filesError.value = ""), 6000); + } finally { + filesOpening.value = false; + } +} + const earbudsStatus = computed(() => { if (!activeEarbuds.value) return t("earbuds.not_connected"); return activeEarbuds.value.on === "local" ? t("earbuds.on_local") : t("earbuds.on_peer"); @@ -183,6 +206,20 @@ const earbudsStatus = computed(() => { {{ phoneOnline ? t("peers.connected") : phoneConnecting ? t("peers.connecting") : t("peers.offline") }} + +
@@ -321,6 +358,24 @@ const earbudsStatus = computed(() => { .vx-chip--live { @apply border-primary/40 bg-primary/[0.14] text-primary; } +/* Small round action sitting inline with a line of text — vx-ring at 36px would + tower over the 13px status row it belongs to. */ +.vx-mini { + @apply flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full transition-colors disabled:opacity-60; + color: hsl(var(--muted-foreground)); + border: 1px solid hsl(var(--border)); + background: hsl(var(--foreground) / 0.04); +} +.vx-mini:hover:not(:disabled) { + color: hsl(var(--foreground)); + background: hsl(var(--foreground) / 0.08); +} +/* A failed mount, held for a few seconds with the reason in the tooltip. */ +.vx-mini--bad { + color: hsl(var(--destructive)); + border-color: hsl(var(--destructive) / 0.45); + background: hsl(var(--destructive) / 0.12); +} /* Find-My ring button — theme-safe tints (foreground/primary alpha) so it reads in light mode too; pulses while a ring was just requested. */ .vx-ring { From c95e02326f2f6fe2f92b552c36001e110520abe7 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Mon, 7 Sep 2026 20:26:19 +0200 Subject: [PATCH 53/71] style(ui): the files button matches the ring buttons above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right edge of the row rather than hugging the "Connected" text, and `vx-ring` at its full 36px with an 18px icon, so it lines up under the ring and switch buttons instead of reading as a third size on the same card. `vx-mini` is gone — with the sizes equal there was nothing left in it — and its failure tint moves onto `vx-ring--bad`, a sibling of `vx-ring--on`. No pulse on that one: it is reporting, not working. Co-Authored-By: Claude Opus 5 (1M context) --- docs/design/file-browsing.md | 4 +-- linux/ui-tauri/src/pages/home/Devices.vue | 36 ++++++++--------------- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index c0667b1..e0d12ac 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -319,8 +319,8 @@ This is where these features usually fail, and it is all daemon-side: a write refused. That test needs `/dev/fuse`, so it is `#[ignore]`d and run with `cargo test --lib fs_mount -- --ignored`. - Reachable from the UI: a folder button beside "Connected" on the phone's - card mounts on demand and hands the path to `xdg-open`. Mounting is what can + Reachable from the UI: a folder button on the right of the phone card's + "Connected" row mounts on demand and hands the path to `xdg-open`. Mounting is what can fail — the phone may have gone since the card last said Connected — so the button holds the error for a few seconds with the reason in its tooltip, rather than opening a file manager onto nothing. diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 8f20a57..12965bd 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -208,17 +208,18 @@ const earbudsStatus = computed(() => { + open. `ml-auto` puts it on the card's right edge, under the ring + and switch buttons it matches. -->
@@ -358,24 +359,6 @@ const earbudsStatus = computed(() => { .vx-chip--live { @apply border-primary/40 bg-primary/[0.14] text-primary; } -/* Small round action sitting inline with a line of text — vx-ring at 36px would - tower over the 13px status row it belongs to. */ -.vx-mini { - @apply flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full transition-colors disabled:opacity-60; - color: hsl(var(--muted-foreground)); - border: 1px solid hsl(var(--border)); - background: hsl(var(--foreground) / 0.04); -} -.vx-mini:hover:not(:disabled) { - color: hsl(var(--foreground)); - background: hsl(var(--foreground) / 0.08); -} -/* A failed mount, held for a few seconds with the reason in the tooltip. */ -.vx-mini--bad { - color: hsl(var(--destructive)); - border-color: hsl(var(--destructive) / 0.45); - background: hsl(var(--destructive) / 0.12); -} /* Find-My ring button — theme-safe tints (foreground/primary alpha) so it reads in light mode too; pulses while a ring was just requested. */ .vx-ring { @@ -388,6 +371,13 @@ const earbudsStatus = computed(() => { color: hsl(var(--foreground)); background: hsl(var(--foreground) / 0.08); } +/* An action that just failed — held for a few seconds, with the reason in the + button's tooltip. Still, no pulse: this one is reporting, not working. */ +.vx-ring--bad { + color: hsl(var(--destructive)); + border-color: hsl(var(--destructive) / 0.45); + background: hsl(var(--destructive) / 0.12); +} .vx-ring--on { color: hsl(var(--primary)); border-color: hsl(var(--primary) / 0.4); From c375206fa303f5c242b5d17439d5eb359abd1581 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Fri, 11 Sep 2026 21:42:47 +0200 Subject: [PATCH 54/71] feat(fs): project the phone's storage on Windows, with ProjFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc §8 step 6's other half, so the feature is on both OSes. Windows skips WebDAV for the reason the doc already predicted: the WebClient redirector caps a file at ~50 MB, and escaping a 64 MB cap into a 50 MB one would be absurd. ProjFS ships in Windows 10 1809+ with no third-party install, though it is an optional feature that is off by default on client SKUs — the mount error says how to turn it on rather than failing obscurely. Adding a second adapter first meant splitting the first one. `fs_vfs` is now everything between a mount and the wire — caches, the path walk, pipelined ranged reads, the concurrency cap — with `fs_fuse` and `fs_projfs` holding only translation, and `fs_mount` the facade the rest of the app calls. So the interesting bugs are written and tested once, and the path walk in particular is tested on Linux precisely because it is the hardest part of the Windows adapter and the part least able to be tested there. The threading models are opposites and that drives the difference. FUSE hands one request at a time on one thread, so the Linux adapter must never block. ProjFS runs its own pool and is built for providers that block one of its threads, so the Windows callbacks are plainly synchronous — safe only because the pool is sized at twice MAX_INFLIGHT, leaving the shared semaphore as what limits concurrency rather than the pool. If that stops holding, the escape hatch is ERROR_IO_PENDING + PrjCompleteCommand, which costs an owned copy of every callback parameter and is why it is not the starting point. Read-only takes enforcing rather than declaring: ProjFS has no `ro` mount option, so every placeholder carries FILE_ATTRIBUTE_READONLY (advisory, greys Explorer's commands out) and the notification callback vetoes PRE_DELETE, PRE_RENAME, PRE_SET_HARDLINK and FILE_PRE_CONVERT_TO_FULL, which is the half that actually enforces it. Hydration is a free content cache and a staleness problem: ProjFS writes fetched bytes into the real directory and serves later reads from disk without asking us. The projection is cleared at each mount so a session starts from the phone's current truth; within a session a changed file still shows the old content. Fixing that properly means a ContentID from size and mtime plus PrjUpdateFileIfNeeded — the companion to step 3. Three things worth a reviewer's eye, all found by reading rather than running: * the stop thread must move the whole `Instance`, not its fields — Rust 2021 captures disjointly, which would capture two raw pointers and bypass the `unsafe impl Send` that vouches for them; * the notification root is an empty string, not null, and getting that wrong fails OPEN, as a writable projection; * callbacks can fire before PrjGetVirtualizationInstanceInfo returns, so the write alignment starts at 64 KiB rather than 1 — a chunk rounded up to that is also a whole multiple of 512 and 4096, so a hydration landing in the window is legal whatever the volume wants. Verified on Windows: nothing. There is no Windows machine in this loop. `cargo check --all-targets --target x86_64-pc-windows-gnu` is clean, which type-checks every callback signature, struct layout and constant against the real Win32 metadata and says nothing about behaviour. The ProjFS protocol itself — enumeration restart and buffer-full handling, the write-alignment rule, whether the veto covers every path to a write — is written from the documented contract and is what a first run should be expected to shake out. Verified on Linux, because this refactored working code: 57 unit tests, the real-kernel mount test, and the device again — the same 52 MB video and the same 64 KiB at offset 3,396,354,250 of a 3.4 GB zip, both md5-identical to the earlier runs, plus listings, a subdirectory and a refused write. Co-Authored-By: Claude Opus 5 (1M context) --- docs/design/file-browsing.md | 148 +- linux/ui-tauri/src-tauri/Cargo.lock | 1 + linux/ui-tauri/src-tauri/Cargo.toml | 17 + linux/ui-tauri/src-tauri/src/fs_cli.rs | 31 +- linux/ui-tauri/src-tauri/src/fs_fuse.rs | 671 +++++++++ linux/ui-tauri/src-tauri/src/fs_mount.rs | 1247 +---------------- linux/ui-tauri/src-tauri/src/fs_projfs.rs | 871 ++++++++++++ linux/ui-tauri/src-tauri/src/fs_vfs.rs | 725 ++++++++++ linux/ui-tauri/src-tauri/src/lib.rs | 15 +- .../src-tauri/src/platform_unsupported.rs | 9 - 10 files changed, 2468 insertions(+), 1267 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/fs_fuse.rs create mode 100644 linux/ui-tauri/src-tauri/src/fs_projfs.rs create mode 100644 linux/ui-tauri/src-tauri/src/fs_vfs.rs diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index e0d12ac..34a30c9 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -1,7 +1,10 @@ # Browsing the phone's files from the desktop -**Status:** design, not implemented. **Targets:** Linux *and* Windows from day -one — the Windows port branch means every new feature needs both. +**Status:** built through §8 step 2, plus step 6 (the mounts) on both OSes. +Steps 3-5 — the daemon cache layer, a WebDAV gateway, writes — are open; the +gateway is now unlikely, see §4. **Targets:** Linux *and* Windows from day one. +Linux is verified on a device; the Windows adapter compiles and has never run +(§8 step 6). Goal: open the phone's storage in Dolphin / Nautilus / Explorer, like KDE Connect does. Read-only first, writes stubbed. @@ -106,14 +109,32 @@ Design notes: --- -## 4. Desktop presentation: WebDAV first, native VFS as the exit +## 4. Desktop presentation: native VFS on both, WebDAV never built -*Linux went straight to the native VFS (v2) and skipped WebDAV.* The reasoning -is under v2 below; in short, on Linux WebDAV buys strictly less than FUSE for -comparable work, so the "cheapest path" argument for doing it first does not -survive contact with it. Windows still has the choice open. +***Both* operating systems went straight to the native VFS (v2); WebDAV was +skipped entirely.** Its whole argument was being the cheapest path to something +usable, and on neither OS did that survive contact: -### v1 — WebDAV on loopback +* **Linux** — GVFS and KIO mount `davs://` *inside the file manager's own + process*, so only that program's file dialogs would see the files. `cp`, + `mpv`, `ffprobe`, a text editor's Open box: none of them. FUSE is a real path + in the filesystem, so everything sees it. +* **Windows** — the WebClient redirector caps a file at ~50 MB by default, and + escaping a 64 MB cap into a 50 MB one would have been absurd. ProjFS has no + such ceiling and needs no third-party install. + +What v1 was really buying was *one* implementation instead of two. That turned +out to be the wrong unit of accounting: the two native adapters share +everything above the OS call (`fs_vfs.rs` — caching, the path walk, pipelined +reads, the concurrency cap) and differ only in the translation layer, which a +WebDAV gateway would have needed anyway in the form of an HTTP server. Two +adapters over one shared core came to about the same code as one gateway, with +no size limits, no port, no auth story and no second process. + +The v1 sketch is kept below because its Windows caveat table is the reason +ProjFS won there, and because it is the road not taken. + +### v1 — WebDAV on loopback (not built) One implementation serving both OSes: @@ -134,26 +155,53 @@ Cheapest path to something usable, and platform-neutral Rust in the daemon. Loopback-only binding removes the auth problem outright. The 50 MB limit does not go away and is the main reason v1 may not be the end state. -### v2 — native virtual filesystem +### v2 — native virtual filesystem — **both done** -- **Linux:** FUSE. Straightforward, gives a real mount. **Done** — - [`fs_mount.rs`], mounted at `$XDG_RUNTIME_DIR/vortex/phone`. -- **Windows:** **ProjFS** (Projected File System), shipped in Windows 10 1809+ - with **no third-party install** — it is what VFS for Git uses. This is the key - fact that beats WebDAV: a real filesystem, no size limits, proper seeking. +- **Linux:** FUSE ([`fs_fuse.rs`]), mounted at `$XDG_RUNTIME_DIR/vortex/phone`. +- **Windows:** **ProjFS** ([`fs_projfs.rs`]), projected at + `%LOCALAPPDATA%\Vortex\phone`. Ships in Windows 10 1809+ with no + third-party install — it is what VFS for Git uses — though it is an optional + feature that is off by default on client SKUs, so the mount error says how to + turn it on. -More code (two presentation implementations), but no artificial ceilings, and -the phone side is untouched by the switch. +Three files, not two: [`fs_vfs.rs`] is everything between an adapter and the +wire — the caches, the path walk, the pipelined ranged reads, the concurrency +cap — and the adapters are only translation. That is what keeps "the mount +adapter is swappable; the protocol is the investment" true in the code and not +just in this document: adding Windows touched nothing the phone can observe. -**Why Linux skipped WebDAV.** GVFS and KIO mount `davs://` *inside the file -manager's own process*, so only that program's file dialogs can see the files — -`cp`, `mpv`, `ffprobe`, a text editor's Open box, anything not built on KIO, -cannot. A FUSE mount is a path in the filesystem, so everything can. Against -that, the platform-neutrality argument for WebDAV-first only pays off on -Windows, where it also runs into the ~50 MB `FileSizeLimitInBytes` cap. Linux -needs no gateway process, no port, and no auth story at all: the mount is a -directory only the mounting user can see (FUSE's default `Owner` access mode), -which is a smaller attack surface than a loopback HTTP server. +**The threading models are opposites, and that is the whole difference.** + +| | FUSE | ProjFS | +|---|---|---| +| Request delivery | one at a time, one session thread | its own thread pool, concurrent | +| So the adapter must | **never block** — hand every op to the async runtime and answer from there | **block freely** — that is what the pool is for | +| Concurrency limited by | our semaphore | our semaphore (the pool is sized above it on purpose) | + +Blocking a FUSE session thread serialises the entire mount behind one round +trip at a time. Blocking a ProjFS pool thread is what ProjFS is built for — so +the Windows callbacks are plainly synchronous, and the pool is sized at twice +`MAX_INFLIGHT` so the shared semaphore runs out first and a thundering +thumbnailer cannot exhaust the pool and wedge Explorer. If that ever stops +holding, ProjFS has its own escape hatch (`ERROR_IO_PENDING` plus +`PrjCompleteCommand`); it costs an owned copy of every callback parameter, +which is why it is not the starting point. + +**Read-only is enforced differently too.** FUSE takes an `ro` mount option and +the kernel refuses writes before they reach us. ProjFS has no such flag, so the +projection marks every placeholder `FILE_ATTRIBUTE_READONLY` (advisory — it +greys the commands out in Explorer) *and* vetoes `PRE_DELETE`, `PRE_RENAME`, +`PRE_SET_HARDLINK` and `FILE_PRE_CONVERT_TO_FULL` from the notification +callback, which is the half that actually enforces it. + +**One thing ProjFS gives free and one it costs.** It hydrates fetched content +into the real directory and serves later reads from disk without asking us — +design doc §7's content cache, for nothing. The cost is staleness: a file that +changes on the phone is not re-fetched. The projection is therefore cleared at +each mount, so a session starts from the phone's current truth; *within* a +session a changed file still shows its old content. Fixing that properly means +deriving a placeholder ContentID from size and mtime and driving +`PrjUpdateFileIfNeeded` — the natural companion to step 3. ### Rejected: SFTP + sshfs @@ -212,9 +260,11 @@ ride it, and it is how the daemon knows the phone is there at all. So: - With no usable network, the mount reports an honest, immediate error rather than hanging — a file manager blocked on a dead read is the worst outcome. *Done:* a request that reaches neither transport fails at once with - `EHOSTDOWN` ("Host is down") instead of waiting out the 20 s reply timeout. - Twenty seconds per operation on a phone that is simply not here is - indistinguishable from a hung file manager. + `EHOSTDOWN` — or `ERROR_HOST_DOWN`, which Explorer renders as "The host is + down" — instead of waiting out the 20 s reply timeout. Twenty seconds per + operation on a phone that is simply not here is indistinguishable from a hung + file manager. Both adapters map the protocol's codes straight across, which + is what the errno shape in §3 was for. - Wi-Fi Direct is already used for large transfers and applies here unchanged. --- @@ -297,17 +347,19 @@ This is where these features usually fail, and it is all daemon-side: 3. **Daemon cache layer** — metadata, readahead, content budget. 4. **WebDAV loopback gateway**, both OSes. 5. **`FS_WRITE` / `FS_SETMETA`** for real, once read-only is solid. -6. **FUSE + ProjFS**, if the Windows WebDAV limits bite. **Linux done** - ([`fs_mount.rs`]), ahead of steps 3-5 and instead of WebDAV on this OS — - see §4. `--fs-mount` / `--fs-umount` put the phone's storage at - `$XDG_RUNTIME_DIR/vortex/phone`, read-only, and every program on the machine - can read it. - - The load-bearing decision is that **nothing blocks the FUSE session +6. **FUSE + ProjFS**, if the Windows WebDAV limits bite. **Both done**, ahead + of steps 3-5 and instead of WebDAV on either OS — see §4. `--fs-mount` / + `--fs-umount`, or the folder button on the phone's card, put the phone's + storage at `$XDG_RUNTIME_DIR/vortex/phone` (Linux) or + `%LOCALAPPDATA%\Vortex\phone` (Windows), read-only, and every program on + the machine can read it. + + The load-bearing decision on Linux is that **nothing blocks the FUSE session thread**: each operation is handed to the async runtime and its reply object (which fuser makes `Send` for exactly this) is answered when the phone answers. Serving inline instead would cost one full round trip per operation - in series, and a file manager opening a folder issues dozens at once. + in series, and a file manager opening a folder issues dozens at once. On + Windows the same requirement is met by doing the opposite — see §4's table. What is not there yet: writes (step 5 — the mount is `ro`, so the kernel refuses them without a round trip), a content cache, coalescing, and @@ -320,7 +372,8 @@ This is where these features usually fail, and it is all daemon-side: with `cargo test --lib fs_mount -- --ignored`. Reachable from the UI: a folder button on the right of the phone card's - "Connected" row mounts on demand and hands the path to `xdg-open`. Mounting is what can + "Connected" row mounts on demand and hands the path to the platform's file + manager (`xdg-open`, or `explorer.exe`). Mounting is what can fail — the phone may have gone since the card last said Connected — so the button holds the error for a few seconds with the reason in its tooltip, rather than opening a file manager onto nothing. @@ -343,6 +396,20 @@ This is where these features usually fail, and it is all daemon-side: * `touch` in the mount: "Read-only file system", refused by the kernel without a round trip. + **The Windows half has never run.** There is no Windows machine in this + project's loop, so ProjFS is verified only as far as a cross-compile reaches: + `cargo check --all-targets --target x86_64-pc-windows-gnu` is clean, which + type-checks every callback signature, struct layout and constant against the + real Win32 metadata — and nothing about behaviour. What that cannot catch is + the ProjFS *protocol*: enumeration restart and buffer-full handling, the + write-alignment rule, whether the notification veto covers every path to a + write. Those are written from the documented contract with the reasoning in + comments, and they are what a first run on Windows should be expected to + shake out. The shared layer under it (`fs_vfs.rs`) is tested on every + platform, which is deliberately where the path walk lives — it is the + hardest part of the Windows adapter and the part least able to be tested + there. + Two things that only a live run surfaced. Every path walk was asking us `access` and every `close` a `flush`, and `getxattr`/`listxattr` on top — fuser logs each as "[Not Implemented]", so an ordinary `ls` wrote warnings @@ -392,8 +459,11 @@ listener — worth doing, and the natural companion to step 3. ## 9. Open questions -- **Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer, - document it, or skip straight to ProjFS? +- ~~**Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer, + document it, or skip straight to ProjFS?~~ **Answered: straight to ProjFS**, + so the limit never applies. What replaces it as a Windows deployment question + is that ProjFS is an optional feature, off by default on client SKUs — the + mount error says how to enable it, and an installer step could do it instead. - **Handle lifetime** across phone process death — the daemon must transparently reopen, or the file manager will see spurious I/O errors after a Doze kill. - **Multi-peer:** with several paired phones, is the mount per-phone (a mount diff --git a/linux/ui-tauri/src-tauri/Cargo.lock b/linux/ui-tauri/src-tauri/Cargo.lock index 9cadf3e..8336f38 100644 --- a/linux/ui-tauri/src-tauri/Cargo.lock +++ b/linux/ui-tauri/src-tauri/Cargo.lock @@ -5445,6 +5445,7 @@ dependencies = [ "tracing-subscriber", "vortex-l3-daemon", "walkdir", + "windows 0.62.2", "wl-clipboard-rs", "x11rb", "zbus 5.15.0", diff --git a/linux/ui-tauri/src-tauri/Cargo.toml b/linux/ui-tauri/src-tauri/Cargo.toml index 19206e5..f190d07 100644 --- a/linux/ui-tauri/src-tauri/Cargo.toml +++ b/linux/ui-tauri/src-tauri/Cargo.toml @@ -146,3 +146,20 @@ ksni = "0.3" # `fusermount3` binary — a runtime dependency already present anywhere FUSE # works at all. fuser = { version = "0.18", default-features = false } + + +# ── Windows-bound dependencies ──────────────────────────────────────────── +# The mount adapter there is ProjFS (design doc §8 step 6) — a Win32 API with +# no Linux counterpart, the way FUSE has no Windows one. Feature-gated per +# namespace so we pull metadata for what we actually call: the ProjFS entry +# points themselves, the FILE_ATTRIBUTE_* bits a placeholder carries, and the +# ERROR_* codes the callbacks answer with. +# +# Same major version the daemon crate already pins, so the two do not resolve +# two copies of the metadata into one build. +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Storage_ProjectedFileSystem", +] } diff --git a/linux/ui-tauri/src-tauri/src/fs_cli.rs b/linux/ui-tauri/src-tauri/src/fs_cli.rs index 8ca6c3d..d124e1e 100644 --- a/linux/ui-tauri/src-tauri/src/fs_cli.rs +++ b/linux/ui-tauri/src-tauri/src/fs_cli.rs @@ -136,10 +136,10 @@ pub(crate) async fn get(remote: String, local: String) { /// `--fs-mount` / `--fs-umount` — put the phone's storage on the filesystem. /// -/// The mount is not automatic: it costs a `fusermount3` and a kernel session, -/// and a mount pointing at a phone that is not here is worse than no mount. So -/// it is driven explicitly, and from here until there is a button for it. -#[cfg(target_os = "linux")] +/// The mount is not automatic: it costs a kernel session (FUSE) or a +/// virtualization instance (ProjFS), and one pointing at a phone that is not +/// here is worse than none. The home screen's folder button is the everyday +/// way in; this stays because a flag can be scripted and a button cannot. pub(crate) async fn mount() { match crate::fs_mount::mount().await { Ok(dir) => tracing::info!("fs-cli: mounted at {}", dir.display()), @@ -150,20 +150,17 @@ pub(crate) async fn mount() { /// Route an `--fs-*` flag. Returns false when `argv` holds none, so the caller /// can fall through to its other flags. pub(crate) fn dispatch(argv: &[String]) -> bool { - #[cfg(target_os = "linux")] - { - if argv.iter().any(|a| a == "--fs-umount") { - if crate::fs_mount::is_mounted() { - crate::fs_mount::unmount(); - } else { - tracing::info!("fs-cli: nothing mounted"); - } - return true; - } - if argv.iter().any(|a| a == "--fs-mount") { - tauri::async_runtime::spawn(mount()); - return true; + if argv.iter().any(|a| a == "--fs-umount") { + if crate::fs_mount::is_mounted() { + crate::fs_mount::unmount(); + } else { + tracing::info!("fs-cli: nothing mounted"); } + return true; + } + if argv.iter().any(|a| a == "--fs-mount") { + tauri::async_runtime::spawn(mount()); + return true; } if let Some(pos) = argv.iter().position(|a| a == "--fs-ls") { // Optional: no path means the synthetic root listing the peer's shares. diff --git a/linux/ui-tauri/src-tauri/src/fs_fuse.rs b/linux/ui-tauri/src-tauri/src/fs_fuse.rs new file mode 100644 index 0000000..522cb52 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/fs_fuse.rs @@ -0,0 +1,671 @@ +//! The Linux mount adapter: the phone's storage as a FUSE filesystem. +//! +//! Everything between this and the wire — caching, the path walk, pipelined +//! reads — is [`crate::fs_vfs`]; this file is only the translation between that +//! and the kernel's FUSE protocol. Its Windows counterpart is +//! [`crate::fs_projfs`]. +//! +//! # Why FUSE and not the WebDAV gateway +//! +//! The design doc sequenced WebDAV ahead of a native VFS because one gateway +//! serves both operating systems. On Linux it buys nothing FUSE does not: +//! GVFS/KIO mount `davs://` in *their* process, so only their own file dialogs +//! see the files — `cp`, `mpv` and every non-KIO program do not. A FUSE mount +//! is a real path in the filesystem with no ceiling. +//! +//! # Concurrency, which is the load-bearing design decision +//! +//! FUSE hands us one request at a time on one thread. Answering each one +//! inline — issue the request, block on the phone's reply, return — would make +//! the mount as slow as the round trip *times* the number of operations, and a +//! file manager stats every visible file at once. So every operation is +//! immediately handed to the async runtime and its `Reply` object (which is +//! `Send`, deliberately) is answered from there. The session thread does +//! nothing but parse and dispatch. +//! +//! That is also what makes the kernel's own readahead work for us: a sequential +//! reader triggers several `read` calls at once, and because we never block, +//! they overlap on the wire instead of queueing. +//! +//! (ProjFS is the opposite: it runs a thread pool and expects a provider to +//! block one of its threads. Same requirement — do not serialise — reached from +//! opposite directions.) + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime}; + +use fuser::{ + Errno, FileAttr, FileType, FopenFlags, Generation, INodeNo, KernelConfig, MountOption, + OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, + ReplyOpen, ReplyStatfs, ReplyXattr, Request, +}; +use vortex_l3_daemon::core::fs_proto::{self as p, code}; + +use crate::fs_vfs::{FsRemote, LinkRemote, Vfs, ATTR_TTL, MAX_INFLIGHT, ROOT_ADDR}; + +/// The mount root. FUSE fixes this at 1; the peer's synthetic root lives here. +const ROOT_INO: u64 = 1; + +// --------------------------------------------------------------------------- +// Inode identity +// --------------------------------------------------------------------------- + +/// Inode numbers ↔ peer addresses. +/// +/// A peer address is an opaque token, not a path, so an inode number cannot be +/// derived from one and the mapping has to be remembered. Numbers are **never +/// recycled**: a file manager holds inode numbers across a refresh and reusing +/// one would silently show it the wrong file. The table therefore only grows, +/// which is fine at a few dozen bytes per entry for a session's browsing. +#[derive(Default)] +struct Inodes { + by_ino: HashMap, + by_addr: HashMap, + next: u64, +} + +impl Inodes { + fn new() -> Self { + let mut t = Self { + next: ROOT_INO + 1, + ..Default::default() + }; + t.by_ino.insert(ROOT_INO, ROOT_ADDR.to_string()); + t.by_addr.insert(ROOT_ADDR.to_string(), ROOT_INO); + t + } + + fn intern(&mut self, addr: &str) -> u64 { + if let Some(&ino) = self.by_addr.get(addr) { + return ino; + } + let ino = self.next; + self.next += 1; + self.by_ino.insert(ino, addr.to_string()); + self.by_addr.insert(addr.to_string(), ino); + ino + } + + fn addr(&self, ino: u64) -> Option<&str> { + self.by_ino.get(&ino).map(String::as_str) + } +} + +struct PhoneFs { + vfs: Arc>, + inodes: Arc>, + rt: tokio::runtime::Handle, +} + +/// Intern an address, outside any `await`. +fn intern(inodes: &Mutex, addr: &str) -> u64 { + inodes.lock().map(|mut t| t.intern(addr)).unwrap_or(ROOT_INO) +} + +fn addr_of(inodes: &Mutex, ino: u64) -> Option { + inodes + .lock() + .ok() + .and_then(|t| t.addr(ino).map(str::to_string)) +} + +// --------------------------------------------------------------------------- +// Translation +// --------------------------------------------------------------------------- + +/// A protocol entry as a kernel `stat`. +/// +/// Permissions are fixed rather than reported by the peer: the mount is +/// read-only until design doc §8 step 5 lands, and a writable-looking mode bit +/// would only get a copy half-way through before the phone refused it. `nlink` +/// of 2 for a directory is the usual lie (`.` and `..`) — the real subdirectory +/// count would cost a listing per stat. +fn attr_of(ino: u64, e: &p::FsEntry, uid: u32, gid: u32) -> FileAttr { + let mtime = mtime_of(e.mtime); + FileAttr { + ino: INodeNo(ino), + size: if e.is_dir { 0 } else { e.size }, + blocks: e.size.div_ceil(512), + atime: mtime, + mtime, + ctime: mtime, + crtime: mtime, + kind: if e.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }, + perm: if e.is_dir { 0o555 } else { 0o444 }, + nlink: if e.is_dir { 2 } else { 1 }, + uid, + gid, + rdev: 0, + blksize: 4096, + flags: 0, + } +} + +/// Seconds since the epoch as a `SystemTime`, tolerating the 0 the protocol +/// uses for "the peer cannot tell" and the negative values a badly-set phone +/// clock can produce. +fn mtime_of(secs: i64) -> SystemTime { + if secs >= 0 { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs as u64) + } else { + SystemTime::UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs()) + } +} + +/// A protocol code as an errno. +/// +/// The reason [`code`] is errno-shaped in the first place: this is meant to be +/// a rename, not a translation. What the file manager shows the user comes +/// straight from here, so [`crate::fs_link::NO_LINK`] mapping to `EHOSTDOWN` +/// ("Host is down") rather than a generic I/O error is the difference between +/// an accurate message and a puzzling one. +fn errno_of(c: i32) -> Errno { + match c { + code::NOENT => Errno::ENOENT, + code::ACCES => Errno::EACCES, + code::BADF => Errno::EBADF, + code::INVAL => Errno::EINVAL, + code::NOTSUP => Errno::ENOTSUP, + code::ISDIR => Errno::EISDIR, + code::ROFS => Errno::EROFS, + crate::fs_link::NO_LINK => Errno::EHOSTDOWN, + // Includes `code::IO`, and anything a future peer invents. + _ => Errno::EIO, + } +} + +// --------------------------------------------------------------------------- +// The filesystem +// --------------------------------------------------------------------------- + +/// Hand `body` the runtime and let it answer whenever the phone does. +/// +/// Every operation goes through here, which is what keeps the FUSE session +/// thread free to dispatch the next one. Nothing waits on the result: the +/// `Reply` carries the request id, so the answer finds its way back on its own. +macro_rules! detach { + ($fs:expr, |$vfs:ident, $inodes:ident| $body:block) => {{ + let $vfs = $fs.vfs.clone(); + let $inodes = $fs.inodes.clone(); + $fs.rt.spawn(async move { $body }); + }}; +} + +impl fuser::Filesystem for PhoneFs { + fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> std::io::Result<()> { + // Readahead is the one design-doc §7 item the kernel implements for us: + // it turns a sequential reader into several overlapping `read` calls, + // and because we never block one, they overlap on the wire too. Ask for + // as much as it will give (it clamps and reports what it took). + let readahead = config.set_max_readahead(1024 * 1024).unwrap_or_else(|max| { + let _ = config.set_max_readahead(max); + max + }); + // Background requests are how many of those may be outstanding. Ours + // are answered off-thread, so a deeper queue costs nothing here. + let _ = config.set_max_background(MAX_INFLIGHT as u16 * 2); + tracing::info!(readahead, "fs-mount: kernel session up"); + Ok(()) + } + + fn lookup(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { + // Names arrive from the kernel as bytes and reach us over JSON, so a + // name that is not UTF-8 cannot round-trip in the first place; matching + // lossily just makes it miss rather than panic. + let name = name.to_string_lossy().to_string(); + let (uid, gid) = (req.uid(), req.gid()); + let parent = parent.0; + detach!(self, |vfs, inodes| { + let Some(parent) = addr_of(&inodes, parent) else { + return reply.error(Errno::ENOENT); + }; + match vfs.lookup_child(&parent, &name).await { + Ok(e) => { + let ino = intern(&inodes, &e.path); + reply.entry(&ATTR_TTL, &attr_of(ino, &e, uid, gid), Generation(0)) + } + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn getattr( + &self, + req: &Request, + ino: INodeNo, + _fh: Option, + reply: ReplyAttr, + ) { + let (uid, gid) = (req.uid(), req.gid()); + let ino = ino.0; + detach!(self, |vfs, inodes| { + let Some(addr) = addr_of(&inodes, ino) else { + return reply.error(Errno::ENOENT); + }; + match vfs.entry_of(&addr).await { + Ok(e) => reply.attr(&ATTR_TTL, &attr_of(ino, &e, uid, gid)), + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn readdir( + &self, + _req: &Request, + ino: INodeNo, + _fh: fuser::FileHandle, + offset: u64, + mut reply: ReplyDirectory, + ) { + let ino = ino.0; + detach!(self, |vfs, inodes| { + let Some(addr) = addr_of(&inodes, ino) else { + return reply.error(Errno::ENOENT); + }; + let entries = match vfs.listing(&addr).await { + Ok(v) => v, + Err(c) => return reply.error(errno_of(c)), + }; + // `.` and `..` occupy the first two slots so the rest of the indices + // line up with the listing. `..` points at this directory rather + // than the parent: the parent is not knowable from an opaque + // address, and no caller resolves `..` through us — the kernel + // remembers the path it walked. + for (i, (child, kind, name)) in std::iter::once((ino, FileType::Directory, ".".into())) + .chain(std::iter::once((ino, FileType::Directory, "..".into()))) + .chain(entries.iter().map(|e| { + ( + intern(&inodes, &e.path), + if e.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }, + e.name.clone(), + ) + })) + .enumerate() + .skip(offset as usize) + { + // The offset we hand back is where to RESUME, hence i + 1. + if reply.add(INodeNo(child), i as u64 + 1, kind, &name) { + break; + } + } + reply.ok(); + }); + } + + fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { + // The kernel enforces `ro` at the mount before reaching us, so this is + // belt-and-braces for a caller that got here another way. + if flags.acc_mode() != OpenAccMode::O_RDONLY { + return reply.error(Errno::EROFS); + } + let ino = ino.0; + detach!(self, |vfs, inodes| { + let Some(addr) = addr_of(&inodes, ino) else { + return reply.error(Errno::ENOENT); + }; + match vfs.open(&addr).await { + // No flags: keeping the page cache is what lets the kernel serve + // a re-read without us, and read ahead of the reader. + Ok(fh) => reply.opened(fuser::FileHandle(fh), FopenFlags::empty()), + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn read( + &self, + _req: &Request, + _ino: INodeNo, + fh: fuser::FileHandle, + offset: u64, + size: u32, + _flags: OpenFlags, + _lock_owner: Option, + reply: ReplyData, + ) { + let fh = fh.0; + detach!(self, |vfs, _inodes| { + match vfs.read_range(fh, offset, size).await { + Ok(bytes) => reply.data(&bytes), + Err(c) => reply.error(errno_of(c)), + } + }); + } + + fn release( + &self, + _req: &Request, + _ino: INodeNo, + fh: fuser::FileHandle, + _flags: OpenFlags, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + let fh = fh.0; + detach!(self, |vfs, _inodes| { + // Answer the kernel first: `close()` cannot fail from here and the + // caller should not wait on a phone round trip to return from it. + reply.ok(); + vfs.close(fh).await; + }); + } + + // ── Ops answered without asking the phone ──────────────────────────── + // + // Left unimplemented, each of these answers `ENOSYS`, which the kernel + // handles but fuser logs as "[Not Implemented]" — a warning per call in the + // app's log, several per file opened. Answering them here costs nothing and + // the answers are all knowable locally. + + /// Nothing to flush: the mount is read-only, and a read has no state on the + /// peer beyond the handle `release` will close. + fn flush( + &self, + _req: &Request, + _ino: INodeNo, + _fh: fuser::FileHandle, + _lock_owner: fuser::LockOwner, + reply: ReplyEmpty, + ) { + reply.ok(); + } + + /// No extended attributes, and no way to have any: the protocol carries a + /// name, a kind, a size and an mtime, and nothing else. `ENODATA` is the + /// answer for "this attribute is not set", which is the truth for every + /// name that could be asked about. + fn getxattr( + &self, + _req: &Request, + _ino: INodeNo, + _name: &OsStr, + _size: u32, + reply: ReplyXattr, + ) { + reply.error(Errno::ENODATA); + } + + /// An empty list, not an error — `cp -a` and Dolphin both ask, and a failure + /// here reads to them as a file they could not fully inspect. + fn listxattr(&self, _req: &Request, _ino: INodeNo, size: u32, reply: ReplyXattr) { + // The two-call protocol: size 0 asks how much room to allocate. + if size == 0 { + reply.size(0); + } else { + reply.data(&[]); + } + } + + fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) { + // Zeroed on purpose. There is no protocol op for free space, and a made + // up figure would be a lie a file manager acts on. Zero free on a + // read-only mount is at least the truth: nothing can be written here. + // Revisit with design doc §8 step 5, which is when a real number starts + // to matter. + reply.statfs(0, 0, 0, 0, 0, 4096, 255, 4096); + } +} + +// --------------------------------------------------------------------------- +// Mount lifecycle +// --------------------------------------------------------------------------- + +/// The live session. Dropping it unmounts, which is what cleans up on a normal +/// app exit. +static SESSION: OnceLock>> = OnceLock::new(); + +fn session_slot() -> &'static Mutex> { + SESSION.get_or_init(|| Mutex::new(None)) +} + +/// Where the phone's files appear. +/// +/// Under `XDG_RUNTIME_DIR` because the session lifetime is exactly right: the +/// directory goes away at logout, so a crashed app cannot leave a stale mount +/// point in the user's home. +pub(crate) fn mount_point() -> PathBuf { + let base = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join("vortex").join("phone") +} + +pub(crate) fn is_mounted() -> bool { + session_slot().lock().map(|g| g.is_some()).unwrap_or(false) +} + +/// Mount the phone's storage. Returns the mount point. +pub(crate) async fn mount() -> Result { + let dir = mount_point(); + let rt = tokio::runtime::Handle::current(); + // `Session::new` runs `fusermount3` and waits for the kernel's INIT, so it + // does not belong on an async thread. + tokio::task::spawn_blocking(move || { + let bg = spawn_session(rt, &dir, LinkRemote)?; + if let Ok(mut g) = session_slot().lock() { + *g = Some(bg); + } + tracing::info!(path = %dir.display(), "fs-mount: mounted"); + Ok(dir) + }) + .await + .map_err(|e| format!("mount task failed: {e}"))? +} + +/// Mount `remote` at `dir` and start serving it. +/// +/// Generic over the remote so the integration test at the bottom of this file +/// can mount its fake peer for real — kernel, session thread and all — which is +/// the only way to check that what we tell the kernel is what a program reading +/// the mount actually sees. +fn spawn_session( + rt: tokio::runtime::Handle, + dir: &Path, + remote: R, +) -> Result { + // FIRST, before touching the path at all: a previous run that died without + // unmounting (a crash, a SIGKILL, the installer restarting the app) leaves + // the mount in the table with no server behind it, and every syscall on it + // answers ENOTCONN. That includes the `stat` inside `create_dir_all`, which + // therefore fails with EEXIST — the directory is there, it just cannot be + // looked at. Clearing the corpse first is what makes a remount work. + // + // Only ever our own private path under XDG_RUNTIME_DIR, and a no-op when + // nothing is mounted there. + let _ = std::process::Command::new("fusermount3") + .args(["-quz", &dir.to_string_lossy()]) + .stderr(std::process::Stdio::null()) + .status(); + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + + let fs = PhoneFs { + vfs: Arc::new(Vfs::new(remote)), + inodes: Arc::new(Mutex::new(Inodes::new())), + rt, + }; + let mut config = fuser::Config::default(); + config.mount_options = vec![ + // What `mount` and `df` call it. + MountOption::FSName("vortex".into()), + MountOption::Subtype("vortex".into()), + // Read-only until design doc §8 step 5. Enforced by the kernel, so a + // write is refused without a round trip to the phone. + MountOption::RO, + MountOption::NoSuid, + MountOption::NoDev, + MountOption::NoExec, + // Let the KERNEL do permission checks, from the mode bits we report. + // Without it the kernel asks us (`access`) on every path walk, which is + // a round trip through the session thread to answer "yes" — the mount + // is visible to its owner alone and read-only, so there is nothing for + // us to decide that the mode bits do not already say. + MountOption::DefaultPermissions, + ]; + // One session thread is enough: it only parses a request and hands it to + // the runtime, so it is never the thing that is busy. + fuser::Session::new(fs, dir, &config) + .map_err(|e| format!("mounting {} failed: {e}", dir.display()))? + .spawn() + .map_err(|e| format!("session thread failed: {e}")) +} + +/// Unmount, if mounted. +pub(crate) fn unmount() { + let taken = session_slot().lock().ok().and_then(|mut g| g.take()); + if let Some(bg) = taken { + // `umount_and_join` waits for the session loop to finish, which needs + // the kernel to have released the mount — a blocking call, so keep it + // off the async threads. + std::thread::spawn(move || match bg.umount_and_join() { + Ok(()) => tracing::info!("fs-mount: unmounted"), + Err(e) => tracing::warn!("fs-mount: unmount failed: {e}"), + }); + } +} + +/// Detach the mount on the process's way out. +/// +/// A FUSE mount whose server process has died is not gone — it stays in the +/// mount table answering `ENOTCONN`, which makes `df` error and leaves a broken +/// entry in every file manager. So the deliberate-quit path detaches it first. +/// +/// `fusermount3 -z` rather than [`unmount`] because this runs microseconds +/// before `exit()`: the lazy form returns immediately and lets the kernel finish +/// when the last user of the mount goes away, where waiting for the session +/// thread to join would simply be killed half-way. +pub(crate) fn unmount_on_exit() { + if !is_mounted() { + return; + } + let dir = mount_point(); + let _ = std::process::Command::new("fusermount3") + .args(["-quz", &dir.to_string_lossy()]) + .stderr(std::process::Stdio::null()) + .status(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs_vfs::tests::{big_bytes, dir, file, FakePeer}; + + #[test] + fn a_directory_and_a_file_translate_to_the_right_stat() { + let d = attr_of(7, &dir("DCIM", "/sdcard/DCIM"), 1000, 1000); + assert_eq!(d.kind, FileType::Directory); + assert_eq!(d.perm, 0o555); + assert_eq!(d.ino, INodeNo(7)); + let f = attr_of(8, &file("a.txt", "/sdcard/a.txt", 5), 1000, 1000); + assert_eq!(f.kind, FileType::RegularFile); + assert_eq!(f.perm, 0o444, "the mount is read-only"); + assert_eq!(f.size, 5); + assert_eq!(f.blocks, 1); + assert_eq!( + f.mtime, + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) + ); + } + + #[test] + fn an_unknown_mtime_is_the_epoch_not_a_panic() { + assert_eq!(mtime_of(0), SystemTime::UNIX_EPOCH); + // A phone with a clock set before 1970 must not take the mount down. + assert!(mtime_of(-86_400) < SystemTime::UNIX_EPOCH); + } + + #[test] + fn every_protocol_code_reaches_the_user_as_itself() { + assert_eq!(errno_of(code::NOENT), Errno::ENOENT); + assert_eq!(errno_of(code::ACCES), Errno::EACCES); + assert_eq!(errno_of(code::ROFS), Errno::EROFS); + assert_eq!(errno_of(code::ISDIR), Errno::EISDIR); + assert_eq!(errno_of(crate::fs_link::NO_LINK), Errno::EHOSTDOWN); + assert_eq!(errno_of(code::IO), Errno::EIO); + assert_eq!( + errno_of(9999), + Errno::EIO, + "an unknown code is still an error" + ); + } + + #[test] + fn an_inode_is_stable_and_never_reused() { + let t = Mutex::new(Inodes::new()); + let first = intern(&t, "/sdcard/a.txt"); + assert_eq!(intern(&t, "/sdcard/a.txt"), first, "same address, same ino"); + assert_ne!(intern(&t, "/sdcard/DCIM"), first); + assert_ne!(first, ROOT_INO); + assert_eq!(addr_of(&t, ROOT_INO).as_deref(), Some(ROOT_ADDR)); + assert_eq!(addr_of(&t, first).as_deref(), Some("/sdcard/a.txt")); + assert_eq!(addr_of(&t, 9999), None, "an unknown ino resolves to nothing"); + } + + /// The whole thing, for real: a kernel mount over the fake peer, driven by + /// ordinary `std::fs` calls. + /// + /// `#[ignore]` because it needs `/dev/fuse` and `fusermount3`, which a + /// container or a build box may not have, and a test that cannot run there + /// should not look like a failure. Run it with: + /// + /// ```text + /// cargo test --lib fs_fuse -- --ignored --nocapture + /// ``` + /// + /// Multi-threaded on purpose: the syscalls block a thread while the FUSE + /// operations they trigger are answered on another. + #[tokio::test(flavor = "multi_thread")] + #[ignore = "needs /dev/fuse and fusermount3"] + async fn a_real_mount_answers_ordinary_file_calls() { + let peer = Arc::new(FakePeer::new()); + let dir = std::env::temp_dir().join(format!("vortex-fuse-{}", std::process::id())); + let session = spawn_session(tokio::runtime::Handle::current(), &dir, peer.clone()) + .expect("mount failed — is /dev/fuse available?"); + + let at = dir.clone(); + let seen = tokio::task::spawn_blocking(move || { + let mut names: Vec = std::fs::read_dir(&at) + .expect("read_dir") + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + names.sort(); + let small = std::fs::read_to_string(at.join("a.txt")).expect("read a.txt"); + let big = std::fs::read(at.join("DCIM").join("big.bin")).expect("read big.bin"); + let meta = std::fs::metadata(at.join("DCIM")).expect("stat DCIM"); + // Writes must be refused by the kernel, without reaching the peer. + let write = std::fs::write(at.join("nope.txt"), b"x"); + (names, small, big, meta.is_dir(), write.is_err()) + }) + .await + .unwrap(); + + let (names, small, big, dcim_is_dir, write_refused) = seen; + assert_eq!(names, ["DCIM", "a.txt"]); + assert_eq!(small, "hello"); + assert!(dcim_is_dir); + assert!(write_refused, "the mount must be read-only"); + assert_eq!(big, big_bytes(), "100 KiB came back wrong through the kernel"); + + tokio::task::spawn_blocking(move || { + let _ = session.umount_and_join(); + let _ = std::fs::remove_dir(&dir); + }) + .await + .unwrap(); + } +} diff --git a/linux/ui-tauri/src-tauri/src/fs_mount.rs b/linux/ui-tauri/src-tauri/src/fs_mount.rs index ea33f5d..899b318 100644 --- a/linux/ui-tauri/src-tauri/src/fs_mount.rs +++ b/linux/ui-tauri/src-tauri/src/fs_mount.rs @@ -1,768 +1,50 @@ -//! The phone's storage as a real filesystem, via FUSE (design doc §8 step 6). +//! Presenting the phone's storage as a filesystem — the part the rest of the +//! app talks to (design doc §8 step 6). //! -//! Dolphin, Nautilus, `cp`, mpv and every thumbnailer already know how to talk -//! to a filesystem, so the cheapest way to make the phone's files usable is to -//! be one. This module is the Linux **mount adapter**: it turns kernel FUSE -//! operations into the ranged-filesystem protocol and back. The phone is -//! untouched by it — that is the whole point of §2's "the phone serves a dumb, -//! narrow protocol; the laptop does everything clever". +//! Three files sit behind this one: //! -//! # Why FUSE and not the WebDAV gateway first +//! * [`crate::fs_vfs`] — everything between a mount and the wire: caching, the +//! path walk, pipelined ranged reads, the concurrency cap. OS-independent, +//! and where the interesting bugs live, so it is written and tested once. +//! * [`crate::fs_fuse`] — the Linux adapter. A real mount under +//! `$XDG_RUNTIME_DIR`. +//! * [`crate::fs_projfs`] — the Windows adapter, on ProjFS. A projected +//! directory under `%LOCALAPPDATA%`. //! -//! The doc sequenced WebDAV ahead of this because one gateway serves both -//! operating systems. On Linux it buys nothing FUSE does not: GVFS/KIO mount -//! `davs://` in *their* process, so only their own file dialogs see the files — -//! `cp`, `mpv` and every non-KIO program do not. Windows' WebClient also caps a -//! file at ~50 MB, and escaping a 64 MB cap into a 50 MB one would be absurd. -//! A FUSE mount is a real path in the filesystem with no ceiling, and ProjFS -//! gives Windows the same later. +//! The split is the design doc's: "the mount adapter is swappable; the protocol +//! is the investment". Changing how a desktop presents the files must never +//! require touching the phone, and adding the second desktop did not. //! -//! # Concurrency, which is the load-bearing design decision +//! # Why not the WebDAV gateway the doc sequenced first //! -//! FUSE hands us one request at a time on one thread. Answering each one -//! inline — issue the request, block on the phone's reply, return — would make -//! the mount as slow as the round trip *times* the number of operations, and a -//! file manager stats every visible file at once. So every operation is -//! immediately handed to the async runtime and its `Reply` object (which is -//! `Send`, deliberately) is answered from there. The session thread does -//! nothing but parse and dispatch. +//! One gateway would have served both OSes, which is why it was first. It buys +//! less than it looks like on either: //! -//! That is also what makes the kernel's own readahead work for us: a sequential -//! reader triggers several `read` calls at once, and because we never block, -//! they overlap on the wire instead of queueing. +//! * **Linux:** GVFS and KIO mount `davs://` *inside the file manager's own +//! process*, so only that program's file dialogs see the files — `cp`, `mpv`, +//! a text editor's Open box cannot. A FUSE mount is a path, so everything +//! can. +//! * **Windows:** the WebClient redirector caps a file at ~50 MB by default. +//! Escaping a 64 MB cap into a 50 MB one would be absurd, and ProjFS ships in +//! Windows 10 1809+ with no third-party install. //! -//! Two brakes on it: a semaphore caps how many requests may be on the link at -//! once (a thumbnailer will otherwise fire dozens and starve the BLE session -//! with them), and each `read` splits into at most [`READ_WINDOW`] pipelined -//! ranged reads. -//! -//! # Why a `FsRemote` trait -//! -//! The interesting bugs here are ours — inode identity, cache staleness, -//! reassembling a short read — and none of them need a phone to reproduce. The -//! trait lets the tests below drive the whole filesystem against a fake tree in -//! memory; [`LinkRemote`] is the one-line production implementation. +//! So both went native, and no HTTP server, port or auth story exists on either. -use std::collections::HashMap; -use std::ffi::OsStr; -use std::future::Future; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant, SystemTime}; -use fuser::{ - Errno, FileAttr, FileType, FopenFlags, Generation, INodeNo, KernelConfig, MountOption, - OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, - ReplyOpen, ReplyStatfs, ReplyXattr, Request, -}; -use vortex_l3_daemon::core::fs_proto::{self as p, code}; - -/// How long the kernel may trust an attribute or a directory entry. -/// -/// This is the metadata cache design doc §7 asks for, and the kernel gives it -/// to us for free: within the TTL a `stat` never reaches this process, let -/// alone the phone. Five seconds is long enough to survive a file manager's -/// stat storm and short enough that a file changed on the phone shows up while -/// the user is still looking at the folder. -const ATTR_TTL: Duration = Duration::from_secs(5); - -/// How long *we* keep a directory listing. -/// -/// Separate from [`ATTR_TTL`] because it serves a different purpose: a listing -/// is how a child's opaque address is discovered at all (see [`Inner::listing`]), -/// so it is consulted on paths the kernel cache never reaches. -const DIR_TTL: Duration = Duration::from_secs(5); - -/// Requests allowed on the link at once. -/// -/// Not a throughput knob — a fairness one. A thumbnailer opening a folder of -/// photos will issue as many reads as there are files, and an unbounded queue -/// of them would delay every listing behind megabytes of image data and, on a -/// BLE fallback, starve the session that carries everything else. -const MAX_INFLIGHT: usize = 8; - -/// Ranged reads pipelined inside ONE FUSE read. -/// -/// The kernel asks for up to 128 KiB; the protocol caps a read at 48 KiB. Those -/// pieces are issued together rather than one after another, for the same -/// reason [`crate::fs_link::read_all`] does it. -const READ_WINDOW: usize = 4; - -/// The mount root. FUSE fixes this at 1; the phone's synthetic root (`""`, the -/// path that lists what it shares) lives here. -const ROOT_INO: u64 = 1; - -/// Entries we will hold for one directory. A guard against a peer that pages -/// forever, not a real limit — 200k files in one folder is already pathological. -const MAX_DIR_ENTRIES: usize = 200_000; - -/// Cached directories, and cached attributes, before expired ones are swept. -/// -/// The caches are keyed by inode and inodes are never recycled, so without a -/// sweep a long browse would hold every listing it ever fetched for the life of -/// the process — which for this app is days. Sweeping on insert past a -/// threshold keeps it bounded without a timer, and the cost lands on the -/// operation that grew the map. -const CACHE_SWEEP_AT: usize = 4_096; - -// --------------------------------------------------------------------------- -// The remote half -// --------------------------------------------------------------------------- - -/// The peer-facing operations the mount needs. -/// -/// Returns `impl Future + Send` rather than using `async fn` in the trait so -/// the futures can be `tokio::spawn`ed, which is the entire concurrency model -/// above. -pub(crate) trait FsRemote: Send + Sync + 'static { - fn list( - &self, - path: String, - cursor: u32, - ) -> impl Future, Option), i32>> + Send; - fn stat(&self, path: String) -> impl Future> + Send; - /// Returns `(handle, size at open time)`. - fn open(&self, path: String) -> impl Future> + Send; - /// Returns `(bytes, eof)`. A short result is normal. - fn read( - &self, - handle: u64, - offset: u64, - len: u32, - ) -> impl Future, bool), i32>> + Send; - fn close(&self, handle: u64) -> impl Future + Send; -} - -/// The production remote: the protocol client over whatever transport is up. -pub(crate) struct LinkRemote; - -// The trait declares `-> impl Future + Send`; an impl may satisfy that with a -// plain `async fn`, and the compiler still checks the future is `Send`. -impl FsRemote for LinkRemote { - async fn list( - &self, - path: String, - cursor: u32, - ) -> Result<(Vec, Option), i32> { - crate::fs_link::list(&path, cursor).await - } - async fn stat(&self, path: String) -> Result { - crate::fs_link::stat(&path).await - } - async fn open(&self, path: String) -> Result<(u64, u64), i32> { - // Never for writing: the mount is read-only (design doc §8 step 5). - crate::fs_link::open(&path, false).await - } - async fn read(&self, handle: u64, offset: u64, len: u32) -> Result<(Vec, bool), i32> { - crate::fs_link::read(handle, offset, len).await - } - async fn close(&self, handle: u64) { - crate::fs_link::close(handle).await - } -} - -// --------------------------------------------------------------------------- -// Inode identity -// --------------------------------------------------------------------------- - -/// Inode numbers ↔ peer addresses. -/// -/// A peer address is an opaque token, not a path — on Android it is a document -/// URI — so an inode number cannot be derived from one and the mapping has to -/// be remembered. Numbers are **never recycled**: a file manager holds inode -/// numbers across a refresh and reusing one would silently show it the wrong -/// file. The table therefore only grows, which is fine at a few dozen bytes per -/// entry for a session's worth of browsing. -#[derive(Default)] -struct Inodes { - by_ino: HashMap, - by_path: HashMap, - next: u64, -} - -impl Inodes { - fn new() -> Self { - let mut t = Self { - next: ROOT_INO + 1, - ..Default::default() - }; - // The peer's synthetic root: the empty path is what lists its shares. - t.by_ino.insert(ROOT_INO, String::new()); - t.by_path.insert(String::new(), ROOT_INO); - t - } - - fn intern(&mut self, path: &str) -> u64 { - if let Some(&ino) = self.by_path.get(path) { - return ino; - } - let ino = self.next; - self.next += 1; - self.by_ino.insert(ino, path.to_string()); - self.by_path.insert(path.to_string(), ino); - ino - } - - fn path(&self, ino: u64) -> Option<&str> { - self.by_ino.get(&ino).map(String::as_str) - } -} - -/// A file the kernel has open, keyed by the handle we handed back from `open`. -struct OpenFile { - /// The peer's handle. Ours is a separate number so a peer handle of 0 (or a - /// reused one) cannot collide with "no handle". - remote: u64, - /// Size as of `open`. Reads are clamped to it so we never ask the phone for - /// a range past the end just because the kernel rounded up to a page. - size: u64, -} - -struct Inner { - remote: R, - inodes: Mutex, - /// Listings by directory inode. - dirs: Mutex)>>, - /// Attributes by inode, seeded from listings. - attrs: Mutex>, - files: Mutex>, - next_fh: AtomicU64, - gate: tokio::sync::Semaphore, -} - -impl Inner { - fn new(remote: R) -> Self { - Self { - remote, - inodes: Mutex::new(Inodes::new()), - dirs: Mutex::new(HashMap::new()), - attrs: Mutex::new(HashMap::new()), - files: Mutex::new(HashMap::new()), - next_fh: AtomicU64::new(1), - gate: tokio::sync::Semaphore::new(MAX_INFLIGHT), - } - } - - // Every lock here is a `std::sync::Mutex` held for a single map operation - // and never across an `await`. Keeping that discipline is why the helpers - // are this granular. - - fn intern(&self, path: &str) -> u64 { - self.inodes - .lock() - .map(|mut t| t.intern(path)) - .unwrap_or(ROOT_INO) - } - - fn path_of(&self, ino: u64) -> Option { - self.inodes - .lock() - .ok() - .and_then(|t| t.path(ino).map(str::to_string)) - } - - fn cached_dir(&self, ino: u64) -> Option> { - let g = self.dirs.lock().ok()?; - let (at, entries) = g.get(&ino)?; - (at.elapsed() < DIR_TTL).then(|| entries.clone()) - } - - fn cached_attr(&self, ino: u64) -> Option { - let g = self.attrs.lock().ok()?; - let (at, entry) = g.get(&ino)?; - (at.elapsed() < ATTR_TTL).then(|| entry.clone()) - } - - fn store_attr(&self, ino: u64, entry: &p::FsEntry) { - if let Ok(mut g) = self.attrs.lock() { - if g.len() >= CACHE_SWEEP_AT { - g.retain(|_, (at, _)| at.elapsed() < ATTR_TTL); - } - g.insert(ino, (Instant::now(), entry.clone())); - } - } - - /// Run one peer request under the concurrency cap. - async fn gated(&self, f: impl Future) -> T { - // `acquire` only fails on a closed semaphore, and we never close it; - // proceeding uncapped beats failing the operation. - let _permit = self.gate.acquire().await; - f.await - } - - /// A directory's entries, from cache or from the peer. - /// - /// Also where child inodes are minted and the attribute cache is seeded: - /// a file manager follows every `readdir` with a `lookup` and a `getattr` - /// per entry, and answering those from the listing we already have is the - /// difference between one round trip per folder and one per file. - async fn listing(&self, ino: u64) -> Result, i32> { - if let Some(entries) = self.cached_dir(ino) { - return Ok(entries); - } - let path = self.path_of(ino).ok_or(code::NOENT)?; - let mut all: Vec = Vec::new(); - let mut cursor = 0u32; - loop { - let (page, next) = self.gated(self.remote.list(path.clone(), cursor)).await?; - all.extend(page); - match next { - // A peer that keeps handing back the same cursor is not making - // progress; stopping with a partial listing beats looping. - Some(c) if c != cursor && all.len() < MAX_DIR_ENTRIES => cursor = c, - _ => break, - } - } - // An entry with no address cannot be opened or listed, so it would - // appear as a permanently broken row. Drop it and say so once. - let before = all.len(); - all.retain(|e| !e.path.is_empty()); - if all.len() != before { - tracing::warn!( - dropped = before - all.len(), - "fs-mount: listing had entries with no address" - ); - } - for e in &all { - let child = self.intern(&e.path); - self.store_attr(child, e); - } - if let Ok(mut g) = self.dirs.lock() { - if g.len() >= CACHE_SWEEP_AT { - g.retain(|_, (at, _)| at.elapsed() < DIR_TTL); - } - g.insert(ino, (Instant::now(), all.clone())); - } - Ok(all) - } - - /// Resolve one name inside a directory. - /// - /// Goes through the parent's listing rather than joining the name onto the - /// parent's path, because a child's address is opaque: under SAF a name is - /// simply not addressable, and constructing `parent/name` would produce a - /// path the phone cannot resolve. - async fn lookup_child(&self, parent: u64, name: &str) -> Result<(u64, p::FsEntry), i32> { - let entries = self.listing(parent).await?; - let entry = entries - .into_iter() - .find(|e| e.name == name) - .ok_or(code::NOENT)?; - let ino = self.intern(&entry.path); - self.store_attr(ino, &entry); - Ok((ino, entry)) - } - - /// One inode's attributes. - async fn entry_of(&self, ino: u64) -> Result { - if ino == ROOT_INO { - return Ok(root_entry()); - } - if let Some(e) = self.cached_attr(ino) { - return Ok(e); - } - let path = self.path_of(ino).ok_or(code::NOENT)?; - let entry = self.gated(self.remote.stat(path)).await?; - self.store_attr(ino, &entry); - Ok(entry) - } - - /// Read `size` bytes at `offset` from an open file, as one contiguous run. - /// - /// Splits into protocol-sized pieces and keeps [`READ_WINDOW`] of them in - /// flight. `FuturesOrdered` yields in issue order, which is also offset - /// order, so the pieces concatenate directly — and each future carries the - /// offset it asked for, so a short piece is *detected* rather than silently - /// shifting everything after it. On a gap we return the prefix: a FUSE read - /// must be contiguous from `offset`, and a short reply is a legal answer. - async fn read_range(&self, remote: u64, offset: u64, size: u32) -> Result, i32> { - use futures::stream::{FuturesOrdered, StreamExt}; - - let end = offset.saturating_add(size as u64); - let mut out: Vec = Vec::new(); - let mut pending = FuturesOrdered::new(); - let mut next = offset; - let mut expect = offset; - loop { - while pending.len() < READ_WINDOW && next < end { - let at = next; - let len = (end - at).min(p::MAX_READ_LEN as u64) as u32; - pending.push_back(async move { - (at, self.gated(self.remote.read(remote, at, len)).await) - }); - next = at + len as u64; - } - let Some((at, res)) = pending.next().await else { - break; - }; - let (bytes, eof) = res?; - if at != expect { - // An earlier piece came back short, so this one starts past the - // end of what we have. Anything further would land at the wrong - // file offset. - break; - } - expect = at + bytes.len() as u64; - out.extend_from_slice(&bytes); - if eof || bytes.is_empty() { - break; - } - } - Ok(out) - } -} - -// --------------------------------------------------------------------------- -// Translation -// --------------------------------------------------------------------------- - -/// The mount root's own attributes. -/// -/// Synthetic rather than a `STAT` of the empty path: the peer's root is a list -/// of what it shares, not a directory it can stat, and `ls` of the mount point -/// must work regardless. `UNIX_EPOCH` rather than "now" so the kernel does not -/// see the root's mtime change on every remount. -fn root_entry() -> p::FsEntry { - p::FsEntry { - name: "/".to_string(), - path: String::new(), - is_dir: true, - size: 0, - mtime: 0, - readonly: true, - } -} - -/// A protocol entry as a kernel `stat`. -/// -/// Permissions are fixed rather than reported by the peer: the mount is -/// read-only until design doc §8 step 5 lands, and a writable-looking mode bit -/// would only get a copy half-way through before the phone refused it. `nlink` -/// of 2 for a directory is the usual lie (`.` and `..`) — the real subdirectory -/// count would cost a listing per stat. -fn attr_of(ino: u64, e: &p::FsEntry, uid: u32, gid: u32) -> FileAttr { - let mtime = mtime_of(e.mtime); - FileAttr { - ino: INodeNo(ino), - size: if e.is_dir { 0 } else { e.size }, - blocks: e.size.div_ceil(512), - atime: mtime, - mtime, - ctime: mtime, - crtime: mtime, - kind: if e.is_dir { - FileType::Directory - } else { - FileType::RegularFile - }, - perm: if e.is_dir { 0o555 } else { 0o444 }, - nlink: if e.is_dir { 2 } else { 1 }, - uid, - gid, - rdev: 0, - blksize: 4096, - flags: 0, - } -} - -/// Seconds since the epoch as a `SystemTime`, tolerating the 0 the protocol -/// uses for "the peer cannot tell" and the negative values a badly-set phone -/// clock can produce. -fn mtime_of(secs: i64) -> SystemTime { - if secs >= 0 { - SystemTime::UNIX_EPOCH + Duration::from_secs(secs as u64) - } else { - SystemTime::UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs()) - } -} - -/// A protocol code as an errno. -/// -/// The reason [`code`] is errno-shaped in the first place: this is meant to be -/// a rename, not a translation. What the file manager shows the user comes -/// straight from here, so [`crate::fs_link::NO_LINK`] mapping to `EHOSTDOWN` -/// ("Host is down") rather than a generic I/O error is the difference between -/// an accurate message and a puzzling one. -fn errno_of(c: i32) -> Errno { - match c { - code::NOENT => Errno::ENOENT, - code::ACCES => Errno::EACCES, - code::BADF => Errno::EBADF, - code::INVAL => Errno::EINVAL, - code::NOTSUP => Errno::ENOTSUP, - code::ISDIR => Errno::EISDIR, - code::ROFS => Errno::EROFS, - crate::fs_link::NO_LINK => Errno::EHOSTDOWN, - // Includes `code::IO`, and anything a future peer invents. - _ => Errno::EIO, - } -} - -// --------------------------------------------------------------------------- -// The filesystem -// --------------------------------------------------------------------------- - -struct PhoneFs { - inner: Arc>, - rt: tokio::runtime::Handle, -} - -/// Hand `body` the runtime and let it answer whenever the phone does. -/// -/// Every operation goes through here, which is what keeps the FUSE session -/// thread free to dispatch the next one. Nothing waits on the result: the -/// `Reply` carries the request id, so the answer finds its way back on its own. -macro_rules! detach { - ($fs:expr, |$inner:ident| $body:block) => {{ - let $inner = $fs.inner.clone(); - $fs.rt.spawn(async move { $body }); - }}; -} - -impl fuser::Filesystem for PhoneFs { - fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> std::io::Result<()> { - // Readahead is the one §7 item the kernel implements for us: it turns a - // sequential reader into several overlapping `read` calls, and because - // we never block one, they overlap on the wire too. Ask for as much as - // it will give (it clamps and reports what it took). - let readahead = config.set_max_readahead(1024 * 1024).unwrap_or_else(|max| { - let _ = config.set_max_readahead(max); - max - }); - // Background requests are how many of those may be outstanding. Ours - // are answered off-thread, so a deeper queue costs nothing here. - let _ = config.set_max_background(MAX_INFLIGHT as u16 * 2); - tracing::info!(readahead, "fs-mount: kernel session up"); - Ok(()) - } - - fn lookup(&self, req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) { - // Names arrive from the kernel as bytes and reach us over JSON, so a - // name that is not UTF-8 cannot round-trip in the first place; matching - // lossily just makes it miss rather than panic. - let name = name.to_string_lossy().to_string(); - let (uid, gid) = (req.uid(), req.gid()); - let parent = parent.0; - detach!(self, |inner| { - match inner.lookup_child(parent, &name).await { - Ok((ino, e)) => reply.entry(&ATTR_TTL, &attr_of(ino, &e, uid, gid), Generation(0)), - Err(c) => reply.error(errno_of(c)), - } - }); - } - - fn getattr(&self, req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { - let (uid, gid) = (req.uid(), req.gid()); - let ino = ino.0; - detach!(self, |inner| { - match inner.entry_of(ino).await { - Ok(e) => reply.attr(&ATTR_TTL, &attr_of(ino, &e, uid, gid)), - Err(c) => reply.error(errno_of(c)), - } - }); - } - - fn readdir( - &self, - _req: &Request, - ino: INodeNo, - _fh: fuser::FileHandle, - offset: u64, - mut reply: ReplyDirectory, - ) { - let ino = ino.0; - detach!(self, |inner| { - let entries = match inner.listing(ino).await { - Ok(v) => v, - Err(c) => return reply.error(errno_of(c)), - }; - // `.` and `..` occupy the first two slots so the rest of the - // indices line up with the listing. `..` points at this directory - // for the root, and at *this* directory elsewhere too: the parent - // is not knowable from an opaque address, and no caller resolves - // `..` through us — the kernel remembers the path it walked. - for (i, (child, kind, name)) in std::iter::once((ino, FileType::Directory, ".".into())) - .chain(std::iter::once((ino, FileType::Directory, "..".into()))) - .chain(entries.iter().map(|e| { - ( - inner.intern(&e.path), - if e.is_dir { - FileType::Directory - } else { - FileType::RegularFile - }, - e.name.clone(), - ) - })) - .enumerate() - .skip(offset as usize) - { - // The offset we hand back is where to RESUME, hence i + 1. - if reply.add(INodeNo(child), i as u64 + 1, kind, &name) { - break; - } - } - reply.ok(); - }); - } - - fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) { - // The kernel enforces `ro` at the mount before reaching us, so this is - // belt-and-braces for a caller that got here another way. - if flags.acc_mode() != OpenAccMode::O_RDONLY { - return reply.error(Errno::EROFS); - } - let ino = ino.0; - detach!(self, |inner| { - let Some(path) = inner.path_of(ino) else { - return reply.error(Errno::ENOENT); - }; - match inner.gated(inner.remote.open(path)).await { - Ok((remote, size)) => { - let fh = inner.next_fh.fetch_add(1, Ordering::Relaxed); - if let Ok(mut g) = inner.files.lock() { - g.insert(fh, OpenFile { remote, size }); - } - // No flags: keeping the page cache is what lets the kernel - // serve a re-read without us, and read ahead of the reader. - reply.opened(fuser::FileHandle(fh), FopenFlags::empty()); - } - Err(c) => reply.error(errno_of(c)), - } - }); - } - - fn read( - &self, - _req: &Request, - _ino: INodeNo, - fh: fuser::FileHandle, - offset: u64, - size: u32, - _flags: OpenFlags, - _lock_owner: Option, - reply: ReplyData, - ) { - let fh = fh.0; - detach!(self, |inner| { - let Some((remote, file_size)) = inner - .files - .lock() - .ok() - .and_then(|g| g.get(&fh).map(|f| (f.remote, f.size))) - else { - return reply.error(Errno::EBADF); - }; - // Past the end is an empty read, not an error — and clamping here - // saves a round trip for the page-sized overshoot the kernel makes - // at the end of every file. - if offset >= file_size { - return reply.data(&[]); - } - let want = (file_size - offset).min(size as u64) as u32; - match inner.read_range(remote, offset, want).await { - Ok(bytes) => reply.data(&bytes), - Err(c) => reply.error(errno_of(c)), - } - }); - } - - fn release( - &self, - _req: &Request, - _ino: INodeNo, - fh: fuser::FileHandle, - _flags: OpenFlags, - _lock_owner: Option, - _flush: bool, - reply: ReplyEmpty, - ) { - let fh = fh.0; - detach!(self, |inner| { - let handle = inner.files.lock().ok().and_then(|mut g| g.remove(&fh)); - // Answer the kernel first: `close()` cannot fail from here and the - // caller should not wait on a phone round trip to return from it. - reply.ok(); - if let Some(f) = handle { - inner.gated(inner.remote.close(f.remote)).await; - } - }); - } - - // ── Ops answered without asking the phone ──────────────────────────── - // - // Left unimplemented, each of these answers `ENOSYS`, which the kernel - // handles but fuser logs as "[Not Implemented]" — a warning per call in - // the app's log, several per file opened. Answering them here costs - // nothing and the answers are all knowable locally. - - /// Nothing to flush: the mount is read-only, and a read has no state on the - /// peer beyond the handle `release` will close. - fn flush( - &self, - _req: &Request, - _ino: INodeNo, - _fh: fuser::FileHandle, - _lock_owner: fuser::LockOwner, - reply: ReplyEmpty, - ) { - reply.ok(); - } - - /// No extended attributes, and no way to have any: the protocol carries a - /// name, a kind, a size and an mtime, and nothing else. `ENODATA` is the - /// answer for "this attribute is not set", which is the truth for every - /// name that could be asked about. - fn getxattr(&self, _req: &Request, _ino: INodeNo, _name: &OsStr, _size: u32, reply: ReplyXattr) { - reply.error(Errno::ENODATA); - } - - /// An empty list, not an error — `cp -a` and Dolphin both ask, and a - /// failure here reads to them as a file they could not fully inspect. - fn listxattr(&self, _req: &Request, _ino: INodeNo, size: u32, reply: ReplyXattr) { - // The two-call protocol: size 0 asks how much room to allocate. - if size == 0 { - reply.size(0); - } else { - reply.data(&[]); - } - } - - fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) { - // Zeroed on purpose. There is no protocol op for free space, and a made - // up figure would be a lie a file manager acts on. Zero free on a - // read-only mount is at least the truth: nothing can be written here. - // Revisit with design doc §8 step 5, which is when a real number starts - // to matter. - reply.statfs(0, 0, 0, 0, 0, 4096, 255, 4096); - } -} - -// --------------------------------------------------------------------------- -// Mount lifecycle -// --------------------------------------------------------------------------- - -/// The live session. Dropping it unmounts, which is what cleans up on a normal -/// app exit. -static SESSION: OnceLock>> = OnceLock::new(); - -fn session_slot() -> &'static Mutex> { - SESSION.get_or_init(|| Mutex::new(None)) -} +#[cfg(target_os = "linux")] +use crate::fs_fuse as backend; +#[cfg(target_os = "windows")] +use crate::fs_projfs as backend; /// Where the phone's files appear. -/// -/// Under `XDG_RUNTIME_DIR` because the session lifetime is exactly right: the -/// directory goes away at logout, so a crashed app cannot leave a stale mount -/// point in the user's home. One fixed name rather than one per phone, because -/// the protocol client sends to whichever peer is *active* — a per-phone mount -/// is not expressible until it takes a peer (design doc §9). pub(crate) fn mount_point() -> PathBuf { - let base = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - base.join("vortex").join("phone") + backend::mount_point() +} + +/// Whether the phone's files are currently mounted. +pub(crate) fn is_mounted() -> bool { + backend::is_mounted() } /// Mount the phone's storage. Returns the mount point. @@ -770,105 +52,27 @@ pub(crate) fn mount_point() -> PathBuf { /// Idempotent: a second call while mounted returns the same path rather than /// tearing the mount down under whoever is using it. pub(crate) async fn mount() -> Result { - if let Ok(g) = session_slot().lock() { - if g.is_some() { - return Ok(mount_point()); - } - } - let dir = mount_point(); - let rt = tokio::runtime::Handle::current(); - // `Session::new` runs `fusermount3` and waits for the kernel's INIT, so it - // does not belong on an async thread. - tokio::task::spawn_blocking(move || mount_blocking(rt, dir)) - .await - .map_err(|e| format!("mount task failed: {e}"))? -} - -fn mount_blocking(rt: tokio::runtime::Handle, dir: PathBuf) -> Result { - let bg = spawn_session(rt, &dir, LinkRemote)?; - if let Ok(mut g) = session_slot().lock() { - *g = Some(bg); + if is_mounted() { + return Ok(mount_point()); } - tracing::info!(path = %dir.display(), "fs-mount: mounted"); - Ok(dir) -} - -/// Mount `remote` at `dir` and start serving it. -/// -/// Generic over the remote so the integration test at the bottom of this file -/// can mount its fake peer for real — kernel, session thread and all — which is -/// the only way to check that what we tell the kernel is what a program reading -/// the mount actually sees. -fn spawn_session( - rt: tokio::runtime::Handle, - dir: &PathBuf, - remote: R, -) -> Result { - // FIRST, before touching the path at all: a previous run that died without - // unmounting (a crash, a SIGKILL, the installer restarting the app) leaves - // the mount in the table with no server behind it, and every syscall on it - // answers ENOTCONN. That includes the `stat` inside `create_dir_all`, which - // therefore fails with EEXIST — the directory is there, it just cannot be - // looked at. Clearing the corpse first is what makes a remount work. - // - // Only ever our own private path under XDG_RUNTIME_DIR, and a no-op when - // nothing is mounted there. - let _ = std::process::Command::new("fusermount3") - .args(["-quz", &dir.to_string_lossy()]) - .stderr(std::process::Stdio::null()) - .status(); - std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - - let fs = PhoneFs { - inner: Arc::new(Inner::new(remote)), - rt, - }; - let mut config = fuser::Config::default(); - config.mount_options = vec![ - // What `mount` and `df` call it. - MountOption::FSName("vortex".into()), - MountOption::Subtype("vortex".into()), - // Read-only until design doc §8 step 5. Enforced by the kernel, so a - // write is refused without a round trip to the phone. - MountOption::RO, - MountOption::NoSuid, - MountOption::NoDev, - MountOption::NoExec, - // Let the KERNEL do permission checks, from the mode bits we report. - // Without it the kernel asks us (`access`) on every path walk, which - // is a round trip through the session thread to answer "yes" — the - // mount is visible to its owner alone and read-only, so there is - // nothing for us to decide that the mode bits do not already say. - MountOption::DefaultPermissions, - ]; - // One session thread is enough: it only parses a request and hands it to - // the runtime, so it is never the thing that is busy. - fuser::Session::new(fs, dir, &config) - .map_err(|e| format!("mounting {} failed: {e}", dir.display()))? - .spawn() - .map_err(|e| format!("session thread failed: {e}")) + backend::mount().await } /// Unmount, if mounted. pub(crate) fn unmount() { - let taken = session_slot().lock().ok().and_then(|mut g| g.take()); - if let Some(bg) = taken { - // `umount_and_join` waits for the session loop to finish, which needs - // the kernel to have released the mount — a blocking call, so keep it - // off the async threads. - std::thread::spawn(move || match bg.umount_and_join() { - Ok(()) => tracing::info!("fs-mount: unmounted"), - Err(e) => tracing::warn!("fs-mount: unmount failed: {e}"), - }); - } + backend::unmount(); +} + +/// Detach the mount on the process's way out, so nothing is left behind that +/// outlives the server serving it. +pub(crate) fn unmount_on_exit() { + backend::unmount_on_exit(); } /// Open the phone's storage in the desktop file manager. /// /// Mounts on demand: the button IS the request, so asking the user to mount /// first would be a step that exists only because the code is in two pieces. -/// Idempotent, because [`mount`] is — clicking twice reveals the same window -/// rather than remounting under it. /// /// Returns the path so the UI can name it in a tooltip; the interesting half of /// the result is the error, which is what a phone that is not reachable looks @@ -877,367 +81,20 @@ pub(crate) fn unmount() { pub async fn open_phone_files() -> Result { let dir = mount().await?; let path = dir.to_string_lossy().to_string(); + // `explorer.exe` on Windows, `xdg-open` on Linux — both take a directory + // and bring up the platform's file manager on it. + // // `tokio::process`, not `std::process`: a `std` `Child` dropped without // `wait()` stays a zombie for the parent's whole life, and this app runs // for days. Same reason `handoff::open_url` does it this way. - tokio::process::Command::new("xdg-open") + #[cfg(target_os = "windows")] + let opener = "explorer.exe"; + #[cfg(not(target_os = "windows"))] + let opener = "xdg-open"; + tokio::process::Command::new(opener) .arg(&path) .spawn() .map_err(|e| format!("cannot open the file manager: {e}"))?; tracing::info!(%path, "fs-mount: opened in the file manager"); Ok(path) } - -/// Detach the mount on the process's way out. -/// -/// A FUSE mount whose server process has died is not gone — it stays in the -/// mount table answering `ENOTCONN`, which makes `df` error and leaves a broken -/// entry in every file manager. So the deliberate-quit path detaches it first. -/// -/// `fusermount3 -z` rather than [`unmount`] because this runs microseconds -/// before `exit()`: the lazy form returns immediately and lets the kernel -/// finish when the last user of the mount goes away, where waiting for the -/// session thread to join would simply be killed half-way. -pub(crate) fn unmount_on_exit() { - if !is_mounted() { - return; - } - let dir = mount_point(); - let _ = std::process::Command::new("fusermount3") - .args(["-quz", &dir.to_string_lossy()]) - .stderr(std::process::Stdio::null()) - .status(); -} - -/// Whether the phone's files are currently mounted. -pub(crate) fn is_mounted() -> bool { - session_slot().lock().map(|g| g.is_some()).unwrap_or(false) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - /// A peer with a fixed tree, so the filesystem logic can be tested without - /// a phone, a kernel or a link. Counts requests: most of what this module - /// does is avoid making them. - struct FakePeer { - /// path → entries, for directories. - dirs: HashMap>, - /// path → contents, for files. - files: HashMap>, - lists: std::sync::atomic::AtomicU32, - stats: std::sync::atomic::AtomicU32, - reads: std::sync::atomic::AtomicU32, - } - - fn dir(name: &str, path: &str) -> p::FsEntry { - p::FsEntry { - name: name.into(), - path: path.into(), - is_dir: true, - size: 0, - mtime: 1_700_000_000, - readonly: true, - } - } - - fn file(name: &str, path: &str, size: u64) -> p::FsEntry { - p::FsEntry { - name: name.into(), - path: path.into(), - is_dir: false, - size, - mtime: 1_700_000_000, - readonly: true, - } - } - - impl FakePeer { - fn new() -> Self { - // 100 KiB, so a read of it spans three protocol reads. - let big: Vec = (0..100 * 1024).map(|i| (i % 251) as u8).collect(); - let mut dirs = HashMap::new(); - dirs.insert( - String::new(), - vec![dir("DCIM", "/sdcard/DCIM"), file("a.txt", "/sdcard/a.txt", 5)], - ); - dirs.insert( - "/sdcard/DCIM".to_string(), - vec![file("big.bin", "/sdcard/DCIM/big.bin", big.len() as u64)], - ); - let mut files = HashMap::new(); - files.insert("/sdcard/a.txt".to_string(), b"hello".to_vec()); - files.insert("/sdcard/DCIM/big.bin".to_string(), big); - Self { - dirs, - files, - lists: Default::default(), - stats: Default::default(), - reads: Default::default(), - } - } - } - - impl FsRemote for Arc { - fn list( - &self, - path: String, - cursor: u32, - ) -> impl Future, Option), i32>> + Send { - let me = self.clone(); - async move { - me.lists.fetch_add(1, Ordering::Relaxed); - let all = me.dirs.get(&path).ok_or(code::NOENT)?; - // One entry per page, so pagination is exercised rather than - // assumed. - let at = cursor as usize; - match all.get(at) { - Some(e) => Ok(( - vec![e.clone()], - (at + 1 < all.len()).then_some(cursor + 1), - )), - None => Ok((vec![], None)), - } - } - } - - fn stat(&self, path: String) -> impl Future> + Send { - let me = self.clone(); - async move { - me.stats.fetch_add(1, Ordering::Relaxed); - me.dirs - .values() - .flatten() - .find(|e| e.path == path) - .cloned() - .ok_or(code::NOENT) - } - } - - fn open(&self, path: String) -> impl Future> + Send { - let me = self.clone(); - async move { - let bytes = me.files.get(&path).ok_or(code::NOENT)?; - // The handle IS the path's index; enough to read it back. - let idx = me.files.keys().position(|k| k == &path).unwrap() as u64; - Ok((idx + 1, bytes.len() as u64)) - } - } - - fn read( - &self, - handle: u64, - offset: u64, - len: u32, - ) -> impl Future, bool), i32>> + Send { - let me = self.clone(); - async move { - me.reads.fetch_add(1, Ordering::Relaxed); - let key = me - .files - .keys() - .nth(handle as usize - 1) - .cloned() - .ok_or(code::BADF)?; - let bytes = &me.files[&key]; - let at = (offset as usize).min(bytes.len()); - let to = (at + len as usize).min(bytes.len()); - Ok((bytes[at..to].to_vec(), to >= bytes.len())) - } - } - - async fn close(&self, _handle: u64) {} - } - - fn fs() -> (Arc>>, Arc) { - let peer = Arc::new(FakePeer::new()); - (Arc::new(Inner::new(peer.clone())), peer) - } - - #[tokio::test] - async fn listing_follows_pagination_to_the_end() { - let (fs, peer) = fs(); - let entries = fs.listing(ROOT_INO).await.unwrap(); - assert_eq!( - entries.iter().map(|e| e.name.as_str()).collect::>(), - ["DCIM", "a.txt"] - ); - // Two pages plus the one that reports the end. - assert_eq!(peer.lists.load(Ordering::Relaxed), 2); - } - - #[tokio::test] - async fn a_listing_answers_the_lookups_that_follow_it() { - let (fs, peer) = fs(); - fs.listing(ROOT_INO).await.unwrap(); - let before = peer.lists.load(Ordering::Relaxed); - let (ino, e) = fs.lookup_child(ROOT_INO, "a.txt").await.unwrap(); - assert_eq!(e.path, "/sdcard/a.txt"); - // The point of the exercise: no further round trips, of any kind. - assert_eq!(peer.lists.load(Ordering::Relaxed), before); - assert_eq!(peer.stats.load(Ordering::Relaxed), 0); - assert_eq!(fs.entry_of(ino).await.unwrap().name, "a.txt"); - assert_eq!(peer.stats.load(Ordering::Relaxed), 0); - } - - #[tokio::test] - async fn an_inode_is_stable_and_never_reused() { - let (fs, _) = fs(); - let (first, _) = fs.lookup_child(ROOT_INO, "a.txt").await.unwrap(); - let (again, _) = fs.lookup_child(ROOT_INO, "a.txt").await.unwrap(); - assert_eq!(first, again, "the same file must keep its inode number"); - let (other, _) = fs.lookup_child(ROOT_INO, "DCIM").await.unwrap(); - assert_ne!(first, other); - assert_ne!(first, ROOT_INO); - } - - #[tokio::test] - async fn a_missing_name_is_noent_not_a_hang() { - let (fs, _) = fs(); - assert_eq!( - fs.lookup_child(ROOT_INO, "nope").await.unwrap_err(), - code::NOENT - ); - } - - #[tokio::test] - async fn the_root_stats_without_asking_the_peer() { - let (fs, peer) = fs(); - let e = fs.entry_of(ROOT_INO).await.unwrap(); - assert!(e.is_dir); - assert_eq!(peer.stats.load(Ordering::Relaxed), 0); - } - - #[tokio::test] - async fn a_read_larger_than_the_protocol_limit_is_split_and_reassembled() { - let (fs, peer) = fs(); - let (_, e) = fs.lookup_child(ROOT_INO, "DCIM").await.unwrap(); - let sub = fs.intern(&e.path); - let (_, big) = fs.lookup_child(sub, "big.bin").await.unwrap(); - let (handle, size) = fs.remote.open(big.path.clone()).await.unwrap(); - assert_eq!(size, 100 * 1024); - - // One kernel-sized read: 128 KiB clamped to the file, three protocol - // reads of 48/48/4 KiB. - let bytes = fs.read_range(handle, 0, size as u32).await.unwrap(); - assert_eq!(bytes.len(), size as usize); - assert_eq!(peer.reads.load(Ordering::Relaxed), 3); - let expected: Vec = (0..100 * 1024).map(|i| (i % 251) as u8).collect(); - assert_eq!(bytes, expected, "reassembled in the wrong order"); - } - - #[tokio::test] - async fn a_read_at_an_offset_starts_there() { - let (fs, _) = fs(); - let (handle, size) = fs - .remote - .open("/sdcard/DCIM/big.bin".to_string()) - .await - .unwrap(); - let at = 70 * 1024; - let bytes = fs.read_range(handle, at, (size - at) as u32).await.unwrap(); - let expected: Vec = (at..size).map(|i| (i % 251) as u8).collect(); - assert_eq!(bytes, expected); - } - - #[tokio::test] - async fn reading_past_the_end_yields_nothing() { - let (fs, _) = fs(); - let (handle, size) = fs.remote.open("/sdcard/a.txt".to_string()).await.unwrap(); - assert!(fs.read_range(handle, size, 4096).await.unwrap().is_empty()); - } - - #[test] - fn a_directory_and_a_file_translate_to_the_right_stat() { - let d = attr_of(7, &dir("DCIM", "/sdcard/DCIM"), 1000, 1000); - assert_eq!(d.kind, FileType::Directory); - assert_eq!(d.perm, 0o555); - assert_eq!(d.ino, INodeNo(7)); - let f = attr_of(8, &file("a.txt", "/sdcard/a.txt", 5), 1000, 1000); - assert_eq!(f.kind, FileType::RegularFile); - assert_eq!(f.perm, 0o444, "the mount is read-only"); - assert_eq!(f.size, 5); - assert_eq!(f.blocks, 1); - assert_eq!( - f.mtime, - SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) - ); - } - - #[test] - fn an_unknown_mtime_is_the_epoch_not_a_panic() { - assert_eq!(mtime_of(0), SystemTime::UNIX_EPOCH); - // A phone with a clock set before 1970 must not take the mount down. - assert!(mtime_of(-86_400) < SystemTime::UNIX_EPOCH); - } - - #[test] - fn every_protocol_code_reaches_the_user_as_itself() { - assert_eq!(errno_of(code::NOENT), Errno::ENOENT); - assert_eq!(errno_of(code::ACCES), Errno::EACCES); - assert_eq!(errno_of(code::ROFS), Errno::EROFS); - assert_eq!(errno_of(code::ISDIR), Errno::EISDIR); - assert_eq!(errno_of(crate::fs_link::NO_LINK), Errno::EHOSTDOWN); - assert_eq!(errno_of(code::IO), Errno::EIO); - assert_eq!(errno_of(9999), Errno::EIO, "an unknown code is still an error"); - } - - /// The whole thing, for real: a kernel mount over the fake peer, driven by - /// ordinary `std::fs` calls. - /// - /// `#[ignore]` because it needs `/dev/fuse` and `fusermount3`, which a - /// container or a build box may not have, and a test that cannot run there - /// should not look like a failure. Run it with: - /// - /// ```text - /// cargo test --lib fs_mount -- --ignored --nocapture - /// ``` - /// - /// Multi-threaded on purpose: the syscalls block a thread while the FUSE - /// operations they trigger are answered on another. - #[tokio::test(flavor = "multi_thread")] - #[ignore = "needs /dev/fuse and fusermount3"] - async fn a_real_mount_answers_ordinary_file_calls() { - let peer = Arc::new(FakePeer::new()); - let dir = std::env::temp_dir().join(format!("vortex-fuse-{}", std::process::id())); - let session = spawn_session(tokio::runtime::Handle::current(), &dir, peer.clone()) - .expect("mount failed — is /dev/fuse available?"); - - let at = dir.clone(); - let seen = tokio::task::spawn_blocking(move || { - let mut names: Vec = std::fs::read_dir(&at) - .expect("read_dir") - .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) - .collect(); - names.sort(); - let small = std::fs::read_to_string(at.join("a.txt")).expect("read a.txt"); - let big = std::fs::read(at.join("DCIM").join("big.bin")).expect("read big.bin"); - let meta = std::fs::metadata(at.join("DCIM")).expect("stat DCIM"); - // Writes must be refused by the kernel, without reaching the peer. - let write = std::fs::write(at.join("nope.txt"), b"x"); - (names, small, big, meta.is_dir(), write.is_err()) - }) - .await - .unwrap(); - - let (names, small, big, dcim_is_dir, write_refused) = seen; - assert_eq!(names, ["DCIM", "a.txt"]); - assert_eq!(small, "hello"); - assert!(dcim_is_dir); - assert!(write_refused, "the mount must be read-only"); - let expected: Vec = (0..100 * 1024).map(|i| (i % 251) as u8).collect(); - assert_eq!(big, expected, "100 KiB came back wrong through the kernel"); - - tokio::task::spawn_blocking(move || { - let _ = session.umount_and_join(); - let _ = std::fs::remove_dir(&dir); - }) - .await - .unwrap(); - } -} diff --git a/linux/ui-tauri/src-tauri/src/fs_projfs.rs b/linux/ui-tauri/src-tauri/src/fs_projfs.rs new file mode 100644 index 0000000..7c3f7c0 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/fs_projfs.rs @@ -0,0 +1,871 @@ +//! The Windows mount adapter: the phone's storage projected with ProjFS. +//! +//! Everything between this and the wire — caching, the path walk, pipelined +//! reads — is [`crate::fs_vfs`]; this file is only the translation between that +//! and the Projected File System. Its Linux counterpart is +//! [`crate::fs_fuse`]. +//! +//! # Why ProjFS and not the WebDAV gateway +//! +//! WebDAV was sequenced first because one gateway serves both OSes, but on +//! Windows the redirector caps a file at ~50 MB (`FileSizeLimitInBytes`), needs +//! the WebClient service running, and wants the unfamiliar `\\host@port\` form. +//! Escaping a 64 MB cap into a 50 MB one would be absurd. ProjFS ships in +//! Windows 10 1809+ with **no third-party install** — it is what VFS for Git +//! uses — and gives a real directory with no size ceiling and proper seeking. +//! +//! It is an optional Windows *feature*, though, off by default on client SKUs. +//! [`start`] says so plainly when it is missing rather than failing obscurely. +//! +//! # How this differs from the FUSE adapter, and why +//! +//! The Linux side never blocks: FUSE hands it one request at a time on one +//! thread, so answering inline would serialise the whole mount behind one round +//! trip at a time. ProjFS is the opposite — it runs **its own thread pool** and +//! is designed for providers that block a pool thread while fetching. So the +//! callbacks here are straightforwardly synchronous: they block on the async +//! runtime and return an answer. +//! +//! That is safe only because the pool is sized above [`fs_vfs::MAX_INFLIGHT`]: +//! the semaphore in the shared layer runs out before ProjFS's threads do, so +//! the cap on concurrency is ours and a thundering thumbnailer cannot exhaust +//! the pool and wedge Explorer. If that ever stops holding, the escape hatch is +//! ProjFS's own: return `HRESULT_FROM_WIN32(ERROR_IO_PENDING)` and finish later +//! with `PrjCompleteCommand`. It costs an owned copy of everything in the +//! callback data, which is why it is not the starting point. +//! +//! # Hydration, and what that means for staleness +//! +//! Unlike FUSE, ProjFS writes fetched content into the real directory and +//! serves later reads from it without asking us. That is design doc §7's +//! content cache, for free — and it is also a correctness problem, because a +//! file that changes on the phone is not re-fetched. [`start`] therefore clears +//! the projection each time it mounts, so a session begins from the phone's +//! current truth. Within one session a file changed on the phone still shows +//! its old content; fixing that properly means giving placeholders a ContentID +//! derived from size and mtime and driving `PrjUpdateFileIfNeeded`, which is +//! the natural companion to the daemon cache layer (§8 step 3). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use windows::core::{GUID, HRESULT, PCWSTR}; +use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_HOST_DOWN, ERROR_INSUFFICIENT_BUFFER, + ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_IO_DEVICE, ERROR_NOT_SUPPORTED, + ERROR_WRITE_PROTECT, +}; +use windows::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY}; +use windows::Win32::Storage::ProjectedFileSystem::*; + +use vortex_l3_daemon::core::fs_proto::{self as p, code}; + +use crate::fs_vfs::{FsRemote, LinkRemote, Vfs, MAX_INFLIGHT}; + +/// `S_OK`. Spelled out rather than imported so every callback's success path +/// reads the same as its failure paths. +const OK: HRESULT = HRESULT(0); + +/// This provider's virtualization instance id. +/// +/// Fixed rather than freshly generated: it identifies *Vortex's* projection of +/// a directory, and the directory outlives a run. A new id each start would +/// make Windows treat the same folder as a different projection every time. +const INSTANCE_ID: GUID = GUID::from_u128(0x7b1f9a42_5d33_4c86_9e10_2f6a4c8d1b57); + +/// Bytes fetched from the phone per `PrjWriteFileData` call. +/// +/// ProjFS may ask for a whole file in one callback, and a 3.4 GB request must +/// not become a 3.4 GB allocation — the flat-memory property is the entire +/// point of the ranged protocol. Rounded up to the volume's write alignment at +/// use, since every chunk but the file's last must be a multiple of it. +const HYDRATE_CHUNK: u32 = 1024 * 1024; + +/// ProjFS threads. Above [`MAX_INFLIGHT`] on purpose — see the module docs: +/// the shared semaphore is meant to be what limits concurrency, not the pool. +const POOL_THREADS: u32 = MAX_INFLIGHT as u32 * 2; + +// --------------------------------------------------------------------------- +// Translation +// --------------------------------------------------------------------------- + +/// A protocol code as an `HRESULT`. +/// +/// The reason [`code`] is errno-shaped is that both mount adapters have to turn +/// it back into an OS error; this is that, for the other OS. What Explorer +/// shows the user comes straight from here, so [`crate::fs_link::NO_LINK`] +/// becoming `ERROR_HOST_DOWN` rather than a generic device error is the +/// difference between an accurate message and a puzzling one. +fn hresult_of(c: i32) -> HRESULT { + let win = match c { + code::NOENT => ERROR_FILE_NOT_FOUND, + code::ACCES => ERROR_ACCESS_DENIED, + code::BADF => ERROR_INVALID_HANDLE, + code::INVAL => ERROR_INVALID_PARAMETER, + code::NOTSUP => ERROR_NOT_SUPPORTED, + // No Win32 equivalent of EISDIR in this position; a caller that opened + // a directory as a file gets the same refusal it would from NTFS. + code::ISDIR => ERROR_ACCESS_DENIED, + code::ROFS => ERROR_WRITE_PROTECT, + crate::fs_link::NO_LINK => ERROR_HOST_DOWN, + // Includes `code::IO`, and anything a future peer invents. + _ => ERROR_IO_DEVICE, + }; + HRESULT::from_win32(win.0) +} + +/// Seconds since the Unix epoch as a Windows `FILETIME` tick count. +/// +/// FILETIME counts 100 ns intervals from 1601-01-01, which is +/// [`EPOCH_DELTA`](self) seconds before the Unix epoch. Saturating, because the +/// protocol's 0 ("the peer cannot tell") and the negative value a badly-set +/// phone clock produces must not overflow a mount into a panic. +fn filetime_of(unix_secs: i64) -> i64 { + /// Seconds between 1601-01-01 and 1970-01-01. + const EPOCH_DELTA: i64 = 11_644_473_600; + unix_secs + .saturating_add(EPOCH_DELTA) + .saturating_mul(10_000_000) + .max(0) +} + +/// A protocol entry as the metadata ProjFS stores in a placeholder. +/// +/// `FILE_ATTRIBUTE_READONLY` on everything: the projection is read-only until +/// design doc §8 step 5, and an attribute that says so lets Explorer grey out +/// the operations rather than offer them and fail. It is advisory, which is why +/// [`notification`] refuses the operations outright as well. +fn basic_info(e: &p::FsEntry) -> PRJ_FILE_BASIC_INFO { + let t = filetime_of(e.mtime); + let attrs = if e.is_dir { + FILE_ATTRIBUTE_DIRECTORY.0 | FILE_ATTRIBUTE_READONLY.0 + } else { + FILE_ATTRIBUTE_READONLY.0 + }; + PRJ_FILE_BASIC_INFO { + IsDirectory: e.is_dir, + FileSize: e.size as i64, + // The protocol carries one timestamp. Reporting it as all four is + // better than reporting three zeroes, which Explorer renders as 1601. + CreationTime: t, + LastAccessTime: t, + LastWriteTime: t, + ChangeTime: t, + FileAttributes: attrs, + } +} + +/// A NUL-terminated UTF-16 buffer, for passing a Rust string to Win32. +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Read a `PCWSTR` the OS handed us. Null and invalid both become empty, which +/// every caller here treats as "the root" or "no pattern" — the right reading +/// in both cases. +/// +/// # Safety +/// `s` must be null or point at a NUL-terminated UTF-16 string. +unsafe fn from_wide(s: PCWSTR) -> String { + if s.is_null() { + return String::new(); + } + unsafe { s.to_string() }.unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Provider state +// --------------------------------------------------------------------------- + +/// One directory enumeration in progress. +/// +/// A snapshot, deliberately: a directory listed while the phone is adding files +/// must not grow under the caller mid-enumeration, and ProjFS's restart flag +/// rewinds *this* list rather than re-reading the folder. +struct EnumSession { + entries: Vec, + /// Index of the next entry to hand back. + next: usize, + /// The search expression, captured on the first call and on every restart. + /// + /// ProjFS passes it once and may pass null afterwards, so a provider that + /// does not save it will filter the first page and nothing after it. + pattern: Option, +} + +struct Provider { + vfs: Arc>, + rt: tokio::runtime::Handle, + enums: Mutex>, + /// The volume's required alignment for `PrjWriteFileData`, learned once the + /// instance is up. 1 until then, which is a no-op rounding. + write_alignment: AtomicU32, +} + +impl Provider { + /// Run one async operation to completion from a ProjFS pool thread. + /// + /// Blocking is correct here — see the module docs. `Handle::block_on` + /// rather than a runtime of our own so the work lands on the same executor + /// (and the same link) as everything else, and it cannot panic for being + /// inside an async context because ProjFS's threads are not tokio's. + fn block(&self, f: impl std::future::Future) -> T { + self.rt.block_on(f) + } + + /// Resolve a ProjFS-relative path to the peer's opaque address. + fn resolve(&self, rel: &str) -> Result { + self.block(self.vfs.resolve(rel)) + } +} + +/// The running instance. `PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT` is a raw +/// pointer, so it is not `Send` on its own; it is only ever touched under this +/// mutex and only handed back to ProjFS. +struct Instance { + ctx: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, + /// The leaked provider handed to ProjFS as the instance context. Reclaimed + /// after `PrjStopVirtualizing` returns, which is when no callback can still + /// be looking at it. + provider: *const Provider, + root: PathBuf, +} + +// SAFETY: both fields are only dereferenced by ProjFS callbacks (on ProjFS's +// own threads, which is what the pointers are for) and by [`stop`], which holds +// the mutex and runs after virtualization has been told to stop. +unsafe impl Send for Instance {} + +static INSTANCE: OnceLock>> = OnceLock::new(); + +fn instance_slot() -> &'static Mutex> { + INSTANCE.get_or_init(|| Mutex::new(None)) +} + +/// The provider behind a callback. +/// +/// # Safety +/// `cb` must be a callback-data pointer ProjFS handed us, whose +/// `InstanceContext` is the provider leaked by [`start`]. +unsafe fn provider<'a>(cb: *const PRJ_CALLBACK_DATA) -> Option<(&'a Provider, &'a PRJ_CALLBACK_DATA)> { + let data = unsafe { cb.as_ref() }?; + let p = unsafe { (data.InstanceContext as *const Provider).as_ref() }?; + Some((p, data)) +} + +// --------------------------------------------------------------------------- +// Callbacks +// --------------------------------------------------------------------------- + +/// Begin enumerating a directory. +/// +/// The listing is fetched here rather than on the first `get_enumeration` so +/// that a failure — an unreachable phone, a folder that has gone — surfaces at +/// the point Windows can still report it cleanly, and so the sort happens once +/// per enumeration rather than once per page. +unsafe extern "system" fn start_enumeration( + cb: *const PRJ_CALLBACK_DATA, + enum_id: *const GUID, +) -> HRESULT { + let (Some((prov, data)), Some(id)) = (unsafe { provider(cb) }, unsafe { enum_id.as_ref() }) + else { + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + let rel = unsafe { from_wide(data.FilePathName) }; + let entry = match prov.resolve(&rel) { + Ok(e) => e, + Err(c) => return hresult_of(c), + }; + if !entry.is_dir { + return HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0); + } + let mut entries = match prov.block(prov.vfs.listing(&entry.path)) { + Ok(v) => (*v).clone(), + Err(c) => return hresult_of(c), + }; + // ProjFS requires entries in ITS collation order, not ours: it merges our + // list with what is already on disk, and a differently-ordered list makes + // that merge drop or duplicate entries. + entries.sort_by(|a, b| compare_names(&a.name, &b.name)); + match prov.enums.lock() { + Ok(mut g) => { + g.insert( + id.to_u128(), + EnumSession { + entries, + next: 0, + pattern: None, + }, + ); + OK + } + Err(_) => HRESULT::from_win32(ERROR_IO_DEVICE.0), + } +} + +/// ProjFS's own filename collation, which is not Rust's `Ord`. +fn compare_names(a: &str, b: &str) -> std::cmp::Ordering { + let (a, b) = (wide(a), wide(b)); + let r = unsafe { PrjFileNameCompare(PCWSTR(a.as_ptr()), PCWSTR(b.as_ptr())) }; + r.cmp(&0) +} + +/// Hand back the next page of a directory. +unsafe extern "system" fn get_enumeration( + cb: *const PRJ_CALLBACK_DATA, + enum_id: *const GUID, + search_expression: PCWSTR, + buffer: PRJ_DIR_ENTRY_BUFFER_HANDLE, +) -> HRESULT { + let (Some((prov, data)), Some(id)) = (unsafe { provider(cb) }, unsafe { enum_id.as_ref() }) + else { + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + let Ok(mut sessions) = prov.enums.lock() else { + return HRESULT::from_win32(ERROR_IO_DEVICE.0); + }; + let Some(session) = sessions.get_mut(&id.to_u128()) else { + // No session: ProjFS asked about an enumeration we never started. + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + + // The search expression arrives on the FIRST call and on every restart, and + // may be null on the calls between. Saving it is the provider's job — a + // provider that re-reads it each time filters page one and nothing after. + let restart = data.Flags.0 & PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN.0 != 0; + if restart { + session.next = 0; + session.pattern = Some(unsafe { from_wide(search_expression) }); + } else if session.pattern.is_none() { + session.pattern = Some(unsafe { from_wide(search_expression) }); + } + // An empty expression means "everything"; so does `*`, but ProjFS sends the + // empty one and calling `PrjFileNameMatch` with it would reject every name. + let pattern = session + .pattern + .as_deref() + .filter(|s| !s.is_empty() && *s != "*") + .map(wide); + + while session.next < session.entries.len() { + let e = &session.entries[session.next]; + let name = wide(&e.name); + if let Some(pat) = &pattern { + if !unsafe { PrjFileNameMatch(PCWSTR(name.as_ptr()), PCWSTR(pat.as_ptr())) } { + session.next += 1; + continue; + } + } + let info = basic_info(e); + if let Err(err) = + unsafe { PrjFillDirEntryBuffer(PCWSTR(name.as_ptr()), Some(&info), buffer) } + { + // The buffer is full. Stop WITHOUT consuming this entry — ProjFS + // will call again and it must be the first one next time. + if err.code() == HRESULT::from_win32(ERROR_INSUFFICIENT_BUFFER.0) { + return OK; + } + tracing::warn!("fs-projfs: filling a directory entry failed: {err}"); + return err.code(); + } + session.next += 1; + } + // Ran out of entries: an empty (or short) reply is how the end is reported. + OK +} + +unsafe extern "system" fn end_enumeration( + cb: *const PRJ_CALLBACK_DATA, + enum_id: *const GUID, +) -> HRESULT { + let (Some((prov, _)), Some(id)) = (unsafe { provider(cb) }, unsafe { enum_id.as_ref() }) else { + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + if let Ok(mut g) = prov.enums.lock() { + g.remove(&id.to_u128()); + } + OK +} + +/// A `stat`: give ProjFS the metadata for one path so it can create a +/// placeholder for it. +unsafe extern "system" fn get_placeholder_info(cb: *const PRJ_CALLBACK_DATA) -> HRESULT { + let Some((prov, data)) = (unsafe { provider(cb) }) else { + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + let rel = unsafe { from_wide(data.FilePathName) }; + let entry = match prov.resolve(&rel) { + Ok(e) => e, + Err(c) => return hresult_of(c), + }; + let info = PRJ_PLACEHOLDER_INFO { + FileBasicInfo: basic_info(&entry), + ..Default::default() + }; + let name = wide(&rel); + match unsafe { + PrjWritePlaceholderInfo( + data.NamespaceVirtualizationContext, + PCWSTR(name.as_ptr()), + &info, + std::mem::size_of::() as u32, + ) + } { + Ok(()) => OK, + Err(e) => { + tracing::warn!("fs-projfs: writing a placeholder failed: {e}"); + e.code() + } + } +} + +/// Hydrate: fetch a range of a file and hand it to ProjFS. +/// +/// Chunked, because ProjFS may ask for an entire file in one callback and a +/// 3.4 GB request must not become a 3.4 GB allocation. Every chunk but the +/// file's last is a whole multiple of the volume's write alignment, which is +/// what `PrjWriteFileData` requires of a multi-part write. +unsafe extern "system" fn get_file_data( + cb: *const PRJ_CALLBACK_DATA, + byte_offset: u64, + length: u32, +) -> HRESULT { + let Some((prov, data)) = (unsafe { provider(cb) }) else { + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + let rel = unsafe { from_wide(data.FilePathName) }; + let entry = match prov.resolve(&rel) { + Ok(e) => e, + Err(c) => return hresult_of(c), + }; + let fh = match prov.block(prov.vfs.open(&entry.path)) { + Ok(fh) => fh, + Err(c) => return hresult_of(c), + }; + let r = hydrate(prov, data, fh, byte_offset, length); + prov.block(prov.vfs.close(fh)); + r +} + +fn hydrate( + prov: &Provider, + data: &PRJ_CALLBACK_DATA, + fh: u64, + byte_offset: u64, + length: u32, +) -> HRESULT { + let ctx = data.NamespaceVirtualizationContext; + let align = prov.write_alignment.load(Ordering::Relaxed).max(1); + // Round the chunk UP to the alignment: a chunk that is a whole number of + // alignment units keeps every write but the last one legal. + let chunk = HYDRATE_CHUNK.div_ceil(align).saturating_mul(align).max(align); + + let Some(buf) = AlignedBuffer::new(ctx, chunk as usize) else { + return HRESULT::from_win32(ERROR_IO_DEVICE.0); + }; + let end = byte_offset.saturating_add(length as u64); + let mut at = byte_offset; + while at < end { + let want = (end - at).min(chunk as u64) as u32; + let bytes = match prov.block(prov.vfs.read_range(fh, at, want)) { + Ok(b) => b, + Err(c) => return hresult_of(c), + }; + if bytes.len() != want as usize { + // ProjFS asked for a definite range and will treat anything less as + // a corrupt hydration. A file that shrank on the phone mid-read is + // the honest cause; an error is the honest answer. + tracing::warn!( + at, + want, + got = bytes.len(), + "fs-projfs: short read while hydrating" + ); + return HRESULT::from_win32(ERROR_IO_DEVICE.0); + } + unsafe { buf.fill(&bytes) }; + if let Err(e) = + unsafe { PrjWriteFileData(ctx, &data.DataStreamId, buf.ptr, at, bytes.len() as u32) } + { + tracing::warn!("fs-projfs: writing file data failed: {e}"); + return e.code(); + } + at += bytes.len() as u64; + } + OK +} + +/// A buffer from `PrjAllocateAlignedBuffer`, freed on drop. +/// +/// `PrjWriteFileData` requires its buffer to meet the volume's alignment, which +/// an ordinary `Vec` does not guarantee. Owning it in a guard is what makes the +/// early returns in [`hydrate`] leak-free. +struct AlignedBuffer { + ptr: *mut core::ffi::c_void, + len: usize, +} + +impl AlignedBuffer { + fn new(ctx: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, len: usize) -> Option { + let ptr = unsafe { PrjAllocateAlignedBuffer(ctx, len) }; + (!ptr.is_null()).then_some(Self { ptr, len }) + } + + /// # Safety + /// `src` must be no longer than the buffer. + unsafe fn fill(&self, src: &[u8]) { + debug_assert!(src.len() <= self.len); + unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), self.ptr as *mut u8, src.len()) }; + } +} + +impl Drop for AlignedBuffer { + fn drop(&mut self) { + unsafe { PrjFreeAlignedBuffer(self.ptr) }; + } +} + +/// Does this name exist? Asked before ProjFS commits to creating a placeholder. +/// +/// Answering it is what keeps a miss cheap: without it, every probe for a file +/// that is not there costs a placeholder attempt. +unsafe extern "system" fn query_file_name(cb: *const PRJ_CALLBACK_DATA) -> HRESULT { + let Some((prov, data)) = (unsafe { provider(cb) }) else { + return HRESULT::from_win32(ERROR_INVALID_PARAMETER.0); + }; + let rel = unsafe { from_wide(data.FilePathName) }; + match prov.resolve(&rel) { + Ok(_) => OK, + Err(code::NOENT) => HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0), + Err(c) => hresult_of(c), + } +} + +/// Refuse every operation that would change the projection. +/// +/// ProjFS has no mount-level read-only flag the way FUSE does, so this is what +/// makes the projection read-only in fact rather than by convention: returning +/// a failure from a `PRE_` notification is how a provider vetoes the operation +/// that triggered it. The `FILE_ATTRIBUTE_READONLY` on every placeholder is the +/// advisory half — it makes Explorer grey the commands out rather than offer +/// them and fail here. +unsafe extern "system" fn notification( + _cb: *const PRJ_CALLBACK_DATA, + _is_directory: bool, + notification: PRJ_NOTIFICATION, + _destination: PCWSTR, + _params: *mut PRJ_NOTIFICATION_PARAMETERS, +) -> HRESULT { + match notification { + PRJ_NOTIFICATION_PRE_DELETE + | PRJ_NOTIFICATION_PRE_RENAME + | PRJ_NOTIFICATION_PRE_SET_HARDLINK + // "Convert to full" is the moment a placeholder would become a real, + // writable file. Refusing it is what stops an edit in place. + | PRJ_NOTIFICATION_FILE_PRE_CONVERT_TO_FULL => { + HRESULT::from_win32(ERROR_ACCESS_DENIED.0) + } + _ => OK, + } +} + +// --------------------------------------------------------------------------- +// Mount lifecycle +// --------------------------------------------------------------------------- + +/// Where the phone's files appear. +/// +/// Under `%LOCALAPPDATA%` because ProjFS hydrates content into the real +/// directory: it has to be a local NTFS path with room on it, which rules out +/// the roaming profile and a network home directory both. +pub(crate) fn mount_point() -> PathBuf { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join("Vortex").join("phone") +} + +pub(crate) fn is_mounted() -> bool { + instance_slot().lock().map(|g| g.is_some()).unwrap_or(false) +} + +/// Start projecting the phone's storage. Returns the projection root. +pub(crate) async fn mount() -> Result { + let dir = mount_point(); + let rt = tokio::runtime::Handle::current(); + // `PrjStartVirtualizing` sets up a kernel filter and a thread pool; it does + // not belong on an async thread. + tokio::task::spawn_blocking(move || start(rt, dir)) + .await + .map_err(|e| format!("mount task failed: {e}"))? +} + +fn start(rt: tokio::runtime::Handle, dir: PathBuf) -> Result { + // Clear the projection before marking it. ProjFS serves hydrated content + // from disk without asking us, so anything left by a previous session would + // be served as current — see the module docs. Contents only, and only ever + // our own directory under LOCALAPPDATA. + if dir.exists() { + for entry in std::fs::read_dir(&dir) + .map_err(|e| format!("cannot read {}: {e}", dir.display()))? + .flatten() + { + let path = entry.path(); + let _ = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + } + } + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + + let root = wide(&dir.to_string_lossy()); + // Marking is once per directory, not once per run: a directory that is + // already our virtualization root comes back as an error we expect and + // ignore, because the previous run left it correctly marked. + if let Err(e) = unsafe { + PrjMarkDirectoryAsPlaceholder(PCWSTR(root.as_ptr()), PCWSTR::null(), None, &INSTANCE_ID) + } { + tracing::debug!("fs-projfs: the root was already a placeholder ({e})"); + } + + let provider = Box::into_raw(Box::new(Provider { + vfs: Arc::new(Vfs::new(LinkRemote)), + rt, + enums: Mutex::new(HashMap::new()), + // 64 KiB until the real figure arrives, NOT 1: ProjFS may call back + // the instant virtualization starts, which is before the + // `PrjGetVirtualizationInstanceInfo` below has returned. Rounding + // HYDRATE_CHUNK up to 64 KiB yields a chunk that is also a whole + // multiple of 512 and 4096, so a hydration landing in that window is + // legal whatever the volume turns out to want. + write_alignment: AtomicU32::new(64 * 1024), + })); + + let callbacks = PRJ_CALLBACKS { + StartDirectoryEnumerationCallback: Some(start_enumeration), + GetDirectoryEnumerationCallback: Some(get_enumeration), + EndDirectoryEnumerationCallback: Some(end_enumeration), + GetPlaceholderInfoCallback: Some(get_placeholder_info), + GetFileDataCallback: Some(get_file_data), + QueryFileNameCallback: Some(query_file_name), + NotificationCallback: Some(notification), + CancelCommandCallback: None, + }; + // One mapping over the whole tree: every `PRE_` operation that would modify + // the projection has to reach `notification` for it to be refused. Bound + // before `mappings` so it outlives the pointer taken into it. + let notify_root = wide(""); + let mut mappings = [PRJ_NOTIFICATION_MAPPING { + NotificationBitMask: PRJ_NOTIFY_TYPES( + PRJ_NOTIFY_PRE_DELETE.0 + | PRJ_NOTIFY_PRE_RENAME.0 + | PRJ_NOTIFY_PRE_SET_HARDLINK.0 + | PRJ_NOTIFY_FILE_PRE_CONVERT_TO_FULL.0, + ), + // The empty string is the virtualization root itself, so the mapping + // covers all of it rather than one subtree. An empty string, NOT null: + // that is what the API documents, and a null here would be a provider + // with no veto at all — which fails open, as a writable projection. + NotificationRoot: PCWSTR(notify_root.as_ptr()), + }]; + let options = PRJ_STARTVIRTUALIZING_OPTIONS { + Flags: PRJ_FLAG_NONE, + PoolThreadCount: POOL_THREADS, + ConcurrentThreadCount: POOL_THREADS, + NotificationMappings: mappings.as_mut_ptr(), + NotificationMappingsCount: mappings.len() as u32, + }; + + let ctx = match unsafe { + PrjStartVirtualizing( + PCWSTR(root.as_ptr()), + &callbacks, + Some(provider as *const core::ffi::c_void), + Some(&options), + ) + } { + Ok(ctx) => ctx, + Err(e) => { + // Reclaim the provider: nothing will ever call back into it. + drop(unsafe { Box::from_raw(provider) }); + // The likeliest cause by far, and one the user can act on. ProjFS + // is an optional Windows feature, off by default on client SKUs. + return Err(format!( + "could not start projecting {}: {e}. If this says the request \ + is not supported, enable the \"Windows Projected File System\" \ + optional feature and restart.", + dir.display() + )); + } + }; + + // The alignment every `PrjWriteFileData` has to respect. Asked for once, + // now that there is an instance to ask about. + let mut info = PRJ_VIRTUALIZATION_INSTANCE_INFO::default(); + if unsafe { PrjGetVirtualizationInstanceInfo(ctx, &mut info) }.is_ok() { + // SAFETY: `provider` is live — virtualization started, so nothing has + // freed it — and this runs before any callback can read the field. + unsafe { &*provider } + .write_alignment + .store(info.WriteAlignment.max(1), Ordering::Relaxed); + } + + if let Ok(mut g) = instance_slot().lock() { + *g = Some(Instance { + ctx, + provider, + root: dir.clone(), + }); + } + tracing::info!(path = %dir.display(), alignment = info.WriteAlignment, "fs-projfs: projecting"); + Ok(dir) +} + +/// Stop projecting, if we are. +pub(crate) fn unmount() { + let taken = instance_slot().lock().ok().and_then(|mut g| g.take()); + if let Some(inst) = taken { + // Blocking: it waits for in-flight callbacks to finish, which is + // precisely what makes reclaiming the provider afterwards safe. + std::thread::spawn(move || { + // Move the WHOLE `Instance`, not its fields. Rust 2021 closures + // capture disjointly, so naming `inst.ctx` and `inst.provider` + // would capture two raw pointers — neither of which is `Send` — + // instead of the struct whose `unsafe impl Send` vouches for them. + let inst = inst; + unsafe { PrjStopVirtualizing(inst.ctx) }; + // SAFETY: `PrjStopVirtualizing` has returned, so no callback can + // still hold this pointer. + drop(unsafe { Box::from_raw(inst.provider as *mut Provider) }); + tracing::info!(path = %inst.root.display(), "fs-projfs: stopped"); + }); + } +} + +/// Stop projecting on the process's way out. +/// +/// Unlike a FUSE mount, an abandoned projection leaves nothing broken in the +/// filesystem — the directory is a real directory either way, and Windows drops +/// the virtualization when the process goes. So this only has to be tidy, not +/// urgent: stop the instance and let the leaked provider go with the process. +pub(crate) fn unmount_on_exit() { + let taken = instance_slot().lock().ok().and_then(|mut g| g.take()); + if let Some(inst) = taken { + unsafe { PrjStopVirtualizing(inst.ctx) }; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +// +// Only the parts that do not call into projectedfslib: everything ProjFS itself +// answers needs Windows, and the logic above it — the path walk, the cache, the +// read splitting — is tested once in `fs_vfs` against a fake peer. + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs_vfs::tests::{dir, file}; + + #[test] + fn every_protocol_code_reaches_the_user_as_itself() { + assert_eq!( + hresult_of(code::NOENT), + HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0) + ); + assert_eq!( + hresult_of(code::ACCES), + HRESULT::from_win32(ERROR_ACCESS_DENIED.0) + ); + assert_eq!( + hresult_of(code::ROFS), + HRESULT::from_win32(ERROR_WRITE_PROTECT.0) + ); + assert_eq!( + hresult_of(crate::fs_link::NO_LINK), + HRESULT::from_win32(ERROR_HOST_DOWN.0), + "an absent phone must read as 'host is down', not a disk error" + ); + assert_eq!( + hresult_of(9999), + HRESULT::from_win32(ERROR_IO_DEVICE.0), + "an unknown code is still an error" + ); + } + + #[test] + fn a_unix_timestamp_becomes_the_same_moment_in_filetime() { + // The Unix epoch is 11,644,473,600 seconds after the FILETIME epoch. + assert_eq!(filetime_of(0), 11_644_473_600 * 10_000_000); + assert_eq!( + filetime_of(1_700_000_000), + (1_700_000_000 + 11_644_473_600) * 10_000_000 + ); + } + + #[test] + fn a_nonsense_clock_does_not_overflow_the_mount() { + // A phone set before 1601, and one set past the year 30000: both are + // absurd, and neither may panic a filesystem callback. + assert_eq!(filetime_of(-1_000_000_000_000), 0); + assert_eq!(filetime_of(i64::MAX), i64::MAX); + assert_eq!(filetime_of(i64::MIN), 0); + } + + #[test] + fn a_directory_and_a_file_carry_the_right_attributes() { + let d = basic_info(&dir("DCIM", "/sdcard/DCIM")); + assert!(d.IsDirectory); + assert_eq!(d.FileSize, 0); + assert_ne!(d.FileAttributes & FILE_ATTRIBUTE_DIRECTORY.0, 0); + assert_ne!( + d.FileAttributes & FILE_ATTRIBUTE_READONLY.0, + 0, + "the projection is read-only" + ); + + let f = basic_info(&file("a.txt", "/sdcard/a.txt", 5)); + assert!(!f.IsDirectory); + assert_eq!(f.FileSize, 5); + assert_eq!(f.FileAttributes & FILE_ATTRIBUTE_DIRECTORY.0, 0); + assert_ne!(f.FileAttributes & FILE_ATTRIBUTE_READONLY.0, 0); + // One protocol timestamp, reported as all four — better than three + // zeroes, which Explorer renders as 1601. + assert_eq!(f.LastWriteTime, filetime_of(1_700_000_000)); + assert_eq!(f.CreationTime, f.LastWriteTime); + } + + #[test] + fn a_string_survives_the_round_trip_through_utf16() { + for s in ["DCIM", "", "Ärger\\naïve", "日本語", "a b.txt"] { + let w = wide(s); + assert_eq!(w.last(), Some(&0), "must be NUL-terminated"); + assert_eq!(unsafe { from_wide(PCWSTR(w.as_ptr())) }, s); + } + } + + #[test] + fn a_null_path_reads_as_the_root() { + // ProjFS addresses the virtualization root with an empty string, and a + // null is what several of its fields carry when unset. Both have to + // mean "the root" rather than panic. + assert_eq!(unsafe { from_wide(PCWSTR::null()) }, ""); + } + + #[test] + fn the_hydrate_chunk_is_a_whole_number_of_alignment_units() { + // The rule `PrjWriteFileData` imposes: every chunk but a file's last + // must be a multiple of the volume's write alignment. + for align in [1u32, 512, 4096, 64 * 1024, 2 * 1024 * 1024] { + let chunk = HYDRATE_CHUNK.div_ceil(align).saturating_mul(align).max(align); + assert_eq!(chunk % align, 0, "align={align}"); + assert!(chunk >= align, "align={align}"); + assert!(chunk >= HYDRATE_CHUNK.min(align), "align={align}"); + } + } +} diff --git a/linux/ui-tauri/src-tauri/src/fs_vfs.rs b/linux/ui-tauri/src-tauri/src/fs_vfs.rs new file mode 100644 index 0000000..baa5e19 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/fs_vfs.rs @@ -0,0 +1,725 @@ +//! The OS-independent half of presenting the phone's storage as a filesystem. +//! +//! Two mount adapters sit on top of this: [`crate::fs_mount`] (FUSE, Linux) and +//! [`crate::fs_projfs`] (ProjFS, Windows). They have almost nothing in common — +//! one answers a kernel protocol on a socket, the other implements COM-style +//! callbacks — but everything *between* them and the wire is the same work: +//! +//! * caching listings and attributes, so a file manager's stat storm does not +//! become a round trip per file; +//! * turning an opaque peer address into a path and back; +//! * splitting a large read into protocol-sized ranged reads, pipelined; +//! * holding open handles, and capping how much of any of it is in flight. +//! +//! Keeping it here means the interesting bugs — the ones about identity, +//! staleness and reassembly — are written once and tested once, on whichever +//! machine happens to be running the tests. The adapters are left holding only +//! the part that genuinely differs. +//! +//! # Addresses are not paths +//! +//! The peer addresses an entry with an opaque token ([`p::FsEntry::path`]): an +//! absolute path on a real filesystem, but a `content://` document URI under +//! Android's SAF, where a name is simply not addressable. So a child's address +//! can only be *discovered*, by listing its parent — never constructed by +//! joining a name onto the parent's address. [`Vfs::resolve`] is that walk, and +//! it is why a mount adapter can hand us the `DCIM\Camera\foo.jpg` its OS gave +//! it without knowing what the phone will make of it. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use vortex_l3_daemon::core::fs_proto::{self as p, code}; + +/// How long an attribute may be trusted. +/// +/// The metadata cache design doc §7 asks for. Five seconds is long enough to +/// survive a file manager's stat storm and short enough that a file changed on +/// the phone shows up while the user is still looking at the folder. +/// +/// On Linux this is also handed to the kernel, which then answers a repeat +/// `stat` without troubling this process at all. +pub(crate) const ATTR_TTL: Duration = Duration::from_secs(5); + +/// How long a directory listing is kept. +/// +/// Separate from [`ATTR_TTL`] because it serves a second purpose: a listing is +/// how a child's opaque address is discovered at all, so it is consulted on +/// paths no attribute cache would cover. +pub(crate) const DIR_TTL: Duration = Duration::from_secs(5); + +/// Requests allowed on the link at once. +/// +/// Not a throughput knob — a fairness one. A thumbnailer opening a folder of +/// photos issues as many reads as there are files, and an unbounded queue of +/// them would delay every listing behind megabytes of image data and, on a BLE +/// fallback, starve the session that carries everything else. +pub(crate) const MAX_INFLIGHT: usize = 8; + +/// Ranged reads pipelined inside ONE filesystem read. +/// +/// A caller asks for up to a few hundred KiB; the protocol caps a read at +/// 48 KiB. Those pieces go out together rather than one after another, for the +/// same reason [`crate::fs_link::read_all`] does it. +const READ_WINDOW: usize = 4; + +/// Entries held for one directory. A guard against a peer that pages forever, +/// not a real limit — 200k files in one folder is already pathological. +const MAX_DIR_ENTRIES: usize = 200_000; + +/// Cached directories, and cached attributes, before expired ones are swept. +/// +/// Without a sweep a long browse would hold every listing it ever fetched for +/// the life of the process — which for this app is days. Sweeping on insert +/// past a threshold keeps it bounded without a timer, and the cost lands on the +/// operation that grew the map. +const CACHE_SWEEP_AT: usize = 4_096; + +/// The address of the peer's synthetic root — the listing of what it shares. +pub(crate) const ROOT_ADDR: &str = ""; + +// --------------------------------------------------------------------------- +// The remote half +// --------------------------------------------------------------------------- + +/// The peer-facing operations a mount needs. +/// +/// Returns `impl Future + Send` rather than using `async fn` in the trait so +/// the futures can be `tokio::spawn`ed, which the FUSE adapter's concurrency +/// model depends on. +pub(crate) trait FsRemote: Send + Sync + 'static { + fn list( + &self, + path: String, + cursor: u32, + ) -> impl Future, Option), i32>> + Send; + fn stat(&self, path: String) -> impl Future> + Send; + /// Returns `(handle, size at open time)`. + fn open(&self, path: String) -> impl Future> + Send; + /// Returns `(bytes, eof)`. A short result is normal. + fn read( + &self, + handle: u64, + offset: u64, + len: u32, + ) -> impl Future, bool), i32>> + Send; + fn close(&self, handle: u64) -> impl Future + Send; +} + +/// The production remote: the protocol client over whatever transport is up. +pub(crate) struct LinkRemote; + +// The trait declares `-> impl Future + Send`; an impl may satisfy that with a +// plain `async fn`, and the compiler still checks the future is `Send`. +impl FsRemote for LinkRemote { + async fn list( + &self, + path: String, + cursor: u32, + ) -> Result<(Vec, Option), i32> { + crate::fs_link::list(&path, cursor).await + } + async fn stat(&self, path: String) -> Result { + crate::fs_link::stat(&path).await + } + async fn open(&self, path: String) -> Result<(u64, u64), i32> { + // Never for writing: the mount is read-only (design doc §8 step 5). + crate::fs_link::open(&path, false).await + } + async fn read(&self, handle: u64, offset: u64, len: u32) -> Result<(Vec, bool), i32> { + crate::fs_link::read(handle, offset, len).await + } + async fn close(&self, handle: u64) { + crate::fs_link::close(handle).await + } +} + +// --------------------------------------------------------------------------- +// The cache +// --------------------------------------------------------------------------- + +/// A file a caller has open, keyed by the handle we handed back. +struct OpenFile { + /// The peer's handle. Ours is a separate number so a peer handle of 0 (or a + /// reused one) cannot collide with "no handle". + remote: u64, + /// Size as of open. Reads are clamped to it so we never ask the phone for a + /// range past the end just because the caller rounded up to a page. + size: u64, +} + +/// A cached value and when it was cached, so a reader can check it against a +/// TTL without a second map. +type Cached = (Instant, T); + +/// The phone's filesystem, cached and addressed by opaque peer address. +pub(crate) struct Vfs { + pub(crate) remote: R, + /// Listings by directory address. `Arc` so a hit does not copy a 10,000 + /// entry folder on its way out. + dirs: Mutex>>>>, + /// Attributes by address, seeded from listings. + attrs: Mutex>>, + files: Mutex>, + next_fh: AtomicU64, + gate: tokio::sync::Semaphore, +} + +impl Vfs { + pub(crate) fn new(remote: R) -> Self { + Self { + remote, + dirs: Mutex::new(HashMap::new()), + attrs: Mutex::new(HashMap::new()), + files: Mutex::new(HashMap::new()), + next_fh: AtomicU64::new(1), + gate: tokio::sync::Semaphore::new(MAX_INFLIGHT), + } + } + + // Every lock below is a `std::sync::Mutex` held for a single map operation + // and never across an `await`. Keeping that discipline is why the helpers + // are this granular. + + /// Run one peer request under the concurrency cap. + pub(crate) async fn gated(&self, f: impl Future) -> T { + // `acquire` only fails on a closed semaphore, and we never close it; + // proceeding uncapped beats failing the operation. + let _permit = self.gate.acquire().await; + f.await + } + + fn cached_dir(&self, addr: &str) -> Option>> { + let g = self.dirs.lock().ok()?; + let (at, entries) = g.get(addr)?; + (at.elapsed() < DIR_TTL).then(|| entries.clone()) + } + + fn cached_attr(&self, addr: &str) -> Option { + let g = self.attrs.lock().ok()?; + let (at, entry) = g.get(addr)?; + (at.elapsed() < ATTR_TTL).then(|| entry.clone()) + } + + fn store_attr(&self, entry: &p::FsEntry) { + if let Ok(mut g) = self.attrs.lock() { + if g.len() >= CACHE_SWEEP_AT { + g.retain(|_, (at, _)| at.elapsed() < ATTR_TTL); + } + g.insert(entry.path.clone(), (Instant::now(), entry.clone())); + } + } + + /// A directory's entries, from cache or from the peer. + /// + /// Also where the attribute cache is seeded: a file manager follows a + /// listing with a stat per entry, and answering those from the listing we + /// already hold is the difference between one round trip per folder and one + /// per file. + pub(crate) async fn listing(&self, addr: &str) -> Result>, i32> { + if let Some(entries) = self.cached_dir(addr) { + return Ok(entries); + } + let mut all: Vec = Vec::new(); + let mut cursor = 0u32; + loop { + let (page, next) = self.gated(self.remote.list(addr.to_string(), cursor)).await?; + all.extend(page); + match next { + // A peer that keeps handing back the same cursor is not making + // progress; stopping with a partial listing beats looping. + Some(c) if c != cursor && all.len() < MAX_DIR_ENTRIES => cursor = c, + _ => break, + } + } + // An entry with no address cannot be opened or listed, so it would show + // as a permanently broken row. Drop it and say so once. + let before = all.len(); + all.retain(|e| !e.path.is_empty()); + if all.len() != before { + tracing::warn!( + dropped = before - all.len(), + "fs-vfs: listing had entries with no address" + ); + } + for e in &all { + self.store_attr(e); + } + let all = Arc::new(all); + if let Ok(mut g) = self.dirs.lock() { + if g.len() >= CACHE_SWEEP_AT { + g.retain(|_, (at, _)| at.elapsed() < DIR_TTL); + } + g.insert(addr.to_string(), (Instant::now(), all.clone())); + } + Ok(all) + } + + /// Resolve one name inside a directory. + /// + /// Through the parent's listing rather than by joining the name onto the + /// parent's address, because the address is opaque — see the module docs. + /// + /// Exact match first, then case-insensitive. Windows callers arrive with + /// whatever case the user typed and expect it to work; Android's storage is + /// case-preserving but its FAT-derived emulation is not case-sensitive + /// either, so a fold is the truthful behaviour on both sides. Exact still + /// wins, so a peer that really does hold `README` and `readme` resolves + /// each to itself. + pub(crate) async fn lookup_child(&self, parent: &str, name: &str) -> Result { + let entries = self.listing(parent).await?; + if let Some(e) = entries.iter().find(|e| e.name == name) { + return Ok(e.clone()); + } + entries + .iter() + .find(|e| e.name.eq_ignore_ascii_case(name)) + .cloned() + .ok_or(code::NOENT) + } + + /// One address's attributes. + pub(crate) async fn entry_of(&self, addr: &str) -> Result { + if addr == ROOT_ADDR { + return Ok(root_entry()); + } + if let Some(e) = self.cached_attr(addr) { + return Ok(e); + } + let entry = self.gated(self.remote.stat(addr.to_string())).await?; + self.store_attr(&entry); + Ok(entry) + } + + /// Resolve a path relative to the mount root. + /// + /// Used by the ProjFS adapter, which is addressed by path; FUSE is + /// addressed by inode and resolves one component at a time through + /// [`Vfs::lookup_child`]. Tested on every platform regardless — it is the + /// hardest piece of the Windows adapter and the one least able to be + /// tested there. + /// + /// Accepts either separator: an OS hands us its own, and a mount adapter + /// should not have to translate before it can ask a question. Empty + /// components are skipped, so a doubled separator or a trailing one is not + /// an error. + /// + /// One listing per component, all of them cached, so walking a path the + /// user is already browsing costs nothing. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) async fn resolve(&self, rel: &str) -> Result { + let mut entry = root_entry(); + for part in rel.split(['\\', '/']).filter(|s| !s.is_empty()) { + // `.` and `..` never reach us from either OS — both resolve them + // before the request — and honouring `..` would need a parent link + // an opaque address does not have. Refusing is the honest answer. + if part == "." || part == ".." { + return Err(code::INVAL); + } + if !entry.is_dir { + // A path continues past a file: not "missing", but there is + // nothing there to descend into. + return Err(code::NOENT); + } + entry = self.lookup_child(&entry.path, part).await?; + } + Ok(entry) + } + + /// Open a file by address. The handle returned is ours, not the peer's. + pub(crate) async fn open(&self, addr: &str) -> Result { + let (remote, size) = self.gated(self.remote.open(addr.to_string())).await?; + let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = self.files.lock() { + g.insert(fh, OpenFile { remote, size }); + } + Ok(fh) + } + + /// Size of an open file, as of when it was opened. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn size_of(&self, fh: u64) -> Option { + self.files.lock().ok().and_then(|g| g.get(&fh).map(|f| f.size)) + } + + /// Release a handle. Unknown handles are ignored — a double close is the + /// caller's business, not an error worth propagating. + pub(crate) async fn close(&self, fh: u64) { + let f = self.files.lock().ok().and_then(|mut g| g.remove(&fh)); + if let Some(f) = f { + self.gated(self.remote.close(f.remote)).await; + } + } + + /// Read `size` bytes at `offset` from an open file, as one contiguous run. + /// + /// Splits into protocol-sized pieces and keeps [`READ_WINDOW`] of them in + /// flight. `FuturesOrdered` yields in issue order, which is also offset + /// order, so the pieces concatenate directly — and each future carries the + /// offset it asked for, so a short piece is *detected* rather than silently + /// shifting everything after it. On a gap we return the prefix: a read must + /// be contiguous from `offset`, and a short reply is a legal answer. + /// + /// Clamped to the file's size at open, which saves a round trip for the + /// page-sized overshoot a caller makes at the end of every file. + pub(crate) async fn read_range(&self, fh: u64, offset: u64, size: u32) -> Result, i32> { + use futures::stream::{FuturesOrdered, StreamExt}; + + let (remote, file_size) = self + .files + .lock() + .ok() + .and_then(|g| g.get(&fh).map(|f| (f.remote, f.size))) + .ok_or(code::BADF)?; + if offset >= file_size { + return Ok(Vec::new()); + } + let end = offset + .saturating_add(size as u64) + .min(file_size); + let mut out: Vec = Vec::with_capacity((end - offset) as usize); + let mut pending = FuturesOrdered::new(); + let mut next = offset; + let mut expect = offset; + loop { + while pending.len() < READ_WINDOW && next < end { + let at = next; + let len = (end - at).min(p::MAX_READ_LEN as u64) as u32; + pending.push_back(async move { + (at, self.gated(self.remote.read(remote, at, len)).await) + }); + next = at + len as u64; + } + let Some((at, res)) = pending.next().await else { + break; + }; + let (bytes, eof) = res?; + if at != expect { + // An earlier piece came back short, so this one starts past the + // end of what we have. Anything further would land at the wrong + // file offset. + break; + } + expect = at + bytes.len() as u64; + out.extend_from_slice(&bytes); + if eof || bytes.is_empty() { + break; + } + } + Ok(out) + } +} + +/// The mount root's own attributes. +/// +/// Synthetic rather than a `STAT` of the empty address: the peer's root is a +/// list of what it shares, not a directory it can stat, and listing the mount +/// point must work regardless. A zero mtime rather than "now" so the root does +/// not appear to change on every remount. +pub(crate) fn root_entry() -> p::FsEntry { + p::FsEntry { + name: "/".to_string(), + path: ROOT_ADDR.to_string(), + is_dir: true, + size: 0, + mtime: 0, + readonly: true, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A peer with a fixed tree, so the cache and the path walk can be tested + /// without a phone, a kernel or a link. Counts requests: most of what this + /// module does is avoid making them. + pub(crate) struct FakePeer { + /// address → entries, for directories. + dirs: HashMap>, + /// address → contents, for files. + files: HashMap>, + pub(crate) lists: std::sync::atomic::AtomicU32, + pub(crate) stats: std::sync::atomic::AtomicU32, + pub(crate) reads: std::sync::atomic::AtomicU32, + } + + pub(crate) fn dir(name: &str, path: &str) -> p::FsEntry { + p::FsEntry { + name: name.into(), + path: path.into(), + is_dir: true, + size: 0, + mtime: 1_700_000_000, + readonly: true, + } + } + + pub(crate) fn file(name: &str, path: &str, size: u64) -> p::FsEntry { + p::FsEntry { + name: name.into(), + path: path.into(), + is_dir: false, + size, + mtime: 1_700_000_000, + readonly: true, + } + } + + /// The 100 KiB test file's contents — three protocol reads' worth. + pub(crate) fn big_bytes() -> Vec { + (0..100 * 1024).map(|i| (i % 251) as u8).collect() + } + + impl FakePeer { + pub(crate) fn new() -> Self { + let big = big_bytes(); + let mut dirs = HashMap::new(); + dirs.insert( + ROOT_ADDR.to_string(), + vec![dir("DCIM", "/sdcard/DCIM"), file("a.txt", "/sdcard/a.txt", 5)], + ); + dirs.insert( + "/sdcard/DCIM".to_string(), + vec![file("big.bin", "/sdcard/DCIM/big.bin", big.len() as u64)], + ); + let mut files = HashMap::new(); + files.insert("/sdcard/a.txt".to_string(), b"hello".to_vec()); + files.insert("/sdcard/DCIM/big.bin".to_string(), big); + Self { + dirs, + files, + lists: Default::default(), + stats: Default::default(), + reads: Default::default(), + } + } + } + + impl FsRemote for Arc { + fn list( + &self, + path: String, + cursor: u32, + ) -> impl Future, Option), i32>> + Send { + let me = self.clone(); + async move { + me.lists.fetch_add(1, Ordering::Relaxed); + let all = me.dirs.get(&path).ok_or(code::NOENT)?; + // One entry per page, so pagination is exercised rather than + // assumed. + let at = cursor as usize; + match all.get(at) { + Some(e) => Ok((vec![e.clone()], (at + 1 < all.len()).then_some(cursor + 1))), + None => Ok((vec![], None)), + } + } + } + + fn stat(&self, path: String) -> impl Future> + Send { + let me = self.clone(); + async move { + me.stats.fetch_add(1, Ordering::Relaxed); + me.dirs + .values() + .flatten() + .find(|e| e.path == path) + .cloned() + .ok_or(code::NOENT) + } + } + + fn open(&self, path: String) -> impl Future> + Send { + let me = self.clone(); + async move { + let bytes = me.files.get(&path).ok_or(code::NOENT)?; + // The handle IS the address's index; enough to read it back. + let idx = me.files.keys().position(|k| k == &path).unwrap() as u64; + Ok((idx + 1, bytes.len() as u64)) + } + } + + fn read( + &self, + handle: u64, + offset: u64, + len: u32, + ) -> impl Future, bool), i32>> + Send { + let me = self.clone(); + async move { + me.reads.fetch_add(1, Ordering::Relaxed); + let key = me + .files + .keys() + .nth(handle as usize - 1) + .cloned() + .ok_or(code::BADF)?; + let bytes = &me.files[&key]; + let at = (offset as usize).min(bytes.len()); + let to = (at + len as usize).min(bytes.len()); + Ok((bytes[at..to].to_vec(), to >= bytes.len())) + } + } + + async fn close(&self, _handle: u64) {} + } + + pub(crate) fn vfs() -> (Arc>>, Arc) { + let peer = Arc::new(FakePeer::new()); + (Arc::new(Vfs::new(peer.clone())), peer) + } + + #[tokio::test] + async fn listing_follows_pagination_to_the_end() { + let (fs, peer) = vfs(); + let entries = fs.listing(ROOT_ADDR).await.unwrap(); + assert_eq!( + entries.iter().map(|e| e.name.as_str()).collect::>(), + ["DCIM", "a.txt"] + ); + // Two pages plus the one that reports the end. + assert_eq!(peer.lists.load(Ordering::Relaxed), 2); + } + + #[tokio::test] + async fn a_listing_answers_the_stats_that_follow_it() { + let (fs, peer) = vfs(); + fs.listing(ROOT_ADDR).await.unwrap(); + let before = peer.lists.load(Ordering::Relaxed); + let e = fs.lookup_child(ROOT_ADDR, "a.txt").await.unwrap(); + assert_eq!(e.path, "/sdcard/a.txt"); + // The point of the exercise: no further round trips, of any kind. + assert_eq!(peer.lists.load(Ordering::Relaxed), before); + assert_eq!(peer.stats.load(Ordering::Relaxed), 0); + assert_eq!(fs.entry_of(&e.path).await.unwrap().name, "a.txt"); + assert_eq!(peer.stats.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn a_missing_name_is_noent_not_a_hang() { + let (fs, _) = vfs(); + assert_eq!( + fs.lookup_child(ROOT_ADDR, "nope").await.unwrap_err(), + code::NOENT + ); + } + + #[tokio::test] + async fn the_root_stats_without_asking_the_peer() { + let (fs, peer) = vfs(); + let e = fs.entry_of(ROOT_ADDR).await.unwrap(); + assert!(e.is_dir); + assert_eq!(peer.stats.load(Ordering::Relaxed), 0); + } + + // ── The path walk, which is what the ProjFS adapter stands on ────────── + + #[tokio::test] + async fn a_path_resolves_to_the_peers_opaque_address() { + let (fs, _) = vfs(); + // Both separators, because each OS hands us its own. + for p in ["DCIM\\big.bin", "DCIM/big.bin"] { + let e = fs.resolve(p).await.unwrap(); + assert_eq!(e.path, "/sdcard/DCIM/big.bin", "{p}"); + assert!(!e.is_dir); + } + assert_eq!(fs.resolve("DCIM").await.unwrap().path, "/sdcard/DCIM"); + } + + #[tokio::test] + async fn the_empty_path_is_the_root() { + let (fs, peer) = vfs(); + for p in ["", "\\", "/"] { + assert_eq!(fs.resolve(p).await.unwrap().path, ROOT_ADDR, "{p}"); + } + // The root is synthetic: resolving it must not ask the peer anything. + assert_eq!(peer.lists.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn a_path_walk_is_case_insensitive_like_windows() { + let (fs, _) = vfs(); + assert_eq!( + fs.resolve("dcim\\BIG.BIN").await.unwrap().path, + "/sdcard/DCIM/big.bin" + ); + } + + #[tokio::test] + async fn descending_into_a_file_is_not_found() { + let (fs, _) = vfs(); + assert_eq!( + fs.resolve("a.txt\\nope").await.unwrap_err(), + code::NOENT, + "a path may not continue past a file" + ); + } + + #[tokio::test] + async fn dot_and_dotdot_are_refused_rather_than_guessed() { + let (fs, _) = vfs(); + // Neither OS sends these, and an opaque address has no parent link, so + // answering would mean inventing one. + assert_eq!(fs.resolve("DCIM\\..").await.unwrap_err(), code::INVAL); + assert_eq!(fs.resolve(".").await.unwrap_err(), code::INVAL); + } + + #[tokio::test] + async fn a_missing_component_is_noent() { + let (fs, _) = vfs(); + assert_eq!(fs.resolve("DCIM\\nope.bin").await.unwrap_err(), code::NOENT); + assert_eq!(fs.resolve("nope\\deep").await.unwrap_err(), code::NOENT); + } + + // ── Reads ───────────────────────────────────────────────────────────── + + #[tokio::test] + async fn a_read_larger_than_the_protocol_limit_is_split_and_reassembled() { + let (fs, peer) = vfs(); + let fh = fs.open("/sdcard/DCIM/big.bin").await.unwrap(); + assert_eq!(fs.size_of(fh), Some(100 * 1024)); + let bytes = fs.read_range(fh, 0, 100 * 1024).await.unwrap(); + assert_eq!(bytes, big_bytes(), "reassembled in the wrong order"); + // 48 + 48 + 4 KiB. + assert_eq!(peer.reads.load(Ordering::Relaxed), 3); + } + + #[tokio::test] + async fn a_read_at_an_offset_starts_there() { + let (fs, _) = vfs(); + let fh = fs.open("/sdcard/DCIM/big.bin").await.unwrap(); + let at = 70 * 1024u64; + let bytes = fs.read_range(fh, at, 100 * 1024).await.unwrap(); + assert_eq!(bytes, big_bytes()[at as usize..]); + } + + #[tokio::test] + async fn a_read_is_clamped_to_the_file_rather_than_asking_past_it() { + let (fs, peer) = vfs(); + let fh = fs.open("/sdcard/a.txt").await.unwrap(); + // A caller asking for a whole page of a 5-byte file is the normal case. + assert_eq!(fs.read_range(fh, 0, 4096).await.unwrap(), b"hello"); + assert_eq!(peer.reads.load(Ordering::Relaxed), 1); + // Past the end costs no round trip at all. + assert!(fs.read_range(fh, 5, 4096).await.unwrap().is_empty()); + assert_eq!(peer.reads.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn a_closed_handle_is_bad_not_a_panic() { + let (fs, _) = vfs(); + let fh = fs.open("/sdcard/a.txt").await.unwrap(); + fs.close(fh).await; + assert_eq!(fs.read_range(fh, 0, 16).await.unwrap_err(), code::BADF); + assert_eq!(fs.size_of(fh), None); + // A second close is the caller's business, not an error. + fs.close(fh).await; + } +} diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 50c72d2..5ba2479 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -63,10 +63,6 @@ use platform_unsupported as proximity; use platform_unsupported as earbuds; #[cfg(not(target_os = "linux"))] use platform_unsupported as laptop_cast; -// Browsing the phone's files needs a mount adapter, and the Windows one -// (ProjFS) is not written yet — design doc §8 step 6. -#[cfg(not(target_os = "linux"))] -use platform_unsupported as fs_mount; mod clipboard; mod clipboard_hotkey; mod clipboard_window; @@ -88,10 +84,15 @@ mod share; mod file_consent; mod fs_cli; mod fs_lan; -// The phone's storage as a FUSE mount. Linux-only by nature: the Windows half -// of design doc §8 step 6 is ProjFS, a different API for the same protocol. -#[cfg(target_os = "linux")] +// The phone's storage as a real filesystem. `fs_mount` is the facade and +// `fs_vfs` the OS-independent half; the adapter under them is per-OS — FUSE on +// Linux, ProjFS on Windows (design doc §8 step 6). mod fs_mount; +mod fs_vfs; +#[cfg(target_os = "linux")] +mod fs_fuse; +#[cfg(target_os = "windows")] +mod fs_projfs; mod fs_pull; mod contacts; mod desktop_apps; diff --git a/linux/ui-tauri/src-tauri/src/platform_unsupported.rs b/linux/ui-tauri/src-tauri/src/platform_unsupported.rs index 8be221b..5694468 100644 --- a/linux/ui-tauri/src-tauri/src/platform_unsupported.rs +++ b/linux/ui-tauri/src-tauri/src/platform_unsupported.rs @@ -176,12 +176,3 @@ pub(crate) fn persist_peer_earbuds(_state: &vortex_l3_daemon::core::appstate::Ap /// is nothing to unlock here — Windows has no programmatic unlock at all, which /// `SessionControl::can_unlock` already reports — so the value is dropped. pub(crate) fn note_phone_unlocked(_unlocked: Option) {} - -// ── Browsing the phone's files (FUSE on Linux) ──────────────────────────── - -/// The protocol is portable; the mount adapter is not. Windows needs ProjFS -/// (design doc §8 step 6), so until that exists the button has nothing to open. -#[tauri::command] -pub async fn open_phone_files() -> Result { - Err(UNSUPPORTED.to_string()) -} From 0d9fc0ea502adc9c59dc90927a21c96cd9250c00 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 10:28:47 +0200 Subject: [PATCH 55/71] docs(fs): the Windows cross-build, and the load-time import it exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whole Windows binary cross-builds from Linux with mingw-w64, which is a better check than `cargo check`: the result imports all eleven `Prj*` entry points from projectedfslib.dll, so the FFI is wired and not merely type-correct. Recipe recorded, including that `WebView2Loader.dll` has to ship beside the exe. Building it surfaced a shipping bug worth fixing before release, now an open question: those imports are STATIC, so on a machine where the ProjFS optional feature has never been enabled and the DLL is absent, Windows refuses to start the whole app — a user who never wanted to browse their phone's files would lose notifications, clipboard and calls with it. Reaching ProjFS through LoadLibrary/GetProcAddress is the fix, at the cost of the compiler-checked signatures the `windows` crate gives us today. Delay-loading alone is not enough: its failure is a structured exception, so it needs a failure hook to become an error. Co-Authored-By: Claude Opus 5 (1M context) --- docs/design/file-browsing.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/design/file-browsing.md b/docs/design/file-browsing.md index 34a30c9..f44a735 100644 --- a/docs/design/file-browsing.md +++ b/docs/design/file-browsing.md @@ -397,7 +397,23 @@ This is where these features usually fail, and it is all daemon-side: without a round trip. **The Windows half has never run.** There is no Windows machine in this - project's loop, so ProjFS is verified only as far as a cross-compile reaches: + project's loop, so ProjFS is verified only as far as a cross-compile reaches. + A whole binary does cross-build from Linux, which is further than a check: + + ```text + cd linux/ui-tauri && npm run build # the embedded frontend + cd src-tauri && cargo build --release \ + --target x86_64-pc-windows-gnu \ + --features custom-protocol --bin vortex-ui-tauri + ``` + + needing only mingw-w64 (`-gnu`, not `-msvc`: an MSVC cross-link wants + `lib.exe`). The result imports all eleven `Prj*` entry points from + `projectedfslib.dll`, which is the strongest check available here that the + FFI is wired correctly rather than merely type-correct. Ship + `WebView2Loader.dll` beside it — it is a dynamic import, and the app will + not start without it. + `cargo check --all-targets --target x86_64-pc-windows-gnu` is clean, which type-checks every callback signature, struct layout and constant against the real Win32 metadata — and nothing about behaviour. What that cannot catch is @@ -459,6 +475,17 @@ listener — worth doing, and the natural companion to step 3. ## 9. Open questions +- **ProjFS is imported at load time, and that is wrong for shipping.** The + eleven `Prj*` calls are ordinary static imports, so if `projectedfslib.dll` + is absent — possible on a machine where the optional feature has never been + enabled — Windows refuses to start the *whole app*, with a missing-DLL error + rather than the mount politely declining. A user who never wanted to browse + their phone's files would lose notifications, clipboard and calls with it. + The fix is to reach ProjFS through `LoadLibrary`/`GetProcAddress` instead, + which costs the compiler-checked signatures the `windows` crate currently + gives us — a bad trade while nothing has run, a necessary one before release. + (Delay-loading is not the answer on its own: a delay-load failure raises a + structured exception, so it needs a failure hook to become an error.) - ~~**Windows `FileSizeLimitInBytes`:** ship a registry tweak in the installer, document it, or skip straight to ProjFS?~~ **Answered: straight to ProjFS**, so the limit never applies. What replaces it as a Windows deployment question From 090546dd0b3c936dbd47c13244b13b60ced10e34 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 10:55:38 +0200 Subject: [PATCH 56/71] fix(windows): claim the arbiter on connect, and stop keying caches off $HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent bugs behind the multi-peering weirdness on Windows, both found in the log from the first real run. 1. The portable BLE loop never told the arbiter anything. `ble_portable` READ the arbiter (`preferred_peer`, to pick who to dial) but never wrote to it — no `note_connected`, no `claim` — where the BlueZ loop does both immediately after IK proves the peer's identity. So on Windows no peer was ever active until a restart happened to claim one via worker.rs's single-trusted-peer path. That is exactly the reported shape: pair, and the new laptop never takes ownership from the phone's previous one; restart and pick it by hand, and it works. It also silently disabled every per-peer cache, because `peer_cache::peer_dir` keys on the ACTIVE peer and returns None when there is none. 2. `$HOME` is a Unix variable, and Windows does not set it. `peer_cache::cache_root` built every per-peer path from it, so on Windows the whole cache was None: SMS, contacts and call-log history could be neither read nor written. The consequence is visible in the log as a re-sync storm — the bulk-sync hash is computed over an empty id list, never matches, and the phone re-sends its entire history every 12-second heartbeat. 42 syncs in 8.5 minutes, ~1 MB of SMS history each, where the Linux log answers "match" 66 times in 71. Same bug, same file-scope, in `clipboard` (history never persisted), `notes` (empty after every restart), `icon_cache` (no mirrored app icon ever cached) and `voice_settings`. All now go through the platform seam, which resolves to the same `~/.cache/vortex` on Linux and to `%LOCALAPPDATA%\Vortex\Cache` on Windows. `voice_settings` keeps its exact Linux path because that file is a bridge read by the voice scripts outside this process — the pattern `file_consent` already established when it hit this. Verified on Linux that nothing moved: XDG_CACHE_HOME unset here so the seam returns the same directory, the existing peer cache is untouched, and the bulk-sync gate still answers "match" after the first post-restart sync. 57 + 205 tests pass; both targets check clean. NOT fixed: the crash right after pairing. The log ends abruptly with no panic line despite a panic hook that would have logged one, and the writer is unbuffered, so the process died without unwinding — an access violation, a stack overflow or an abort. That needs the faulting module and exception code from Event Viewer, or a WER dump; it cannot be read out of this log. Co-Authored-By: Claude Opus 5 (1M context) --- linux/daemon/src/core/icon_cache.rs | 6 ++--- linux/ui-tauri/src-tauri/src/ble_portable.rs | 20 +++++++++++++++++ linux/ui-tauri/src-tauri/src/clipboard.rs | 6 +++-- linux/ui-tauri/src-tauri/src/notes.rs | 7 +++--- linux/ui-tauri/src-tauri/src/peer_cache.rs | 17 ++++++++++---- .../ui-tauri/src-tauri/src/voice_settings.rs | 22 +++++++++++++++++-- 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/linux/daemon/src/core/icon_cache.rs b/linux/daemon/src/core/icon_cache.rs index 5e2aa3d..f4a4269 100644 --- a/linux/daemon/src/core/icon_cache.rs +++ b/linux/daemon/src/core/icon_cache.rs @@ -26,9 +26,9 @@ pub fn parse_chunk(plain: &[u8]) -> Option<(String, u16, u16, Vec)> { } fn cache_dir() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/icons"); - Some(p) + // Seam, not `$HOME` — unset on Windows, where no mirrored app icon could + // ever be cached. Same `~/.cache/vortex/icons` on Linux. + Some(crate::core::platform::paths().cache()?.join("icons")) } /// Keep only safe path chars so a malformed package can't escape the dir. diff --git a/linux/ui-tauri/src-tauri/src/ble_portable.rs b/linux/ui-tauri/src-tauri/src/ble_portable.rs index 0091d79..03a9bdf 100644 --- a/linux/ui-tauri/src-tauri/src/ble_portable.rs +++ b/linux/ui-tauri/src-tauri/src/ble_portable.rs @@ -273,6 +273,26 @@ async fn connect_and_run( None => return Err("IK produced no transport ciphers".to_string()), }; + // Tell the arbiter, exactly where the BlueZ loop does: IK has just + // proved this address really is this peer, so ownership can be claimed + // on evidence rather than on a scan result. + // + // Missing here, this loop only ever READ the arbiter (`preferred_peer` + // above) and never wrote to it — so on Windows no peer was ever active + // until a restart happened to claim one. Two things fell out of that: a + // freshly-paired laptop never took ownership from the phone's previous + // one, and `peer_cache::peer_dir` — which keys on the ACTIVE peer — + // returned `None`, quietly disabling every per-peer cache. + crate::arbiter::note_connected(&peer.peer_static_pub); + if let crate::arbiter::Claim::Busy { current } = + crate::arbiter::claim(&peer.peer_static_pub) + { + tracing::warn!( + peer = %hex::encode(&peer.peer_static_pub[..4]), + active = %hex::encode(¤t[..4]), + "second peer connected while another is active; link up but not active" + ); + } tracing::info!(addr = %addr, "BLE link established"); crate::presence::touch_presence(); crate::presence::touch_peer_contact(); diff --git a/linux/ui-tauri/src-tauri/src/clipboard.rs b/linux/ui-tauri/src-tauri/src/clipboard.rs index 4d8847a..6d8436d 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard.rs @@ -80,9 +80,11 @@ pub(crate) struct ClipEntry { pub pinned: bool, } +/// Through the seam rather than `$HOME`: the latter is unset on Windows, where +/// this resolved to `None` and clipboard history was quietly never persisted. +/// Same `~/.cache/vortex` on Linux as before. fn clip_dir() -> Option { - let home = std::env::var_os("HOME")?; - Some(PathBuf::from(home).join(".cache/vortex/clipboard")) + Some(vortex_l3_daemon::core::platform::paths().cache()?.join("clipboard")) } fn index_path() -> Option { diff --git a/linux/ui-tauri/src-tauri/src/notes.rs b/linux/ui-tauri/src-tauri/src/notes.rs index 9f3c25f..cefc7d0 100644 --- a/linux/ui-tauri/src-tauri/src/notes.rs +++ b/linux/ui-tauri/src-tauri/src/notes.rs @@ -51,9 +51,10 @@ pub(crate) fn now_ms() -> i64 { /// `~/.cache/vortex/notes.json` — the full item array incl. tombstones (so a /// delete still propagates after a restart). fn cache_path() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex/notes.json"); - Some(p) + // Seam, not `$HOME` — unset on Windows, where notes were therefore never + // written to disk and came back empty after every restart. Resolves to the + // same `~/.cache/vortex` on Linux. + Some(vortex_l3_daemon::core::platform::paths().cache()?.join("notes.json")) } /// How long a tombstone is kept before it is dropped. diff --git a/linux/ui-tauri/src-tauri/src/peer_cache.rs b/linux/ui-tauri/src-tauri/src/peer_cache.rs index a2be934..8f53ef2 100644 --- a/linux/ui-tauri/src-tauri/src/peer_cache.rs +++ b/linux/ui-tauri/src-tauri/src/peer_cache.rs @@ -21,11 +21,20 @@ use std::path::PathBuf; -/// `~/.cache/vortex` — the shared root (notes, clipboard, icons live here). +/// The shared cache root (notes, clipboard, icons live here too). +/// +/// Through the platform seam, NOT `$HOME`. `$HOME` is a Unix variable Windows +/// does not set, so this returned `None` there — and because every path below +/// is built on it, that silently disabled the entire per-peer cache: SMS, +/// contacts and call-log history could not be read or written, so the bulk-sync +/// hash gate never matched and the phone re-sent its whole history on every +/// 12-second heartbeat. About a megabyte each time, forever. +/// +/// The seam resolves to `$XDG_CACHE_HOME/vortex` (`~/.cache/vortex` by default) +/// on Linux — the same path this used to hardcode — and +/// `%LOCALAPPDATA%\Vortex\Cache` on Windows. fn cache_root() -> Option { - let mut p = PathBuf::from(std::env::var_os("HOME")?); - p.push(".cache/vortex"); - Some(p) + vortex_l3_daemon::core::platform::paths().cache() } /// Directory for the active peer's caches, created if absent. diff --git a/linux/ui-tauri/src-tauri/src/voice_settings.rs b/linux/ui-tauri/src-tauri/src/voice_settings.rs index d7bdb12..47b2031 100644 --- a/linux/ui-tauri/src-tauri/src/voice_settings.rs +++ b/linux/ui-tauri/src-tauri/src/voice_settings.rs @@ -8,8 +8,26 @@ use std::io::Write; use std::path::PathBuf; fn bridge_path() -> Option { - let home = std::env::var_os("HOME")?; - Some(PathBuf::from(home).join(".local/share/vortex/voice/lang")) + // Linux keeps its exact path: this file is a BRIDGE, read by the voice + // assistant scripts outside this process, so moving it would break a + // consumer that knows nothing about the seam. Same reasoning as + // `file_consent::flag_path`. + #[cfg(target_os = "linux")] + { + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".local/share/vortex/voice/lang")) + } + // Everywhere else, through the seam. `$HOME` is unset on Windows, so this + // resolved to `None` and the language could be neither read nor written. + #[cfg(not(target_os = "linux"))] + { + Some( + vortex_l3_daemon::core::platform::paths() + .config()? + .join("voice") + .join("lang"), + ) + } } /// Persist the active language (en/ru/uz) for the voice assistant. Written From a8a474802792c3e6c1ea3e6c87b500af6e5bf4b9 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 12:42:20 +0200 Subject: [PATCH 57/71] fix(windows): the heap corruption that killed the first run, and a white-on-white tray icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash is identified. Event Viewer gives exception code 0xc0000374 — STATUS_HEAP_CORRUPTION — faulting in ntdll, 30 ms after the log's last line. Heap corruption, not a panic, which is why the panic hook logged nothing. `register_aumid_shortcut` built a PROPVARIANT by hand and pointed its VT_LPWSTR at a Rust `Vec`. A PROPVARIANT is an OWNING value by COM convention: whoever holds one may call PropVariantClear, which for VT_LPWSTR is CoTaskMemFree(pwszVal). So the shell was handed a Rust heap block to free with the COM allocator, and the Vec then freed the same block again on the way out. The old comment argued this was safe because the struct has no Drop — true, and beside the point: the hazard is the callee's clear, not ours. Three things pin it to this function. It creates the shortcut exactly once per machine and skips on every later run, which is the observed "crashed once, fine afterwards" shape. Its success line is the last new thing in the log before the fault. And the toast-permission prompt appeared on the SECOND run, confirming run 1 reached shortcut creation and run 2 did not. Fixed by allocating the string with CoTaskMemAlloc and clearing the variant ourselves — one allocator throughout, one free, on both the success and failure paths. Also: the tray icon is invisible on Windows because it is pure white (mean RGB 254,254,254), shipped that way deliberately for Linux, where the GNOME/Ubuntu top bar is dark even in light mode and SNI hosts do not recolor. Windows 11's taskbar is light by default and does not recolor either, so the entry was present and clickable with nothing drawn in it. Windows now gets the full-colour 64px brand icon, which is the convention there anyway; Linux is untouched. Verified: 57 + 205 tests, both targets check clean, and the Linux app restarts on the new tray path. The Windows fixes are reasoned from the crash dump and the API contract — neither has run. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/core/platform/windows/notify.rs | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/linux/daemon/src/core/platform/windows/notify.rs b/linux/daemon/src/core/platform/windows/notify.rs index 422d10b..7f15030 100644 --- a/linux/daemon/src/core/platform/windows/notify.rs +++ b/linux/daemon/src/core/platform/windows/notify.rs @@ -77,9 +77,11 @@ pub const AUMID: &str = "com.vortex.desktop"; pub fn register_aumid_shortcut() { use std::os::windows::ffi::OsStrExt; use windows::core::{Interface, GUID, PCWSTR, PWSTR}; - use windows::Win32::Foundation::PROPERTYKEY; - use windows::Win32::System::Com::StructuredStorage::PROPVARIANT; - use windows::Win32::System::Com::{CoCreateInstance, IPersistFile, CLSCTX_INPROC_SERVER}; + use windows::Win32::Foundation::{E_OUTOFMEMORY, PROPERTYKEY}; + use windows::Win32::System::Com::StructuredStorage::{PropVariantClear, PROPVARIANT}; + use windows::Win32::System::Com::{ + CoCreateInstance, CoTaskMemAlloc, IPersistFile, CLSCTX_INPROC_SERVER, + }; use windows::Win32::System::Variant::VT_LPWSTR; use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore; use windows::Win32::UI::Shell::{IShellLinkW, ShellLink, FOLDERID_Programs}; @@ -112,7 +114,7 @@ pub fn register_aumid_shortcut() { let exe_w: Vec = exe.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); let link_w: Vec = link_path.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); - let mut aumid_w: Vec = AUMID.encode_utf16().chain(std::iter::once(0)).collect(); + let aumid_w: Vec = AUMID.encode_utf16().chain(std::iter::once(0)).collect(); let result = (|| -> windows::core::Result<()> { // SAFETY: a straight-line COM sequence — create the shell-link object, @@ -126,19 +128,43 @@ pub fn register_aumid_shortcut() { link.SetDescription(&HSTRING::from("Vortex"))?; // PROPVARIANT has no safe constructor in this crate version, so it - // is assembled by hand: a VT_LPWSTR tag plus a borrowed pointer to - // the string. `SetValue` copies the value into the store, so the - // buffer only has to survive the call — and because the struct has - // no `Drop`, letting it fall out of scope cannot free a pointer we - // still own. + // is assembled by hand — but the string it points at MUST come from + // the COM allocator. + // + // A PROPVARIANT is an OWNING value by COM convention: whoever holds + // one may call `PropVariantClear`, and for a `VT_LPWSTR` that means + // `CoTaskMemFree(pwszVal)`. Pointing it at a Rust `Vec`, as + // this did, hands a Rust heap block to the COM allocator — and then + // the `Vec` frees the same block again on the way out. That is + // heap corruption, and it is what killed the first Windows run + // (`STATUS_HEAP_CORRUPTION`, 0xc0000374, faulting in ntdll) + // milliseconds after this function first created the shortcut. It + // could only ever happen once per machine, because the existence + // check above skips all of this on every later run — which is + // exactly the "crashed once, fine afterwards" shape it had. + // + // So: allocate with `CoTaskMemAlloc`, and clear the variant + // ourselves when we are done. One allocator throughout, one free. + let bytes = std::mem::size_of_val(&aumid_w[..]); + let com_str = CoTaskMemAlloc(bytes) as *mut u16; + if com_str.is_null() { + return Err(windows::core::Error::from(E_OUTOFMEMORY)); + } + std::ptr::copy_nonoverlapping(aumid_w.as_ptr(), com_str, aumid_w.len()); + let mut pv = PROPVARIANT::default(); { let inner = &mut *pv.Anonymous.Anonymous; inner.vt = VT_LPWSTR; - inner.Anonymous.pwszVal = PWSTR(aumid_w.as_mut_ptr()); + inner.Anonymous.pwszVal = PWSTR(com_str); } let store: IPropertyStore = link.cast()?; - store.SetValue(&PKEY_APPUSERMODEL_ID, &pv)?; + // Clear on BOTH paths: `SetValue` copies the value, so the buffer is + // ours to release whether it succeeded or not. `PropVariantClear` + // zeroes the variant too, so the later drop cannot double-free. + let set = store.SetValue(&PKEY_APPUSERMODEL_ID, &pv); + let _ = PropVariantClear(&mut pv); + set?; store.Commit()?; let file: IPersistFile = link.cast()?; From be70a1ab7174ecd232be8eb2b49b76c55ea9ca33 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 13:44:05 +0200 Subject: [PATCH 58/71] fix(multi-peer): connected is not active, on the phone side too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The laptop has always kept "a link exists" apart from "this peer owns the session" (design doc §D4). The phone did not: `activePeerPub` was set by whichever laptop completed BLE-IK last, unconditionally. That is worse than it sounds, because the presence provider deliberately advertises every remembered peer EXCEPT the active one. So a second laptop in range is actively INVITED to connect, and on arrival it took ownership. With two laptops up, ownership ping-ponged: pick one in the phone's UI and the other reconnected seconds later and took it straight back — exactly the reported "connects for a few sec then switches back to Gaia". Ownership now moves only when there is nothing to displace, when it is the same laptop reconnecting, when the user tapped this one (a targeted seek), when an untargeted seek picks the first answer, or when the owner has really gone. Everything else may hold a link and get no ownership with it. "Really gone" needs a grace period, not a disconnect event. Surrendering ownership the moment the link dropped would hand the session to whichever other laptop reconnected first, and a two-second flap is enough — likely on Windows, whose BLE link has already been seen to drop mid-handshake. So a dropped owner keeps the session for OWNERSHIP_GRACE_MS (20 s) and only then becomes displaceable, which is the walk-away case. Tapping the device in the UI bypasses the wait entirely: an explicit choice should not queue behind a timer. One coupling worth naming: the presence provider now suppresses the owner's token only while it is actually LINKED. Keeping ownership across a drop without that change would have meant never advertising to the laptop we still consider the owner — it could never have found us again. Also, minor: the Windows app called itself a "Linux laptop" on its own card. One frontend bundle ships to every platform, so it cannot know at build time; `ipc::host_platform` tells it. Verified: Kotlin compiles, Android unit tests pass, both Rust targets check clean, 57 + 205 tests. On the device, the phone's service came back up with the new APK, re-linked with the Linux laptop and took ownership normally — the single-laptop path is unregressed. The two-laptop case cannot be tested from here; it needs both machines. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/vortex/a3/core/ble/GattServer.kt | 9 ++ .../java/com/vortex/a3/service/VortexStack.kt | 89 +++++++++++++++++-- linux/ui-tauri/src-tauri/src/ipc.rs | 10 +++ linux/ui-tauri/src-tauri/src/lib.rs | 1 + linux/ui-tauri/src/lib/locales/en.json | 1 + linux/ui-tauri/src/lib/locales/ru.json | 1 + linux/ui-tauri/src/lib/locales/uz.json | 1 + linux/ui-tauri/src/pages/home/Devices.vue | 19 +++- 8 files changed, 122 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt index 1609db2..dbcefb5 100644 --- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt @@ -459,6 +459,15 @@ class GattServer( Log.i(TAG, "registered audio session for peer=${peerHex.take(8)}… device=${device.address}") } + /** Which peer a connected device authenticated as, or null if IK has not + * completed on it. + * + * The disconnect hook hands back a [BluetoothDevice], and the caller + * usually needs the identity behind it — an address is an RPA and means + * nothing on its own. */ + fun peerPubFor(device: BluetoothDevice): ByteArray? = + deviceToPeerPub[device.address]?.copyOf() + /** Drop the audio session for a peer (call on un-trust). Safe to * call repeatedly — the maps tolerate missing keys. */ fun forgetAudioSession(peerStaticPub: ByteArray) { diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index 333c61e..9adc57c 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -81,6 +81,17 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * removes one candidate from the list. */ @Volatile internal var activePeerPub: ByteArray? = null + + /** + * When the active peer's BLE link dropped, or 0 while it is up. + * + * Ownership has to outlive a blip. Clearing [activePeerPub] the moment its + * link went would hand the session to whichever other laptop reconnected + * first — and with a second laptop sitting in range, a two-second flap is + * enough. So ownership is kept and only becomes available to someone else + * after [OWNERSHIP_GRACE_MS] of real absence, which is the walk-away case. + */ + @Volatile internal var activeLostAtMs: Long = 0L internal var gattServer: GattServer? = null /** Open read handles held for the current peer's filesystem session, so * they can be dropped when the link goes. Null until [startFsServer]. */ @@ -704,7 +715,19 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // BLE session gone → mDNS discovery matters again; re-hold the // multicast lock so the laptop can find us over LAN. Also drop any // pending mirror burst — a flapping session must not queue storms. - server.onPeerDisconnected = { _ -> + server.onPeerDisconnected = { device -> + // If the laptop that owned the session is the one that just went, + // ownership is vacant again — otherwise the phone would stay bound + // to a laptop that has left the room, and no other could ever take + // over now that connecting alone no longer confers ownership. + // These two rules are a pair: the walk-away case is exactly what + // used to be served by promoting whoever connected next. + server.peerPubFor(device)?.let { gone -> + if (activePeerPub?.contentEquals(gone) == true && activeLostAtMs == 0L) { + Log.i(TAG, "active laptop disconnected — ownership held for now") + activeLostAtMs = android.os.SystemClock.elapsedRealtime() + } + } mirrorRefreshJob?.cancel() lanServer?.setBleLinked(false) // Handles belong to the session that opened them: the ids mean @@ -792,14 +815,47 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { ) val peerPub = outcome.peerStaticPub.copyOf() val previousPeer = activePeerPub - activePeerPub = peerPub + val seeking = advertiser?.seeking == true + val sameAsActive = previousPeer?.contentEquals(peerPub) == true + // CONNECTED IS NOT ACTIVE (design doc §D4). The laptop side has + // always kept those apart; this side did not, and simply promoted + // whichever laptop completed IK last. + // + // That is worse than it sounds, because the presence provider below + // deliberately advertises every peer EXCEPT the active one — so a + // second remembered laptop in range is actively invited to connect, + // and on arrival it took ownership. With two laptops up, ownership + // ping-ponged: pick one in the UI, and the other reconnected + // seconds later and took it straight back. + // + // Ownership now moves only when there is nothing to displace, when + // it is the same laptop reconnecting, or when the user asked for + // this one by tapping it (a targeted seek). Everything else may + // hold a link and get no ownership with it. + val chosen = seekTarget?.contentEquals(peerPub) == true + // The owner has been gone long enough to have really left, rather + // than flapped — see [activeLostAtMs]. + val ownerGone = activeLostAtMs != 0L && + android.os.SystemClock.elapsedRealtime() - activeLostAtMs > OWNERSHIP_GRACE_MS + val takeOver = previousPeer == null || sameAsActive || chosen || ownerGone || + // An untargeted seek ("switch to any other laptop") has no + // named destination, so the first to answer IS the choice. + (seeking && seekTarget == null) + if (takeOver) { + activePeerPub = peerPub + activeLostAtMs = 0L + } else { + Log.i( + TAG, + "peer ${peerPub.take(4).joinToString("") { "%02x".format(it) }}… " + + "linked but NOT active — another laptop owns the session", + ) + } // A DIFFERENT laptop just completed IK while we were seeking — // that is the switch succeeding, so close the window. Ownership // has effectively moved; the old link drops on its own (the // laptop side stops being active the moment it hands over). - if (advertiser?.seeking == true && - (previousPeer == null || !previousPeer.contentEquals(peerPub)) - ) { + if (seeking && takeOver && !sameAsActive) { Log.i(TAG, "seek satisfied — another laptop connected") // Tell the laptop we just left, while its link is still up. // Ordering matters: this runs BEFORE stopSeeking() teardown so @@ -859,7 +915,10 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { if (kind == FrameSub.HANDOFF_RELEASE) { val who = if (successorName.isBlank()) "another phone" else successorName Log.i(TAG, "peer released us (now with $who) — resuming presence") - if (activePeerPub?.contentEquals(peerPub) == true) activePeerPub = null + if (activePeerPub?.contentEquals(peerPub) == true) { + activePeerPub = null + activeLostAtMs = 0L + } // A seek in flight is moot: the laptop already chose someone. stopSeeking() // Presence resumes on the next phase evaluation; kick it so we @@ -907,7 +966,11 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { return@provider all.filter { it.peerStaticPub.contentEquals(target) } .map { it.prs } } - val linkedPub = activePeerPub + // Suppress the owner's token only while it is actually LINKED — + // the live session is the presence proof, so beaconing at it is + // waste. Once its link drops we must advertise it again or the + // laptop we still consider the owner could never find us back. + val linkedPub = activePeerPub?.takeIf { activeLostAtMs == 0L } all.filter { linkedPub == null || !it.peerStaticPub.contentEquals(linkedPub) } .map { it.prs } } @@ -1270,6 +1333,18 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { companion object { internal const val TAG = "VortexStack" + + /** + * How long a disconnected owner keeps the session before another + * laptop may take it. + * + * Long enough to ride out a BLE flap — which is the difference between + * "my laptop blinked" and "I walked away" — and short enough that + * arriving at the other desk does not feel stuck. Tapping the device + * in the UI bypasses the wait entirely, because an explicit choice + * should never queue behind a timer. + */ + internal const val OWNERSHIP_GRACE_MS = 20_000L /** How long after losing the laptop link the phone keeps * advertising in LOW_LATENCY (reconnect-seeking) mode. */ internal const val FAST_ADV_WINDOW_MS = 10 * 60_000L diff --git a/linux/ui-tauri/src-tauri/src/ipc.rs b/linux/ui-tauri/src-tauri/src/ipc.rs index 5f628e8..b92efbd 100644 --- a/linux/ui-tauri/src-tauri/src/ipc.rs +++ b/linux/ui-tauri/src-tauri/src/ipc.rs @@ -175,6 +175,16 @@ fn peer_state_cache() -> &'static std::sync::Mutex &'static str { + std::env::consts::OS +} + /// Tauri command: pull the latest per-peer state over the invoke-response /// channel. The UI polls this every ~15s as a backstop to the pushed /// `vortex:peer_state` events — if those stop arriving, the poll keeps the diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 5ba2479..aee3680 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -603,6 +603,7 @@ pub fn run() { worker::start_scan, worker::refresh_state, ipc::get_peer_states, + ipc::host_platform, worker::start_screen_mirror, worker::stop_screen_mirror, pairing::start_pair, diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index e538e8d..77fc288 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -12,6 +12,7 @@ "device": { "this": "This device", "linux": "Linux laptop", + "windows": "Windows laptop", "android": "Android phone", "earbuds": "Earbuds", "paired_badge": "{count} paired", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index 1bc2667..36da95a 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -12,6 +12,7 @@ "device": { "this": "Это устройство", "linux": "Linux ноутбук", + "windows": "Ноутбук Windows", "android": "Android телефон", "earbuds": "Наушники", "paired_badge": "{count} сопряжено", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index dbb51a6..0cece4c 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -12,6 +12,7 @@ "device": { "this": "Bu qurilma", "linux": "Linux noutbuk", + "windows": "Windows noutbuki", "android": "Android telefon", "earbuds": "Quloqchin", "paired_badge": "{count} ta ulangan", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 12965bd..36b5b81 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -3,7 +3,7 @@ // laptop, the phone, the earbuds), translated from the Vortex design system. // Phone + earbuds are wired to live daemon state; the laptop card's battery and // mirror-to-phone action are follow-ups (no UI accessor yet). -import { ref, computed } from "vue"; +import { ref, computed, onMounted } from "vue"; import { useI18n } from "vue-i18n"; import { invoke } from "@tauri-apps/api/core"; import { @@ -46,6 +46,21 @@ import { peers } from "@/lib/connectionStore"; const { t } = useI18n(); +// This laptop's own OS, for its card's subtitle. One frontend bundle ships to +// every platform, so it has to be asked rather than known at build time — +// without it a Windows machine calls itself a Linux laptop. +const hostOs = ref("linux"); +onMounted(async () => { + try { + hostOs.value = await invoke("host_platform"); + } catch { + /* keep the default; a wrong label beats an empty card */ + } +}); +const thisDeviceKind = computed(() => + hostOs.value === "windows" ? t("device.windows") : t("device.linux"), +); + const connectedCount = computed( () => 1 + (phoneOnline.value ? 1 : 0) + (activeEarbuds.value?.connected ? 1 : 0), ); @@ -145,7 +160,7 @@ const earbudsStatus = computed(() => {
- {{ t("device.linux") }} + {{ thisDeviceKind }}
From dcd89e012e41f1926e1ce08bf902eb958734bbe4 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 14:01:13 +0200 Subject: [PATCH 59/71] =?UTF-8?q?fix(multi-peer):=20gate=20the=20LAN=20pat?= =?UTF-8?q?h=20on=20ownership=20too=20=E2=80=94=20that=20is=20where=20the?= =?UTF-8?q?=20dance=20was?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit put the connected-vs-active rule in the BLE reconnect listener. A live capture from the phone showed the flapping was pure LAN: 75 seconds of logcat with both laptops up contained not one BLE line, so that guard never ran. The actual mechanism is three lines deep and entirely on the phone: * `handlePeerAppState` writes `latestPeerState` — a SINGLE slot — from whichever laptop's heartbeat arrives, with no reference to ownership; * each laptop heartbeats about every twelve seconds; * the home screen picks the laptop to display by FRESHEST traffic (`peerLastSeen`), a heuristic for "which one is at my desk" that inverts the moment two are live. So the card alternated between them on a ~12 s cadence, exactly as reported. Nothing to do with BLE, and nothing the BLE-side rule could have reached. Ownership is now one decision — `considerOwnership` — and every transport defers to it. It moves only when nothing holds it, when the holder is the one calling, when the user picked this laptop in the UI, or when the holder has been silent for OWNERSHIP_GRACE_MS. A non-owner may hold a link, sync files and browse, and gets no ownership with it. Three supporting changes fall out: * Liveness is a last-contact timestamp, not a disconnect event. The two transports fail differently — BLE raises a disconnect, a LAN session is torn down and rebuilt every heartbeat by design — so "the socket closed" means nothing there. Last contact means the same on both. * The presence loop suppresses a peer's token based on a live GATT link (`GattServer.linkedPeerPubs`), not on ownership. They are no longer the same thing: a laptop can own the session over Wi-Fi with no BLE link at all, and suppressing its token then would leave it unable to find us on the one transport still working. * The post-IK "refresh the UI's last-seen" nudge is owner-only. It would otherwise put a non-owner straight back at the top of the freshest-traffic ordering — the same dance through another door — and it re-emits the OWNER's snapshot, so attributing it to another laptop drew that card with someone else's battery. Revoking trust stays ungated: a laptop dropping us is honoured whoever it is. Verified on the device with both laptops live: 95 s of logcat shows exactly one ownership change, and it is the user's own tap (`chosen=true`), where the same window before the fix flipped repeatedly. Confirmed by the user. Kotlin compiles, Android unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/vortex/a3/core/ble/GattServer.kt | 9 ++ .../java/com/vortex/a3/service/VortexStack.kt | 148 ++++++++++-------- .../vortex/a3/service/VortexStackAppState.kt | 26 ++- 3 files changed, 116 insertions(+), 67 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt index dbcefb5..1fb413f 100644 --- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt +++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt @@ -824,6 +824,15 @@ class GattServer( fun hasActiveConnection(): Boolean = connectedAddrs.isNotEmpty() + /** The peers holding a live GATT link right now, by static public key. + * + * Only peers that have completed IK appear — an address alone is an RPA + * and proves nothing about identity. Used by the presence loop to decide + * whose token there is no point beaconing at: a live session IS the + * presence proof, so advertising at it is pure radio waste. */ + fun linkedPeerPubs(): List = + connectedAddrs.mapNotNull { addr -> deviceToPeerPub[addr]?.copyOf() } + /** * True when a peer has SUBSCRIBED to AUDIO_SIGNAL, i.e. the notify path is * actually deliverable. diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index 9adc57c..a4213ba 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -83,15 +83,19 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { @Volatile internal var activePeerPub: ByteArray? = null /** - * When the active peer's BLE link dropped, or 0 while it is up. + * When the owner was last heard from, on ANY transport. * - * Ownership has to outlive a blip. Clearing [activePeerPub] the moment its - * link went would hand the session to whichever other laptop reconnected - * first — and with a second laptop sitting in range, a two-second flap is - * enough. So ownership is kept and only becomes available to someone else - * after [OWNERSHIP_GRACE_MS] of real absence, which is the walk-away case. + * Liveness is a timestamp rather than a disconnect event because the two + * transports fail differently: a BLE link raises a disconnect, while a LAN + * session is torn down and rebuilt every heartbeat by design, so "the + * socket closed" says nothing at all there. Last contact is the one signal + * that means the same thing on both. + * + * Ownership then outlives a blip: it becomes available to another laptop + * only after [OWNERSHIP_GRACE_MS] of silence, which is the walk-away case + * rather than the flap. */ - @Volatile internal var activeLostAtMs: Long = 0L + @Volatile internal var activeSeenAtMs: Long = 0L internal var gattServer: GattServer? = null /** Open read handles held for the current peer's filesystem session, so * they can be dropped when the link goes. Null until [startFsServer]. */ @@ -128,6 +132,53 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { * one" (design doc §D1). */ @Volatile internal var seekTarget: ByteArray? = null + + /** + * Does [peerPub] own this phone's session — and take it if it may? + * + * CONNECTED IS NOT ACTIVE (design doc §D4). The laptop side has always kept + * those apart; this side did not, and every path that heard from a laptop + * simply treated it as the current one. With two laptops up, that made the + * phone flip between them: the BLE loop promoted whoever completed IK last, + * and [handlePeerAppState] overwrote the UI's single peer slot from + * whichever LAN heartbeat landed most recently — about one every twelve + * seconds, from each. + * + * Ownership moves only when: + * - nothing holds it; + * - the holder is the one calling (it refreshes its own claim); + * - the user picked this laptop in the UI (a targeted seek), or an + * untargeted seek is running and this is the first to answer; + * - the holder has been silent for [OWNERSHIP_GRACE_MS]. + * + * Everything else may hold a link, sync files, and get no ownership with + * it. Returns true when the caller owns the session after this call. + */ + internal fun considerOwnership(peerPub: ByteArray): Boolean { + val now = android.os.SystemClock.elapsedRealtime() + val current = activePeerPub + if (current == null || current.contentEquals(peerPub)) { + activePeerPub = peerPub.copyOf() + activeSeenAtMs = now + return true + } + val chosen = seekTarget?.contentEquals(peerPub) == true + // An untargeted seek ("switch to any other laptop") names no + // destination, so the first to answer IS the choice. + val anySeek = advertiser?.seeking == true && seekTarget == null + val ownerGone = activeSeenAtMs != 0L && now - activeSeenAtMs > OWNERSHIP_GRACE_MS + if (chosen || anySeek || ownerGone) { + Log.i( + TAG, + "session ownership → ${peerPub.toHexPrefix()} " + + "(chosen=$chosen seek=$anySeek ownerSilent=$ownerGone)", + ) + activePeerPub = peerPub.copyOf() + activeSeenAtMs = now + return true + } + return false + } /** Icon PNG bytes per ICON frame chunk (kept under the BLE notify MTU * once the appId header + AEAD tag + frame header are added). */ internal val ICON_CHUNK = 180 @@ -716,18 +767,10 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // multicast lock so the laptop can find us over LAN. Also drop any // pending mirror burst — a flapping session must not queue storms. server.onPeerDisconnected = { device -> - // If the laptop that owned the session is the one that just went, - // ownership is vacant again — otherwise the phone would stay bound - // to a laptop that has left the room, and no other could ever take - // over now that connecting alone no longer confers ownership. - // These two rules are a pair: the walk-away case is exactly what - // used to be served by promoting whoever connected next. - server.peerPubFor(device)?.let { gone -> - if (activePeerPub?.contentEquals(gone) == true && activeLostAtMs == 0L) { - Log.i(TAG, "active laptop disconnected — ownership held for now") - activeLostAtMs = android.os.SystemClock.elapsedRealtime() - } - } + // Deliberately does NOT touch ownership. A dropped BLE link is not + // evidence the laptop has gone — it may still be right there on + // Wi-Fi — so the owner is timed out on silence instead + // ([activeSeenAtMs]), which is the one signal both transports share. mirrorRefreshJob?.cancel() lanServer?.setBleLinked(false) // Handles belong to the session that opened them: the ids mean @@ -817,39 +860,9 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { val previousPeer = activePeerPub val seeking = advertiser?.seeking == true val sameAsActive = previousPeer?.contentEquals(peerPub) == true - // CONNECTED IS NOT ACTIVE (design doc §D4). The laptop side has - // always kept those apart; this side did not, and simply promoted - // whichever laptop completed IK last. - // - // That is worse than it sounds, because the presence provider below - // deliberately advertises every peer EXCEPT the active one — so a - // second remembered laptop in range is actively invited to connect, - // and on arrival it took ownership. With two laptops up, ownership - // ping-ponged: pick one in the UI, and the other reconnected - // seconds later and took it straight back. - // - // Ownership now moves only when there is nothing to displace, when - // it is the same laptop reconnecting, or when the user asked for - // this one by tapping it (a targeted seek). Everything else may - // hold a link and get no ownership with it. - val chosen = seekTarget?.contentEquals(peerPub) == true - // The owner has been gone long enough to have really left, rather - // than flapped — see [activeLostAtMs]. - val ownerGone = activeLostAtMs != 0L && - android.os.SystemClock.elapsedRealtime() - activeLostAtMs > OWNERSHIP_GRACE_MS - val takeOver = previousPeer == null || sameAsActive || chosen || ownerGone || - // An untargeted seek ("switch to any other laptop") has no - // named destination, so the first to answer IS the choice. - (seeking && seekTarget == null) - if (takeOver) { - activePeerPub = peerPub - activeLostAtMs = 0L - } else { - Log.i( - TAG, - "peer ${peerPub.take(4).joinToString("") { "%02x".format(it) }}… " + - "linked but NOT active — another laptop owns the session", - ) + val takeOver = considerOwnership(peerPub) + if (!takeOver) { + Log.i(TAG, "${peerPub.toHexPrefix()} linked but NOT active — another laptop owns the session") } // A DIFFERENT laptop just completed IK while we were seeking — // that is the switch succeeding, so close the window. Ownership @@ -886,8 +899,18 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { // known snapshot) instead of waiting ~1.5s for the laptop's // first state heartbeat. Without this the laptop UI flips to // "connected" visibly earlier than the phone after a reconnect. - latestPeerState?.let { st -> - VortexService.peerStateBus.tryEmit(peerPub.toHex() to st) + // + // Owner only, for two reasons. The home screen picks the laptop to + // show by FRESHEST traffic, so nudging a laptop that does not own + // the session would put it straight back at the top of that + // ordering — the dance, re-entered by another door. And + // `latestPeerState` is the OWNER's snapshot: attributing it to a + // different laptop would draw that one's card with someone else's + // battery. + if (takeOver) { + latestPeerState?.let { st -> + VortexService.peerStateBus.tryEmit(peerPub.toHex() to st) + } } } @@ -917,7 +940,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { Log.i(TAG, "peer released us (now with $who) — resuming presence") if (activePeerPub?.contentEquals(peerPub) == true) { activePeerPub = null - activeLostAtMs = 0L + activeSeenAtMs = 0L } // A seek in flight is moot: the laptop already chose someone. stopSeeking() @@ -966,12 +989,15 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { return@provider all.filter { it.peerStaticPub.contentEquals(target) } .map { it.prs } } - // Suppress the owner's token only while it is actually LINKED — - // the live session is the presence proof, so beaconing at it is - // waste. Once its link drops we must advertise it again or the - // laptop we still consider the owner could never find us back. - val linkedPub = activePeerPub?.takeIf { activeLostAtMs == 0L } - all.filter { linkedPub == null || !it.peerStaticPub.contentEquals(linkedPub) } + // Suppress a token only for a peer holding a LIVE GATT link: that + // session IS the presence proof, so beaconing at it is waste. + // + // Keyed on the link, NOT on ownership. They are no longer the same + // thing — a laptop can own the session over Wi-Fi with no BLE link + // at all, and suppressing its token then would leave it unable to + // find us on the one transport that still works. + val linked = server.linkedPeerPubs() + all.filter { p -> linked.none { it.contentEquals(p.peerStaticPub) } } .map { it.prs } } if (peerStore.list().isNotEmpty()) { diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt index 49a264a..72861d0 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackAppState.kt @@ -139,6 +139,26 @@ private fun currentWifiIp(): String? = try { * notification, honour a bidirectional forget, run the initiator on a * claim request, and release the buds when the laptop starts playing. */ internal fun VortexStack.handlePeerAppState(peerPub: ByteArray, state: com.vortex.a3.core.appstate.AppState) { + // Being dropped is honoured from ANY laptop — revoking trust is not an + // ownership-gated act, and a laptop that has just forgotten us has nothing + // else worth saying. + if (state.revoked) { + Log.i(VortexStack.TAG, "peer revoked us; forgetting ${peerPub.toHexPrefix()}") + peerStore.forget(peerPub) + VortexService.revokedByPeerBus.tryEmit(peerPub.toHex()) + return + } + // Everything below belongs to whichever laptop OWNS the session: the card + // the UI draws, the media hand-off, the camera, the cast, the ring. + // + // This is where the peer dance actually lived. `latestPeerState` is a + // single slot, and every laptop's heartbeat overwrote it — roughly one + // every twelve seconds, from each — so with two laptops up the phone's + // idea of "the laptop" alternated between them no matter what the BLE + // ownership rules said. The earlier fix guarded the BLE path alone; the + // observed flapping was pure LAN and never went near it. + if (!considerOwnership(peerPub)) return + VortexService.peerStateBus.tryEmit(peerPub.toHex() to state) latestPeerState = state latestPeerStateAtMs = android.os.SystemClock.elapsedRealtime() @@ -209,12 +229,6 @@ internal fun VortexStack.handlePeerAppState(peerPub: ByteArray, state: com.vorte state.dnd, state.dndChangedAt, ) - // Bidirectional forget — peer asked us to drop their trust. - if (state.revoked) { - Log.i(VortexStack.TAG, "peer revoked us; forgetting ${peerPub.toHexPrefix()}") - peerStore.forget(peerPub) - VortexService.revokedByPeerBus.tryEmit(peerPub.toHex()) - } // Peer holds the buds and is asking us to claim them. We become the // initiator. The orchestrator drops the request if a flow is already // in progress, so this is idempotent across repeated heartbeats — From 0d3687c477f2322f1e14307d2de62132ab93cab0 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 14:13:29 +0200 Subject: [PATCH 60/71] fix(windows): let the LAN heartbeat relax when BLE is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adaptive cadence asks "is the BLE link down?" and answers 12 s if so, 240 s if not — BLE already carries liveness, state pushes and the call signal, so with it up the LAN tick only has to keep the cached-IP fast path warm. Off Linux that question was a bare `true`: BLE is always down. So a Windows laptop never reached the 240 s branch and paid a fresh TCP connect plus a full Noise IK every twelve seconds, waking the phone's Wi-Fi each time. Measured from the phone with both laptops live: nine handshakes in 95 s from Windows against two from Linux. The reason it was hard-coded is real — Linux answers from its BLE-audio session map, and that map does not exist off Linux because earbuds hand-off does not. So the portable loop now keeps its own flag, set where the link comes up and cleared once after `connect_and_run` returns rather than at each exit, so no future escape route can leave it stuck on. Stuck-on is the dangerous direction, which is why the second half matters more than the first: the 240 s sleep is only safe because the BLE loop wakes the heartbeat the moment its link drops. The BlueZ loop has always done that; the portable one never needed to, because its cadence never relaxed. It does now — without it this change would turn a waste into a real regression, with the phone looking offline for up to four minutes after a BLE drop. Also pairs `note_disconnected` with the `note_connected` added earlier, so the arbiter's connected set stops growing stale on this side. Verified: both targets check clean, 57 tests, and the Linux app reinstalled and relinked — its cadence is unchanged, being on the other side of the cfg. The Windows half needs the next build to confirm. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/ble_portable.rs | 40 +++++++++++++++++++- linux/ui-tauri/src-tauri/src/lan.rs | 13 ++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/ble_portable.rs b/linux/ui-tauri/src-tauri/src/ble_portable.rs index 03a9bdf..246c388 100644 --- a/linux/ui-tauri/src-tauri/src/ble_portable.rs +++ b/linux/ui-tauri/src-tauri/src/ble_portable.rs @@ -95,6 +95,23 @@ const BACKOFF_SECS: [u64; 5] = [2, 5, 15, 30, 60]; /// catch the phone's advertising interval a few times over. const SCAN_MS: u64 = 8_000; +/// Whether this loop currently holds a GATT link to the phone. +/// +/// The LAN heartbeat's cadence keys off it: with BLE up, BLE already carries +/// liveness, state pushes and the call signal, so the LAN tick only has to keep +/// the cached-IP fast path warm and can drop from 12 s to 4 minutes. +/// +/// The Linux loop answers the same question from its BLE-audio session map, +/// which does not exist here — earbuds hand-off is Linux-only. That map being +/// the only implementation is why this side was hard-coded to "BLE is down" and +/// paid a full TCP + Noise IK every twelve seconds forever. +static LINK_UP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Whether the portable BLE loop currently holds a link. See [`LINK_UP`]. +pub(crate) fn link_is_up() -> bool { + LINK_UP.load(std::sync::atomic::Ordering::Relaxed) +} + /// Keep a BLE link to the trusted peer up, forever. /// /// Returns only if there is no way to proceed at all; every transient failure @@ -154,7 +171,16 @@ pub(crate) async fn run_portable_ble_loop( continue; } - match connect_and_run(¢ral, &identity, &peer_store, &peer, &sinks, &writers).await { + let outcome = connect_and_run(¢ral, &identity, &peer_store, &peer, &sinks, &writers).await; + // Down before anything else looks: every exit from `connect_and_run` — + // clean drop, listener error, a failure before the link was ever up — + // means we hold no link now. Clearing it here rather than at each + // return site is what stops a new escape route leaving the flag stuck + // on, which would park the LAN heartbeat at 4 minutes with no BLE to + // cover the gap. + LINK_UP.store(false, std::sync::atomic::Ordering::Relaxed); + crate::arbiter::note_disconnected(&peer.peer_static_pub); + match outcome { Ok(()) => { // A clean return means the link dropped, which is normal — the // phone moved, slept, or restarted. Reconnect promptly. @@ -171,6 +197,17 @@ pub(crate) async fn run_portable_ble_loop( // must see "no link" rather than push into a torn-down one. clear_writers(&writers).await; + // Wake the LAN heartbeat NOW. With BLE down it is the only liveness and + // hand-off path again, and it is now allowed to sleep 4 minutes while + // BLE is up — so without this nudge a dropped link would leave the + // phone looking offline for minutes. The two changes are a pair: the + // relaxed cadence is only safe because this fires. (The BlueZ loop has + // always done it; this one had nothing to wake, because its cadence + // never relaxed.) + if let Some(n) = crate::SYNC_NUDGE.get() { + n.notify_one(); + } + let wait = BACKOFF_SECS[consec_fail.min(BACKOFF_SECS.len() - 1)]; tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(wait)) => {} @@ -294,6 +331,7 @@ async fn connect_and_run( ); } tracing::info!(addr = %addr, "BLE link established"); + LINK_UP.store(true, std::sync::atomic::Ordering::Relaxed); crate::presence::touch_presence(); crate::presence::touch_peer_contact(); publish_writers(&link, &transport, writers).await; diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 363a488..b5ad160 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -1455,10 +1455,21 @@ pub(crate) fn spawn_heartbeat( Duration::from_secs(12) } } else if { + // "Is the BLE link down?" — asked of whichever loop is + // running. Linux reads its BLE-audio session map; + // elsewhere the portable loop keeps its own flag, + // because that map is Linux-only (earbuds hand-off is). + // + // This used to be a bare `true` off Linux, i.e. "BLE is + // always down", so a Windows laptop never reached the + // 240 s branch and paid a fresh TCP connect + full + // Noise IK every twelve seconds — measured at nine + // handshakes in 95 s against the Linux machine's two, + // with the phone's Wi-Fi woken for each one. #[cfg(target_os = "linux")] { auto_ble_writers.lock().await.is_empty() } #[cfg(not(target_os = "linux"))] - { true } + { !crate::ble_portable::link_is_up() } } { Duration::from_secs(12) } else { From c172c2e82d0cc6e4682b6dbd13797e32272a9400 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 14:19:57 +0200 Subject: [PATCH 61/71] fix(ui): keep the settings header out from under the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit targetSdk 36 makes edge-to-edge mandatory, so the app draws behind the status bar whether it asks to or not. The settings header sat in the same band as the clock, where the system consumes the touch — the back arrow rendered fine and simply did not respond, which reads as a broken button rather than a mispositioned one. Same fix and same reasoning as the home and laptop-files screens. Background before padding, so the status bar still sits on our colour rather than a bare strip. That completes the sweep: Home, Laptop files and Settings each build on a bare Column and now carry `systemBarsPadding()`; Notes needs nothing because it is built on Material3's Scaffold + TopAppBar, which applies the inset itself. I had twice offered to "fix Notes too" on the assumption it shared the defect — it does not, and checking was overdue. Verified on the device by the user. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/vortex/a3/ui/screens/SettingsScreen.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt index 6e99e9c..b3dba18 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -108,7 +109,17 @@ fun SettingsScreen( onBack: () -> Unit, ) { Column( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + // targetSdk 36 makes edge-to-edge mandatory: the app draws behind + // the status bar whether it asks to or not, so this header sat in + // the same band as the clock, where the system consumes the touch. + // The back arrow rendered fine and simply did not respond, which + // reads as a broken button rather than a mispositioned one. + // Background BEFORE padding, so the status bar still sits on our + // colour instead of a bare strip. + .systemBarsPadding(), ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), From 8f9875d4d647b2ef81fcf7e533d154905ec88a8b Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 17:45:10 +0200 Subject: [PATCH 62/71] fix(windows): give the portable BLE loop a state beat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression from the previous commit, and my fault. Relaxing the LAN heartbeat to 4 minutes while BLE is up rests on "BLE carries liveness" — true of the BlueZ loop, which has always pushed AppState every 12 s, and false of the portable one, which pushed nothing at all. So the only thing telling the phone this laptop existed was the LAN tick I had just slowed by 20x. The phone greys a laptop out after LAPTOP_STALE_MS (30 s) without contact, so it showed "disconnected" while being perfectly connected — and file browsing over the same link kept working, because that path never touches AppState. Exactly the shape reported. The portable loop now beats every 12 s for the life of the link, carrying the subset of state this platform can produce: lock state through the seam, the cast offer, the camera request, the ring sequence and now-playing. Earbuds are left out because the hand-off is Linux-only. A successful write also refreshes presence and peer-contact, which are the liveness signals other subsystems gate on. I had guessed the wrong failure mode when I shipped the cadence change: the README warned about the drop nudge, and the hole was the steady-state beat. Worth remembering that "BLE carries liveness" was an assumption inherited from a comment written about the other loop. Verified: both targets check clean, 57 tests. The phone-side evidence that found it — 75 s of logcat from the affected phone showing no traffic from the Windows laptop at all, while the Linux one hammered it with correctly-rejected handshakes — is what a 12 s beat should now fill. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/ble_portable.rs | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/linux/ui-tauri/src-tauri/src/ble_portable.rs b/linux/ui-tauri/src-tauri/src/ble_portable.rs index 246c388..5f96872 100644 --- a/linux/ui-tauri/src-tauri/src/ble_portable.rs +++ b/linux/ui-tauri/src-tauri/src/ble_portable.rs @@ -336,6 +336,9 @@ async fn connect_and_run( crate::presence::touch_peer_contact(); publish_writers(&link, &transport, writers).await; + // The liveness beat. Runs until the listener below returns. + let beat = tokio::spawn(state_beat(link.clone(), transport.clone())); + // Runs until the phone stops notifying — a disconnect, or the cipher // desync escalation dropping the session on purpose. let r = audio_signal::run_listener( @@ -361,6 +364,7 @@ async fn connect_and_run( Some(sinks.raw.clone()), ) .await; + beat.abort(); let _ = link.disconnect().await; return match r { Ok(()) => Ok(()), @@ -423,6 +427,68 @@ async fn publish_writers( } } +/// Push this laptop's AppState to the phone every [`STATE_BEAT`], for as long +/// as the link lives. +/// +/// This is the liveness signal, not just a state push. The phone's home screen +/// greys the laptop out after `LAPTOP_STALE_MS` (30 s) without hearing from it, +/// and while BLE is up the LAN heartbeat deliberately relaxes to 4 minutes +/// because "BLE carries liveness" — which was true of the BlueZ loop, which has +/// always had a 12 s beat, and false here, which had none. Relaxing the cadence +/// without this left a phone that was perfectly connected showing +/// "disconnected" between LAN ticks, while file browsing over the same link +/// carried on working. The two are a pair; neither is correct alone. +/// +/// A failed write is not fatal on its own — GATT can refuse one transiently +/// just after a link comes up — so a few in a row are tolerated before giving +/// up and letting the loop reconnect. +async fn state_beat(link: Arc, transport: Arc>) { + /// Matches the BlueZ loop's beat, and sits inside the phone's 30 s + /// staleness window with room for a lost one. + const STATE_BEAT: std::time::Duration = std::time::Duration::from_secs(12); + const MAX_FAILS: u32 = 6; + + // Let the phone register its receive cipher first — it does that on its own + // IK callback, and a frame written before it lands is dropped without + // advancing the phone's recv nonce, after which every later frame fails to + // open. The BlueZ loop learned this the same way. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + let mut consecutive_fail = 0u32; + loop { + let mut state = vortex_l3_daemon::core::appstate::AppState::now_laptop(); + // Everything below is the portable subset of what the BlueZ beat sends. + // Earbuds are absent on purpose: the hand-off is Linux-only, and + // `now_laptop()` already leaves the field None. + state.locked = vortex_l3_daemon::core::platform::session().is_locked().await; + state.laptop_cast = crate::laptop_cast::current_offer(); + state.laptop_cast_error = crate::laptop_cast::current_error(); + state.camera_req = crate::camera::camera_wanted(); + state.camera_facing = crate::camera::camera_facing(); + state.ring_seq = crate::ring::ring_seq(); + crate::media_remote::fill_now_playing(&mut state).await; + + match audio_signal::write_state(&*link, transport.clone(), &state).await { + Ok(()) => { + consecutive_fail = 0; + // A successful write proves the phone is in range AND that the + // link is genuinely up — both are liveness signals other + // subsystems gate on. + crate::presence::touch_presence(); + crate::presence::touch_peer_contact(); + } + Err(e) => { + consecutive_fail += 1; + tracing::debug!("BLE state beat failed ({consecutive_fail}): {e}"); + if consecutive_fail >= MAX_FAILS { + tracing::info!("BLE state beat giving up; link looks dead"); + return; + } + } + } + tokio::time::sleep(STATE_BEAT).await; + } +} + async fn clear_writers(writers: &BleWriterSlots) { // Session over: the phone's handles on our files no longer resolve, and our // own in-flight requests will never be answered. Fail both loudly rather From 499970d3f5d33008038715c38c95d76eb9a1eb41 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 18:01:03 +0200 Subject: [PATCH 63/71] feat(ui): a way to pair a second phone without forgetting the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add phone" card is rendered `v-if="!primaryPeer"` — only while nothing is paired. Once the first phone was linked it was replaced by that phone's card, and pairing became unreachable: the sole route to a second phone was to forget the one you had. Which is what the user had to do. A Plus button now sits in the phone card's header, beside Ring and Switch. Switch is the precedent — it is the other action there about phones that are not this one — and Plus goes last so it stays the rightmost control: the two before it are conditional (Ring needs the phone online, Switch needs a second peer), and a button that shifts position as they appear is harder to find twice. It reuses `openPairPhoneModal`, so both platforms get it from one change: `UiCmd::Scan` reaches `cmd_pairing::scan` on Linux and `ble_portable::scan_for_ui` on Windows, and `UiCmd::Pair` reaches `cmd_pairing::pair` or `pair_by_scan`. Nothing was missing underneath — the gap was only in the UI. Checked before adding it that a second `save` appends rather than replaces, so pairing a new phone cannot silently drop the old one. No paired-devices list exists anywhere else in the laptop UI (`peers.title` and `peers.forget_all` are defined in the locales and unused), so the card header is the only place this could go. Tooltip in all three locales. Frontend typechecks, both Rust targets check clean, and the Linux app is reinstalled and running. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src/lib/locales/en.json | 1 + linux/ui-tauri/src/lib/locales/ru.json | 1 + linux/ui-tauri/src/lib/locales/uz.json | 1 + linux/ui-tauri/src/pages/home/Devices.vue | 13 +++++++++++++ 4 files changed, 16 insertions(+) diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index 77fc288..cb136a5 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -106,6 +106,7 @@ "failed": "Pairing failed", "done": "Done", "add_phone": "Add phone", + "add_another": "Pair another phone", "add_phone_hint": "Tap to discover nearby", "add_phone_modal_hint": "Open Vortex on your Android phone, then tap a result below.", "add_phone_btn": "Pair", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index 36da95a..a69d6c9 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -106,6 +106,7 @@ "failed": "Сопряжение не удалось", "done": "Готово", "add_phone": "Добавить телефон", + "add_another": "Добавить ещё один телефон", "add_phone_hint": "Нажмите для поиска", "add_phone_modal_hint": "Откройте Vortex на Android и выберите устройство ниже.", "add_phone_btn": "Связать", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index 0cece4c..b12bc79 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -106,6 +106,7 @@ "failed": "Juftlash muvaffaqiyatsiz", "done": "Tayyor", "add_phone": "Telefon qo'shish", + "add_another": "Yana bir telefon ulash", "add_phone_hint": "Yaqindagilarni topish uchun bosing", "add_phone_modal_hint": "Android'da Vortex'ni oching, keyin pastdagi natijani bosing.", "add_phone_btn": "Juftlash", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 36b5b81..0ac80c3 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -212,6 +212,19 @@ const earbudsStatus = computed(() => { + +
Date: Sat, 12 Sep 2026 18:10:52 +0200 Subject: [PATCH 64/71] feat(ui): mirror Android's paired-devices layout on the laptop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Plus button I had put in the phone card's header. That card already carries ring, switch and files; a fourth icon there was crowding, and more to the point it was not the shape the phone uses. Android's home screen shows the active laptop, then an "Also paired" list, then a card to add one — so the laptop now shows the same three things about phones, in the same order and with the same wording where it transfers. Two tiles, both full width under the phone and earbuds cards: * "Also paired" — every other remembered phone as a compact clickable row with its name and when it was last heard from, spinner while a switch is in flight. Compact rows rather than a card each, for the reason the Android version gives: a full card per phone would push the rest of the page away for what is mostly "this one exists". The label never claims reachability — these are by definition the phones we are not the active peer of, so it says "seen 5 min ago", or "paired 2 d ago" for one that has not checked in this session. * "Pair another phone" — hint plus a Pair button. Only rendered once a phone exists, because with none paired the "Add phone" card above already IS this. Two details worth noting. The row icon is its own class rather than `vx-icon` plus size utilities: Vue scoped styles compile to `.vx-icon[data-v-hash]`, which out-specifies a plain `.h-[34px]`, so the override would have been silently ignored — right size in the markup, wrong size on screen. And `nowTick` is now exported from `useHome` rather than re-created here, so the row labels age off the same clock as the online dot; two tickers would drift and update at different moments. Strings in all three locales, and the `pair.add_another` key from the previous commit is removed rather than left dangling. Frontend typechecks, both Rust targets check clean, locales verified in sync (27 keys each), and the Linux app is reinstalled and running. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src/composables/useHome.ts | 6 +- linux/ui-tauri/src/lib/locales/en.json | 14 ++- linux/ui-tauri/src/lib/locales/ru.json | 14 ++- linux/ui-tauri/src/lib/locales/uz.json | 14 ++- linux/ui-tauri/src/pages/home/Devices.vue | 102 +++++++++++++++++++--- 5 files changed, 129 insertions(+), 21 deletions(-) diff --git a/linux/ui-tauri/src/composables/useHome.ts b/linux/ui-tauri/src/composables/useHome.ts index 6788222..4dccfd2 100644 --- a/linux/ui-tauri/src/composables/useHome.ts +++ b/linux/ui-tauri/src/composables/useHome.ts @@ -328,7 +328,11 @@ export const activeEarbuds = computed< * contact", and is comfortably above the 12 s heartbeat. */ const PEER_ONLINE_SECS = 35; -const nowTick = ref(Math.floor(Date.now() / 1000)); + +/** Epoch seconds, re-read every 10 s. Exported so the "Also paired" rows can + * age their "seen 5 min ago" labels off the same clock the online dot uses — + * two tickers would drift and blink at different moments. */ +export const nowTick = ref(Math.floor(Date.now() / 1000)); setInterval(() => (nowTick.value = Math.floor(Date.now() / 1000)), 10_000); /** True if we have a paired peer AND have seen its state within ~3 min. */ diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index cb136a5..6a6ceab 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -75,7 +75,18 @@ "switch_scanning": "Looking for your other phones…", "switch_pick": "Switch to which phone?", "switch_none": "No other paired phone nearby.", - "switch_cancel": "Cancel" + "switch_cancel": "Cancel", + "other_title": "Also paired", + "other_hint": "Click to switch", + "add_pair": "Pair another phone", + "add_pair_hint": "Put the other phone in pairing mode, then start the scan here.", + "seen": "seen {ago}", + "paired_ago": "paired {ago}", + "never": "not connected", + "just_now": "just now", + "mins": "{n} min ago", + "hours": "{n} h ago", + "days": "{n} d ago" }, "discover": { "looking": "Looking for nearby devices…", @@ -106,7 +117,6 @@ "failed": "Pairing failed", "done": "Done", "add_phone": "Add phone", - "add_another": "Pair another phone", "add_phone_hint": "Tap to discover nearby", "add_phone_modal_hint": "Open Vortex on your Android phone, then tap a result below.", "add_phone_btn": "Pair", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index a69d6c9..8d6a01b 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -75,7 +75,18 @@ "switch_scanning": "Поиск других ваших телефонов…", "switch_pick": "На какой телефон переключиться?", "switch_none": "Рядом нет другого сопряжённого телефона.", - "switch_cancel": "Отмена" + "switch_cancel": "Отмена", + "other_title": "Также сопряжено", + "other_hint": "Нажмите, чтобы переключиться", + "add_pair": "Добавить ещё один телефон", + "add_pair_hint": "Включите режим сопряжения на другом телефоне, затем начните поиск здесь.", + "seen": "был(а) {ago}", + "paired_ago": "сопряжён {ago}", + "never": "нет связи", + "just_now": "только что", + "mins": "{n} мин назад", + "hours": "{n} ч назад", + "days": "{n} дн назад" }, "discover": { "looking": "Поиск устройств поблизости…", @@ -106,7 +117,6 @@ "failed": "Сопряжение не удалось", "done": "Готово", "add_phone": "Добавить телефон", - "add_another": "Добавить ещё один телефон", "add_phone_hint": "Нажмите для поиска", "add_phone_modal_hint": "Откройте Vortex на Android и выберите устройство ниже.", "add_phone_btn": "Связать", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index b12bc79..d2ffd96 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -75,7 +75,18 @@ "switch_scanning": "Boshqa telefonlaringiz qidirilmoqda…", "switch_pick": "Qaysi telefonga o'tamiz?", "switch_none": "Yaqinda boshqa ulangan telefon yo'q.", - "switch_cancel": "Bekor qilish" + "switch_cancel": "Bekor qilish", + "other_title": "Yana ulangan", + "other_hint": "O'tish uchun bosing", + "add_pair": "Yana bir telefon ulash", + "add_pair_hint": "Boshqa telefonni ulanish rejimiga o'tkazing, so'ng bu yerda qidiruvni boshlang.", + "seen": "{ago} ko'rilgan", + "paired_ago": "{ago} ulangan", + "never": "ulanmagan", + "just_now": "hozirgina", + "mins": "{n} daq oldin", + "hours": "{n} soat oldin", + "days": "{n} kun oldin" }, "discover": { "looking": "Yaqindagi qurilmalar qidirilmoqda…", @@ -106,7 +117,6 @@ "failed": "Juftlash muvaffaqiyatsiz", "done": "Tayyor", "add_phone": "Telefon qo'shish", - "add_another": "Yana bir telefon ulash", "add_phone_hint": "Yaqindagilarni topish uchun bosing", "add_phone_modal_hint": "Android'da Vortex'ni oching, keyin pastdagi natijani bosing.", "add_phone_btn": "Juftlash", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 0ac80c3..0bd4a2a 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -38,11 +38,13 @@ import { peerSwitchScanning, peerSwitchCandidates, peerSwitchNoneFound, + nowTick, startPeerSwitch, abortPeerSwitch, choosePeer, } from "@/composables/useHome"; -import { peers } from "@/lib/connectionStore"; +import { peers, peerStates } from "@/lib/connectionStore"; +import type { TrustedPeer } from "@/lib/bridge"; const { t } = useI18n(); @@ -130,6 +132,33 @@ async function openPhoneFiles() { } } +// Every paired phone EXCEPT the one on the card above. Mirrors the Android +// home screen's "Also paired" list, which is the layout this page is supposed +// to match — the phone shows the active laptop, then the others, then a way to +// add one, and the laptop now shows the same three things about phones. +const otherPeers = computed(() => + peers.value.filter((p) => p.peer_static_pub !== primaryPeer.value?.peer_static_pub), +); + +function ago(secs: number) { + const s = Math.max(0, secs); + if (s < 60) return t("peers.just_now"); + if (s < 3600) return t("peers.mins", { n: Math.floor(s / 60) }); + if (s < 86_400) return t("peers.hours", { n: Math.floor(s / 3600) }); + return t("peers.days", { n: Math.floor(s / 86_400) }); +} + +// Never claim one of these is reachable: they are by definition the phones we +// are NOT the active peer of, so the honest line is when it was last heard +// from — falling back to when it was paired, which is all we know about a phone +// that has not checked in this session. +function lastSeenLabel(p: TrustedPeer) { + const st = peerStates.value[p.peer_static_pub]; + if (st?.ts) return t("peers.seen", { ago: ago(nowTick.value - st.ts) }); + if (p.paired_at) return t("peers.paired_ago", { ago: ago(nowTick.value - p.paired_at) }); + return t("peers.never"); +} + const earbudsStatus = computed(() => { if (!activeEarbuds.value) return t("earbuds.not_connected"); return activeEarbuds.value.on === "local" ? t("earbuds.on_local") : t("earbuds.on_peer"); @@ -212,19 +241,6 @@ const earbudsStatus = computed(() => { - -
{
+ + +
+
{{ t("peers.other_title") }}
+
{{ t("peers.other_hint") }}
+ +
+ + +
+ +
+
{{ t("peers.add_pair") }}
+
{{ t("peers.add_pair_hint") }}
+
+ +
@@ -380,6 +441,19 @@ const earbudsStatus = computed(() => { .vx-dot { @apply h-2 w-2 shrink-0 rounded-full; } +/* A smaller sibling of `.vx-icon` for the compact rows. Its own class rather + than `vx-icon` plus size utilities: Vue scoped styles compile to + `.vx-icon[data-v-hash]`, which out-specifies a plain `.h-[34px]`, so the + override would have been silently ignored. */ +.vx-row-icon { + @apply flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] border border-white/[0.06] bg-white/[0.05]; + color: #e8eaed; +} +/* One "Also paired" row. The whole row is the button — there is a single + action per row, and the heading already says what it is. */ +.vx-row { + @apply flex w-full items-center gap-2.5 rounded-[10px] px-1 py-1.5 transition-colors hover:bg-white/[0.05] disabled:opacity-50; +} .vx-chip { @apply inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.05] px-[13px] py-2 text-[12.5px] font-medium transition-colors hover:bg-white/[0.09] hover:text-foreground disabled:opacity-50; color: #d4d6db; From 384eca02652ddcac4eb8673397f460b411577d2b Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 18:14:38 +0200 Subject: [PATCH 65/71] fix(ui): a phone, not a plus, on the pair-another tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plus reads as "click me", and that tile is not clickable — its action is the Pair button on the right — so clicking the icon did nothing. The two "Add …" cards above keep their plus, and the difference is the point: each of those is a single button, so the whole card responds and the plus is honest. This tile has a hint line and a separate control, matching the Android card it mirrors, so it gets the phone icon the rest of the page uses for phones. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src/pages/home/Devices.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 0bd4a2a..59d6927 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -415,7 +415,11 @@ const earbudsStatus = computed(() => { and because on Android this is its own card too. Only once a phone exists: with none paired the "Add phone" card above IS this. -->
- + +
{{ t("peers.add_pair") }}
{{ t("peers.add_pair_hint") }}
From 6481e2ba5afb0c5ec2fab3c1170b989afda512f8 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 20:26:35 +0200 Subject: [PATCH 66/71] fix(rebase): reconcile this branch with upstream's six release snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration fallout from rebasing onto origin/main, which had moved a long way: new Linux-only subsystems, a rewritten tray, and continued work on the very file-transfer path this branch replaces. WIRE. Upstream took frame type 0x4F for PHONE_FILES while PEER_HANDOFF had it here. Two meanings for one type byte is a protocol that cannot be read, so PEER_HANDOFF moves to 0x54 — it is the one that has never shipped outside these two repositories, and it now sits just past this branch's FS block. TRAY. Upstream replaced Tauri's tray with ksni, which speaks StatusNotifierItem over D-Bus and cannot exist on Windows. Both implementations expose the same two functions, so `tray.rs` is now a facade over `tray_ksni` (Linux) and `tray_tauri` (elsewhere). The Tauri one stashes its AppHandle at setup so its signature matches ksni's, which takes none. WINDOWS GATES. Upstream added hogp (HID over GATT), bt_hid (a BlueZ profile over zbus), audio_route, x11_focus and SIGTERM handling — all Linux-bound, all newly in the way of a Windows build. Gated at the module where the whole thing is Linux, at the statement where a cross-platform function has a BlueZ branch, and stubbed where a caller needs an answer either way. APIs THIS BRANCH HAD ALREADY REPLACED, with upstream callers grown since: `ClipboardFileReader.read` returns a typed Outcome here (it distinguishes "too large" from "unreadable", which the share sheet reports), so upstream's callers get `readOrNull`. `ClipboardOutgoingFile` carries a URI, not bytes, so upstream's MediaStore auto-send is ported onto ShareGrants — a captured video is routinely hundreds of megabytes and stashing one in the blob store is the allocation this branch exists to remove. Its MAX_FILE_BYTES guard goes with it: the heap reason for a cap is gone, and the laptop streams on demand. DUPLICATED FEATURES. Upstream and this branch each added shared-folder picking. One row survives — upstream's, which is localised — but the branch's launcher, because it registers the tree with FsRoots (what the protocol actually serves) as well as persisting the permission, and upstream's reader sees the same persisted grants either way. `Advertiser.startPresenceLoop` keeps `linkedProvider` over upstream's `isConnected`: the latter is merely ACL-connected, and BlueZ keeps the ACL open past the laptop app's death. Verified: both Rust targets check clean, 78 + 225 tests, Kotlin compiles, Android unit tests pass, frontend builds. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/com/vortex/a3/core/ble/Frame.kt | 4 +- .../a3/core/clipboard/ClipboardFileOut.kt | 9 + .../com/vortex/a3/core/files/PhoneFiles.kt | 2 +- .../a3/core/media/CapturedMediaWatcher.kt | 14 +- .../java/com/vortex/a3/service/VortexStack.kt | 1 - .../vortex/a3/service/VortexStackClipboard.kt | 19 +- .../java/com/vortex/a3/ui/MainActivity.kt | 14 - .../main/java/com/vortex/a3/ui/VortexRoot.kt | 6 +- .../vortex/a3/ui/screens/SettingsScreen.kt | 14 - linux/daemon/src/core/ble/frame.rs | 7 +- linux/daemon/src/core/clipboard_mirror.rs | 24 +- linux/daemon/src/core/mod.rs | 3 + linux/ui-tauri/src-tauri/src/ble.rs | 92 +++++- linux/ui-tauri/src-tauri/src/call.rs | 6 +- linux/ui-tauri/src-tauri/src/diagnostics.rs | 6 + linux/ui-tauri/src-tauri/src/dnd.rs | 2 +- linux/ui-tauri/src-tauri/src/file_consent.rs | 4 +- linux/ui-tauri/src-tauri/src/fs_pull.rs | 19 +- linux/ui-tauri/src-tauri/src/lan.rs | 1 + linux/ui-tauri/src-tauri/src/lan_state.rs | 3 + linux/ui-tauri/src-tauri/src/laptop_cast.rs | 2 +- linux/ui-tauri/src-tauri/src/lib.rs | 7 + linux/ui-tauri/src-tauri/src/mirror_inject.rs | 29 ++ linux/ui-tauri/src-tauri/src/notifications.rs | 5 + .../src-tauri/src/platform_unsupported.rs | 6 +- linux/ui-tauri/src-tauri/src/proximity.rs | 2 +- linux/ui-tauri/src-tauri/src/send_to_phone.rs | 2 +- linux/ui-tauri/src-tauri/src/tray.rs | 311 ++---------------- linux/ui-tauri/src-tauri/src/tray_ksni.rs | 290 ++++++++++++++++ linux/ui-tauri/src-tauri/src/tray_tauri.rs | 249 ++++++++++++++ .../src-tauri/src/universal_control.rs | 7 + linux/ui-tauri/src-tauri/src/worker.rs | 4 + linux/ui-tauri/src-tauri/src/x11_focus.rs | 13 + 33 files changed, 814 insertions(+), 363 deletions(-) create mode 100644 linux/ui-tauri/src-tauri/src/tray_ksni.rs create mode 100644 linux/ui-tauri/src-tauri/src/tray_tauri.rs diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt b/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt index 423cc06..c83a6be 100644 --- a/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt +++ b/android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt @@ -158,7 +158,9 @@ object FrameType { * the AEAD payload an optional UTF-8 successor name for the UI. * Additive: both sides log-and-ignore unknown frame types, so a peer * without this build is unaffected. Mirrors Rust `ty::PEER_HANDOFF`. */ - const val PEER_HANDOFF: Byte = 0x4F + // 0x54, not 0x4F — see the note on the Rust side: upstream took 0x4F for + // PHONE_FILES, and PEER_HANDOFF is the one that has never shipped. + const val PEER_HANDOFF: Byte = 0x54 /** Ranged-filesystem request. `sub` carries the op * ([com.vortex.a3.core.fs.FsOp]), the payload a JSON request — plus a * binary byte tail for WRITE. diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt index 06a311d..b095cf8 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardFileOut.kt @@ -54,6 +54,15 @@ object ClipboardFileReader { * user sees a share succeed and a transfer fail minutes later, so the cheap * check is worth one open. */ + /** The file, or null if it could not be read or was over the cap. + * + * For callers with nowhere to put the reason — a MediaStore auto-send, a + * file-browser fetch. Anything facing the user should call [read] and say + * which of the two it was: "too large" and "unreadable" are different + * problems and only one of them is the user's to fix. */ + fun readOrNull(context: Context, uri: Uri): ClipboardOutgoingFile? = + (read(context, uri) as? Outcome.Ok)?.file + fun read(context: Context, uri: Uri): Outcome { val cr = context.contentResolver val mime = cr.getType(uri) ?: "application/octet-stream" diff --git a/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt b/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt index 2eff73a..ac10a49 100644 --- a/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt +++ b/android/app/src/main/java/com/vortex/a3/core/files/PhoneFiles.kt @@ -185,7 +185,7 @@ object PhoneFiles { */ fun read(context: Context, raw: String): com.vortex.a3.core.clipboard.ClipboardOutgoingFile? { val uri = resolve(context, raw) ?: return null - return com.vortex.a3.core.clipboard.ClipboardFileReader.read(context, uri) + return com.vortex.a3.core.clipboard.ClipboardFileReader.readOrNull(context, uri) } /** `primary:Download` reads better as `Download`. */ diff --git a/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt b/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt index e9362aa..a9d405c 100644 --- a/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt +++ b/android/app/src/main/java/com/vortex/a3/core/media/CapturedMediaWatcher.kt @@ -364,14 +364,12 @@ data class CapturedMedia( continue } val size = if (sizeIdx >= 0) c.getLong(sizeIdx) else 0L - // Reject on the row's own SIZE, before anything opens the - // file. A recording is orders of magnitude bigger than a - // screenshot, and the reader would otherwise pull it into - // the service's heap to find out it was too big. - if (size > com.vortex.a3.core.clipboard.ClipboardFileReader.MAX_FILE_BYTES) { - Log.i(tag, "${kind.name.lowercase()} _id=$id is $size bytes — over the cap, not sent") - continue - } + // No size cap any more. There used to be one because the + // reader pulled the whole file into the service's heap to + // send it — which is exactly what the ranged-read protocol + // removed: an offer now carries a grant, and the laptop + // streams the bytes on demand. A screen recording is + // offered like anything else, and nothing here holds it. val name = (if (nameIdx >= 0) c.getString(nameIdx) else null) ?.takeIf { it.isNotBlank() } ?: "capture-$id" val mime = (if (mimeIdx >= 0) c.getString(mimeIdx) else null) diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt index a4213ba..2510504 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt @@ -1004,7 +1004,6 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { adv.startPresenceLoop( scope = scope, rotationWindowSec = 60L, - isConnected = { gattServer?.hasActiveConnection() == true }, onError = { reason -> Log.w(TAG, "presence adv error: $reason") }, ) Log.i(TAG, "presence loop started (have ${peerStore.list().size} peer(s))") diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt index c32c5b1..0518b27 100644 --- a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt +++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt @@ -137,18 +137,25 @@ internal fun VortexStack.startClipboardOutbound() { */ internal fun VortexStack.offerCapturedMedia(media: com.vortex.a3.core.media.CapturedMedia) { scope.launch { - val file = com.vortex.a3.core.clipboard.ClipboardFileReader.read(ctx, media.uri) + val file = com.vortex.a3.core.clipboard.ClipboardFileReader.readOrNull(ctx, media.uri) if (file == null) { Log.w(VortexStack.TAG, "${media.kind.name.lowercase()} _id=${media.id} unreadable or over the cap; not sent") return@launch } val name = media.name.ifBlank { file.name } - val token = com.vortex.a3.core.clipboard.ClipboardBlobStore.stashLazy(file.bytes) { - com.vortex.a3.core.clipboard.ClipboardFileReader.read(ctx, media.uri)?.bytes - } + // A grant, not the bytes — same as the share path above. A captured + // video is routinely hundreds of megabytes, and stashing one in the + // blob store is the allocation this branch exists to remove: the + // laptop pulls it in ranges through the filesystem protocol instead. + val token = com.vortex.a3.core.fs.ShareGrants.grant( + file.uri, + name, + file.mime, + file.size, + ) val o = org.json.JSONObject() o.put("token", token) - o.put("bytes", file.bytes.size) + o.put("bytes", file.size) o.put("name", name) o.put("mime", file.mime) // Tells the laptop which subfolder and which notification, and that @@ -159,7 +166,7 @@ internal fun VortexStack.offerCapturedMedia(media: com.vortex.a3.core.media.Capt // passed on (see CaptureLedger). Recorded at the offer, not at the // fetch: the laptop files its copy under the same token either way. com.vortex.a3.core.media.CaptureLedger.record(token, media.collection, media.uri) - Log.i(VortexStack.TAG, "${media.kind.name.lowercase()} offered to laptop ('$name', ${file.bytes.size} bytes, token=$token)") + Log.i(VortexStack.TAG, "${media.kind.name.lowercase()} offered to laptop ('$name', ${file.size} bytes, token=$token)") offerFileToLaptop(token, name, offer, quiet = true) } } diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt index 6ae3e7b..46f35ae 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt @@ -343,20 +343,6 @@ class MainActivity : ComponentActivity() { /** The folder picker behind "let the laptop browse a folder". The grant is * persisted on the way back so it survives a restart; a cancelled pick * returns null and simply changes nothing. */ - private val folderPickLauncher = registerForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - ) { uri -> - if (uri != null) com.vortex.a3.core.files.PhoneFiles.persistGrant(this, uri) - } - - internal fun pickSharedFolder() { - try { - folderPickLauncher.launch(null) - } catch (e: Exception) { - android.util.Log.w("PhoneFiles", "no folder picker available: ${e.message}") - } - } - internal fun requestMediaPermission() { val missing = com.vortex.a3.core.media.mediaReadPermissions().filter { androidx.core.content.ContextCompat.checkSelfPermission(this, it) != diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index 69a7588..4cb6ea2 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -171,9 +171,7 @@ fun VortexRoot( // Re-read whenever the settings screen is shown: the picker is // a separate activity, so a grant taken there lands while this // composition is away. - val sharedFolderCount = remember(showSettings) { - com.vortex.a3.core.files.PhoneFiles.grantedTrees(activity).size - } + // Re-checked each time Settings opens AND after either toggle // moves: the flip that turns a row on is what asks for the // grant, and the hint should follow the answer. @@ -266,8 +264,6 @@ fun VortexRoot( onPickSharedFolder = actions.onPickSharedFolder, screenControlOn = screenControlOn, onScreenControlClick = actions.onOpenScreenControl, - sharedFolderCount = sharedFolderCount, - onSharedFoldersClick = actions.onPickSharedFolder, allFilesOn = allFilesOn, onAllFilesClick = actions.onOpenAllFilesAccess, onBack = { showSettings = false }, diff --git a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt index b3dba18..9adc4c1 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/screens/SettingsScreen.kt @@ -102,8 +102,6 @@ fun SettingsScreen( onPickSharedFolder: () -> Unit, screenControlOn: Boolean, onScreenControlClick: () -> Unit, - sharedFolderCount: Int, - onSharedFoldersClick: () -> Unit, allFilesOn: Boolean, onAllFilesClick: () -> Unit, onBack: () -> Unit, @@ -318,18 +316,6 @@ fun SettingsScreen( status = if (screenControlOn) "On" else "Off", onClick = onScreenControlClick, ) - ActionRow( - icon = Icons.Outlined.FolderOpen, - title = "Shared folders", - // Names the read-only limit up front: a user who shares a - // folder expecting the laptop to save into it would - // otherwise only find out when a copy fails. - hint = if (sharedFolderCount > 0) - "The laptop can browse these, read-only — tap to add another" - else "Off — tap to pick a folder the laptop may browse", - status = if (sharedFolderCount > 0) "$sharedFolderCount" else "Off", - onClick = onSharedFoldersClick, - ) ActionRow( icon = Icons.Outlined.Storage, title = "Allow access to any files", diff --git a/linux/daemon/src/core/ble/frame.rs b/linux/daemon/src/core/ble/frame.rs index 4121f1f..a49aa64 100644 --- a/linux/daemon/src/core/ble/frame.rs +++ b/linux/daemon/src/core/ble/frame.rs @@ -212,7 +212,12 @@ pub mod ty { /// Additive by design: both sides log-and-ignore an unknown frame type, so /// a peer without this build is unaffected. Mirrors Kotlin /// `FrameType.PEER_HANDOFF`. - pub const PEER_HANDOFF: u8 = 0x4F; + // 0x54, not 0x4F: upstream took 0x4F for PHONE_FILES while this was on a + // branch, and two meanings for one type byte is a protocol that cannot be + // read. This one moved because it is the one that has never shipped — + // nothing but these two repositories has ever sent it. Sits just past the + // FS block below, keeping this branch's additions contiguous. + pub const PEER_HANDOFF: u8 = 0x54; /// Ranged-filesystem request. `sub` carries the op (`core::fs_proto::op`), /// the payload a JSON request — plus a binary byte tail for `WRITE`. /// diff --git a/linux/daemon/src/core/clipboard_mirror.rs b/linux/daemon/src/core/clipboard_mirror.rs index 9a0155d..d988227 100644 --- a/linux/daemon/src/core/clipboard_mirror.rs +++ b/linux/daemon/src/core/clipboard_mirror.rs @@ -70,13 +70,23 @@ impl ClipboardImageOffer { /// share, or a kind this build does not know — lands in the root as /// shares always have. pub fn subdir(&self) -> Option<&'static str> { - match self.kind.as_str() { - "screenshot" => Some("Phone/Screenshots"), - "photo" => Some("Phone/Photos"), - "screen_recording" => Some("Phone/Screen recordings"), - "video" => Some("Phone/Videos"), - _ => None, - } + subdir_for_kind(&self.kind) + } +} + +/// The same table, reachable without an [`Offer`]. +/// +/// The ranged-read puller receives a kind string off the queue rather than the +/// whole offer, and must file a capture in the same place the bulk path did — +/// two copies of this table would be two chances to disagree about where a +/// screenshot lives. +pub fn subdir_for_kind(kind: &str) -> Option<&'static str> { + match kind { + "screenshot" => Some("Phone/Screenshots"), + "photo" => Some("Phone/Photos"), + "screen_recording" => Some("Phone/Screen recordings"), + "video" => Some("Phone/Videos"), + _ => None, } } diff --git a/linux/daemon/src/core/mod.rs b/linux/daemon/src/core/mod.rs index 0206bd2..43c2b84 100644 --- a/linux/daemon/src/core/mod.rs +++ b/linux/daemon/src/core/mod.rs @@ -7,11 +7,13 @@ pub mod audio_lan_session; pub mod audio_op; #[cfg(target_os = "linux")] pub mod audio_orchestrator; +#[cfg(target_os = "linux")] pub mod audio_route; pub mod audio_sink_cache; #[cfg(target_os = "linux")] pub mod audio_switch; pub mod audio_switch_persistence; +#[cfg(target_os = "linux")] pub mod hogp; #[cfg(target_os = "linux")] pub mod media_runtime; @@ -58,4 +60,5 @@ pub mod platform; pub mod session_lock; pub mod status; pub mod storage; +#[cfg(target_os = "linux")] pub mod bt_hid; diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index 6e1fcf5..ca9b3de 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -27,6 +27,94 @@ use crate::NotifWriter; /// a wedged controller. const CONNECT_WEDGE_THRESHOLD: u32 = 6; +/// The RPA of the BLE session that is live right now, so the app can hand the +/// link back on its way out. `None` between sessions. +/// +/// Everything else about teardown was already handled — the loop disconnects +/// and forgets the device whenever the listener returns. What was missing is +/// that a PROCESS EXIT never reaches that code: the loop dies with the process +/// and BlueZ, which owns the connection independently of us, keeps it open. +/// The phone's GATT server therefore still sees a connected peer, holds its +/// RPA and stops advertising in a way a scan can find — so the freshly started +/// app scans, backs off 15s → 60s, and never reconnects. Measured on this +/// machine: BLE dead for six minutes after a restart, with LAN quietly +/// covering for it. Clearing the entry by hand and letting it reconnect took +/// eleven seconds. +static SESSION_ADDR: std::sync::Mutex> = + std::sync::Mutex::new(None); + +fn note_session_addr(addr: Option) { + if let Ok(mut g) = SESSION_ADDR.lock() { + *g = addr; + } +} + +/// Hand the BLE link back before the process goes away. +/// +/// Synchronous and hard-bounded, because it runs from Tauri's `RunEvent::Exit` +/// on the main thread: an exit that hangs on D-Bus is worse than one that +/// leaves a stale entry. Its own runtime rather than the worker's, which may +/// already be shutting down by the time this runs. +pub(crate) fn shutdown_link_blocking() { + let Some(addr) = SESSION_ADDR.lock().ok().and_then(|g| *g) else { + return; // no live session — nothing to hand back + }; + tracing::info!(%addr, "shutting down — dropping the BLE link so the phone re-advertises"); + let worker = std::thread::spawn(move || { + let Ok(rt) = tokio::runtime::Builder::new_current_thread().enable_all().build() else { + return; + }; + rt.block_on(async move { + let Ok(session) = bluer::Session::new().await else { return }; + let Ok(adapter) = session.default_adapter().await else { return }; + // Disconnect is the half the PHONE sees: it ends the GATT link, so + // the phone stops holding its RPA and advertises again. + if let Ok(dev) = adapter.device(addr) { + let _ = tokio::time::timeout(Duration::from_millis(1200), dev.disconnect()).await; + } + // Removing the entry is the half WE need: it drops the cached + // advertisement so the next run's discovery cannot re-serve this + // dead RPA — the same reason `forget_stale_device` exists. + let _ = + tokio::time::timeout(Duration::from_millis(1200), adapter.remove_device(addr)).await; + }); + }); + let _ = worker.join(); + note_session_addr(None); +} + + +/// Does a failed STATE write mean the LINK is gone, or only that the ATT +/// bearer was busy? +/// +/// The distinction decides whether we tear the session down, so it has to be +/// conservative in the safe direction: anything we do not recognise is treated +/// as busy and the link is kept. A link that is really dead costs us nothing +/// to keep believing in for a few more beats — `run_listener` returns on a real +/// disconnect and tears the session down anyway — whereas killing a live link +/// costs a full scan, IK handshake, resubscribe and bulk re-push. +/// +/// Matched on substrings because these arrive as D-Bus error text from BlueZ, +/// not as typed variants. +fn state_write_means_link_gone(err: &str) -> bool { + let e = err.to_ascii_lowercase(); + // BlueZ says the device, the characteristic, or the D-Bus object is gone. + e.contains("not connected") + || e.contains("notconnected") + || e.contains("does not exist") + || e.contains("doesnotexist") + || e.contains("unknown object") + || e.contains("unknownobject") + || e.contains("no such device") + || e.contains("object removed") + // zbus's wording for UnknownObject, seen live as "the target object was + // either not present or removed". Missing it kept a genuinely dead link + // "alive" for 40 beats of pointless retries instead of reconnecting. + || e.contains("not present or removed") + || e.contains("disconnected") +} + + /// Last BLE address we completed a Noise IK exchange with, per peer. /// /// Recorded only *after* IK succeeds, so the address is positively tied to @@ -1306,8 +1394,8 @@ pub(crate) async fn run_ble_persistent_loop( // duration: the proximity watcher read the phone // as having left, and the mirror pills were swept // while the phone sat right there. - touch_presence(); - touch_peer_contact(); + crate::presence::touch_presence(); + crate::presence::touch_peer_contact(); // Back off hard rather than hammering: each retry // re-seals the frame and therefore burns a Noise // nonce whether or not the bytes ever leave, and diff --git a/linux/ui-tauri/src-tauri/src/call.rs b/linux/ui-tauri/src-tauri/src/call.rs index 630b60f..1d18b61 100644 --- a/linux/ui-tauri/src-tauri/src/call.rs +++ b/linux/ui-tauri/src-tauri/src/call.rs @@ -503,7 +503,7 @@ pub(crate) async fn spawn_consumer( let mut tick = tokio::time::interval(std::time::Duration::from_secs(5)); loop { tick.tick().await; - if crate::ble::peer_contact_age_ms() <= BANNER_STALE_MS { + if crate::presence::peer_contact_age_ms() <= BANNER_STALE_MS { continue; } let id = { @@ -521,7 +521,7 @@ pub(crate) async fn spawn_consumer( its buttons could not have worked", BANNER_STALE_MS / 1000 ); - let _ = notification_display::close(id).await; + let _ = crate::notify::close(id).await; } } }); @@ -790,7 +790,7 @@ pub(crate) async fn spawn_consumer( if tick_gen2.load(Ordering::SeqCst) != my_gen { break; // call ended / re-answered → stop } - if crate::ble::peer_contact_age_ms() > CONTACT_LOST_MS { + if crate::presence::peer_contact_age_ms() > CONTACT_LOST_MS { tracing::info!( "call pill keep-alive: no contact with the phone for over {}s \ — letting the pill expire", diff --git a/linux/ui-tauri/src-tauri/src/diagnostics.rs b/linux/ui-tauri/src-tauri/src/diagnostics.rs index 47450a6..cdb7d6d 100644 --- a/linux/ui-tauri/src-tauri/src/diagnostics.rs +++ b/linux/ui-tauri/src-tauri/src/diagnostics.rs @@ -104,7 +104,13 @@ pub(crate) fn diagnostics() -> Diagnostics { // rounds this happens on; say so here, with the fix, because the fix // (power the adapter off and on) drops the user's audio and so has to be // their decision rather than something the app does behind them. + // BlueZ-specific: the counter is kept by the BlueZ discovery loop, and + // WinRT's scanner reports nothing equivalent. No counter means no wedge to + // report, which is the honest answer rather than a fabricated zero. + #[cfg(target_os = "linux")] let wedged = crate::ble::not_discovering_rounds(); + #[cfg(not(target_os = "linux"))] + let wedged = 0u32; if wedged > 0 { checks.push(check( "bluetooth_discovery", diff --git a/linux/ui-tauri/src-tauri/src/dnd.rs b/linux/ui-tauri/src-tauri/src/dnd.rs index c169cc4..fca5a73 100644 --- a/linux/ui-tauri/src-tauri/src/dnd.rs +++ b/linux/ui-tauri/src-tauri/src/dnd.rs @@ -128,7 +128,7 @@ fn note_local_change(on: bool) { // and the LAN heartbeat stretches to minutes while BLE looks healthy, so // nudging only one can leave a toggle waiting a long time on a wedged // link. The lock-screen hint nudges both for the same reason. - crate::ble::state_nudge().notify_one(); + crate::presence::state_nudge().notify_one(); if let Some(n) = crate::SYNC_NUDGE.get() { n.notify_one(); } diff --git a/linux/ui-tauri/src-tauri/src/file_consent.rs b/linux/ui-tauri/src-tauri/src/file_consent.rs index 494221d..11b0cdd 100644 --- a/linux/ui-tauri/src-tauri/src/file_consent.rs +++ b/linux/ui-tauri/src-tauri/src/file_consent.rs @@ -138,7 +138,7 @@ pub(crate) async fn notify_received(path: PathBuf, kind: &str) { ("fc:copy".to_string(), "Copy".to_string()), ("fc:open".to_string(), "Open".to_string()), ]; - match notification_display::show_call_banner(title, &body, "vortex", &actions, 0, false).await { + match crate::notify::show_banner(title, &body, "vortex", &actions, 0, true).await { Ok(id) => { if let Ok(mut g) = RECEIVED.lock() { g.push((id, path)); @@ -192,7 +192,7 @@ async fn act_on_received(id: u32, key: &str) { } _ => {} } - let _ = notification_display::close(id).await; + let _ = crate::notify::close(id).await; } fn fmt_bytes(n: u64) -> String { diff --git a/linux/ui-tauri/src-tauri/src/fs_pull.rs b/linux/ui-tauri/src-tauri/src/fs_pull.rs index ebe71a8..9a4c5a7 100644 --- a/linux/ui-tauri/src-tauri/src/fs_pull.rs +++ b/linux/ui-tauri/src-tauri/src/fs_pull.rs @@ -32,8 +32,8 @@ pub(crate) fn spawn() { tokio::spawn(async move { loop { notify.notified().await; - while let Some((token, name, _mime, id)) = pop_front() { - pull_one(&token, &name, id).await; + while let Some((token, name, _mime, id, kind)) = pop_front() { + pull_one(&token, &name, id, &kind).await; } } }); @@ -71,13 +71,16 @@ fn clear_in_flight(token: &str) { } } -fn pop_front() -> Option<(String, String, String, u64)> { +/// `(token, name, mime, id, kind)`. `kind` is what the phone called this file — +/// a capture it sent by itself, or something a person shared — and it decides +/// which folder it lands in, so it has to survive the move to ranged reads. +fn pop_front() -> Option<(String, String, String, u64, String)> { crate::PENDING_FILE_OFFERS .get() .and_then(|m| m.lock().ok().and_then(|mut g| g.pop_front())) } -async fn pull_one(token: &str, name: &str, id: u64) { +async fn pull_one(token: &str, name: &str, id: u64, kind: &str) { mark_in_flight(token); // Sanitise to a single path component. The name comes from the phone, and // a `../` in it would otherwise choose where on this laptop the file lands. @@ -86,7 +89,13 @@ async fn pull_one(token: &str, name: &str, id: u64) { .map(|s| s.to_string_lossy().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "vortex-file".to_string()); - let Some(dir) = crate::clipboard_sync::downloads_dir() else { + // Captures (the phone sent it by itself) go under the picture folder, and + // anything a person shared under downloads — `receive_root` is the single + // owner of that rule, and the bulk path this replaces used it too. + let subdir = vortex_l3_daemon::core::clipboard_mirror::subdir_for_kind(kind); + let Some(dir) = crate::clipboard_sync::receive_root(subdir) + .map(|d| crate::clipboard_sync::receive_dir(&d, subdir)) + else { tracing::warn!("file pull: no HOME — dropped"); crate::transfers::fail(id); return; diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index b5ad160..64e1ce8 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -59,6 +59,7 @@ const CLAIM_DEFER_TIMEOUT: Duration = Duration::from_secs(20); /// Resolve once the orchestrator is no longer mid-flow. `Failed` counts as /// settled — the flow is over either way, and only `Idle`-watching is what /// let a failed switch pin a pending claim open. +#[cfg(target_os = "linux")] async fn wait_for_settled( mut rx: tokio::sync::watch::Receiver< vortex_l3_daemon::core::audio_orchestrator::SwitchState, diff --git a/linux/ui-tauri/src-tauri/src/lan_state.rs b/linux/ui-tauri/src-tauri/src/lan_state.rs index 900a5be..7fb6b03 100644 --- a/linux/ui-tauri/src-tauri/src/lan_state.rs +++ b/linux/ui-tauri/src-tauri/src/lan_state.rs @@ -74,6 +74,9 @@ pub(crate) fn dispatch_lock_command(state: &vortex_l3_daemon::core::appstate::Ap tracing::warn!(%cmd, seq, "remote lock command failed: {e}"); // The phone's unlock button hits the same polkit gate as // proximity unlock; tell the user rather than dropping it. + // polkit is a Linux notion; elsewhere a failed unlock has no + // "denied by policy" case to distinguish. + #[cfg(target_os = "linux")] if vortex_l3_daemon::core::session_lock::is_unlock_denied(&e) { crate::proximity::warn_unlock_denied_once().await; } diff --git a/linux/ui-tauri/src-tauri/src/laptop_cast.rs b/linux/ui-tauri/src-tauri/src/laptop_cast.rs index 6f17fb1..73b7335 100644 --- a/linux/ui-tauri/src-tauri/src/laptop_cast.rs +++ b/linux/ui-tauri/src-tauri/src/laptop_cast.rs @@ -663,7 +663,7 @@ async fn start_extend(phone_ip: std::net::IpAddr, key: [u8; 32]) -> Result<(), S // generous because the cast itself is the traffic keeping contact // fresh; going this long without a single frame from the phone means // it is gone, not slow. - if crate::ble::peer_contact_age_ms() > PEER_LOST_MS { + if crate::presence::peer_contact_age_ms() > PEER_LOST_MS { tracing::info!( "laptop-cast: no contact with the phone for over {}s — tearing down \ (a virtual monitor must never outlive the device it exists for)", diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index aee3680..2db9810 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -133,6 +133,11 @@ mod proximity; mod ring; mod sms; mod tray; +// The two tray implementations behind it — see `tray.rs` for why there are two. +#[cfg(target_os = "linux")] +mod tray_ksni; +#[cfg(not(target_os = "linux"))] +mod tray_tauri; mod universal_control; #[cfg(target_os = "linux")] mod virtual_display; @@ -552,6 +557,7 @@ pub fn run() { // Universal Control is the one switch that used to forget itself: it // lives entirely in this process, so a reboot or a quit left the edge // unarmed with the switch showing off. Put it back the way it was. + #[cfg(target_os = "linux")] universal_control::ensure_bt_hid(); // Per-user setup a package cannot do for us (autostart entry, // enabling the GNOME extension). Idempotent, so it also repairs an @@ -686,6 +692,7 @@ pub fn run() { // — leaving the phone believing a peer is still attached, and // the next run scanning for an advertisement it will therefore // never send. + #[cfg(target_os = "linux")] crate::ble::shutdown_link_blocking(); } }); diff --git a/linux/ui-tauri/src-tauri/src/mirror_inject.rs b/linux/ui-tauri/src-tauri/src/mirror_inject.rs index 8fa688f..f02ecbf 100644 --- a/linux/ui-tauri/src-tauri/src/mirror_inject.rs +++ b/linux/ui-tauri/src-tauri/src/mirror_inject.rs @@ -1009,6 +1009,12 @@ pub fn spawn_health_check(armed: impl Fn() -> bool + Send + 'static) { }); } +/// The Bluetooth HID server, when one is registered. +/// +/// Linux-only: registering a HID profile means owning a BlueZ profile object +/// over D-Bus. The adb transport above is what carries Universal Control +/// everywhere else. +#[cfg(target_os = "linux")] static BT_HID: Mutex> = Mutex::new(None); /// BLE HID (HOGP) — the adb-free transport, and the only one that survives a @@ -1020,14 +1026,17 @@ static BT_HID: Mutex> = Mute /// no developer mode, no `adb tcpip`, and no re-bootstrap after the phone /// restarts, which is the single biggest thing standing between a new user and /// this feature working. +#[cfg(target_os = "linux")] static HOGP: Mutex> = Mutex::new(None); +#[cfg(target_os = "linux")] pub fn set_hogp(server: vortex_l3_daemon::core::hogp::HogpServer) { if let Ok(mut g) = HOGP.lock() { *g = Some(server); } } +#[cfg(target_os = "linux")] pub fn get_hogp() -> Option { HOGP.lock().ok().and_then(|g| g.clone()) } @@ -1051,6 +1060,11 @@ pub fn hogp_ready() -> bool { /// Re-read whether a HOGP host is subscribed. Subscription — not the bond and /// not the connection — is what means a report will actually be delivered. +/// No HID-over-GATT server off Linux, so readiness never changes from false. +#[cfg(not(target_os = "linux"))] +pub fn refresh_hogp_ready() {} + +#[cfg(target_os = "linux")] pub fn refresh_hogp_ready() { let Some(server) = get_hogp() else { HOGP_READY.store(false, Ordering::Relaxed); @@ -1062,6 +1076,7 @@ pub fn refresh_hogp_ready() { } /// Register the Bluetooth HID server for ADB-free Universal Control fallback. +#[cfg(target_os = "linux")] pub fn set_bt_hid(server: vortex_l3_daemon::core::bt_hid::BtHidServer) { if let Ok(mut g) = BT_HID.lock() { *g = Some(server); @@ -1103,6 +1118,8 @@ pub fn active() -> bool { if INJECT.lock().map(|g| g.is_some()).unwrap_or(false) { return true; } + // Classic Bluetooth HID: a BlueZ profile, so Linux only. + #[cfg(target_os = "linux")] if let Ok(g) = BT_HID.lock() { if let Some(hid) = g.as_ref() { if hid.is_connected() { @@ -1127,6 +1144,11 @@ static LAST_BT_CONNECT: Mutex> = Mutex::new(None); /// transport that is already unnecessary is how the phone ended up being asked /// to connect over and over while the cursor was crossing perfectly well over /// Wi-Fi. +/// Nothing to reach out to: the Bluetooth HID profile is a BlueZ object. +#[cfg(not(target_os = "linux"))] +pub fn trigger_bt_connect() {} + +#[cfg(target_os = "linux")] pub fn trigger_bt_connect() { if INJECT.lock().map(|g| g.is_some()).unwrap_or(false) { return; // adb injector is up — the fallback is not needed @@ -1138,6 +1160,8 @@ pub fn trigger_bt_connect() { } *last = Some(std::time::Instant::now()); } + // Classic Bluetooth HID: a BlueZ profile, so Linux only. + #[cfg(target_os = "linux")] if let Ok(g) = BT_HID.lock() { if let Some(hid) = g.as_ref() { if !hid.is_connected() { @@ -1170,6 +1194,8 @@ pub fn has_transport() -> bool { if INJECT.lock().map(|g| g.is_some()).unwrap_or(false) { return true; } + // Classic Bluetooth HID: a BlueZ profile, so Linux only. + #[cfg(target_os = "linux")] if let Ok(g) = BT_HID.lock() { if let Some(hid) = g.as_ref() { if hid.is_connected() { @@ -1199,6 +1225,7 @@ pub fn send(line: &str) { // whole state on every report: a move sent while the left button is down // must repeat that bit or the phone sees the button release mid-drag. if hogp_ready() { + #[cfg(target_os = "linux")] if let Some(server) = get_hogp() { let mut parts = line.trim().split_whitespace(); let cmd = parts.next().unwrap_or(""); @@ -1243,6 +1270,8 @@ pub fn send(line: &str) { } // Fallback 2: Classic Bluetooth HID report dispatch + // Classic Bluetooth HID: a BlueZ profile, so Linux only. + #[cfg(target_os = "linux")] if let Ok(g) = BT_HID.lock() { if let Some(hid) = g.as_ref() { let line_str = line.trim(); diff --git a/linux/ui-tauri/src-tauri/src/notifications.rs b/linux/ui-tauri/src-tauri/src/notifications.rs index dc52ec9..9c2a811 100644 --- a/linux/ui-tauri/src-tauri/src/notifications.rs +++ b/linux/ui-tauri/src-tauri/src/notifications.rs @@ -411,6 +411,9 @@ pub(crate) fn spawn_subsystem( tokio::spawn(async move { // Take down anything a previous run left on screen before // we post anything new — see `sweep_stale`. + // Survivors of a previous run are a freedesktop notion — + // a toast has no persistent server holding one. + #[cfg(target_os = "linux")] vortex_l3_daemon::core::notification_display::sweep_stale().await; while let Some(notif) = ble_notif_rx.recv().await { // Laptop-internal nudge (a BLE frame dropped, or we @@ -561,7 +564,9 @@ pub(crate) fn spawn_subsystem( // detached so they outlive us, and only this // record lets a later run take down survivors // whose action mappings died with the process. + #[cfg(target_os = "linux")] vortex_l3_daemon::core::notification_display::remember_live_id(id); + #[cfg(target_os = "linux")] if replaces_id != 0 && replaces_id != id { vortex_l3_daemon::core::notification_display::forget_live_id( replaces_id, diff --git a/linux/ui-tauri/src-tauri/src/platform_unsupported.rs b/linux/ui-tauri/src-tauri/src/platform_unsupported.rs index 5694468..ba42b2f 100644 --- a/linux/ui-tauri/src-tauri/src/platform_unsupported.rs +++ b/linux/ui-tauri/src-tauri/src/platform_unsupported.rs @@ -97,7 +97,11 @@ static SMART_SWITCH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo // ── Continuity camera (GStreamer) ───────────────────────────────────────── #[tauri::command] -pub(crate) fn set_camera_request(_on: bool) {} +/// Answers with the same error the other stubs do, now that the Linux side is +/// fallible: a caller that logs the reason gets one instead of silent success. +pub(crate) fn set_camera_request(_on: bool) -> Result<(), String> { + Err(UNSUPPORTED.to_string()) +} #[tauri::command] pub(crate) fn set_camera_facing(_facing: String) {} diff --git a/linux/ui-tauri/src-tauri/src/proximity.rs b/linux/ui-tauri/src-tauri/src/proximity.rs index 0591a0d..4245ba4 100644 --- a/linux/ui-tauri/src-tauri/src/proximity.rs +++ b/linux/ui-tauri/src-tauri/src/proximity.rs @@ -467,7 +467,7 @@ pub(crate) fn spawn_proximity_watch( // The phone answering on the LAN is presence too. The // window matches the pill sweeper's "we are in contact" // threshold. - peer_contact_fresh: crate::ble::peer_contact_age_ms() < AWAY_GRACE_MS, + peer_contact_fresh: crate::presence::peer_contact_age_ms() < AWAY_GRACE_MS, // Only costs a D-Bus round-trip when a lock is possible. media_active: auto_lock_on && vortex_l3_daemon::core::media_runtime::any_player_playing().await, diff --git a/linux/ui-tauri/src-tauri/src/send_to_phone.rs b/linux/ui-tauri/src-tauri/src/send_to_phone.rs index 1c86508..353bc96 100644 --- a/linux/ui-tauri/src-tauri/src/send_to_phone.rs +++ b/linux/ui-tauri/src-tauri/src/send_to_phone.rs @@ -85,7 +85,7 @@ pub fn send(text: &str) -> Result<(), String> { } // Both transports, and now rather than on the next beat: the user is // reaching for the phone as they click. - crate::ble::state_nudge().notify_one(); + crate::presence::state_nudge().notify_one(); if let Some(n) = crate::SYNC_NUDGE.get() { n.notify_one(); } diff --git a/linux/ui-tauri/src-tauri/src/tray.rs b/linux/ui-tauri/src-tauri/src/tray.rs index 4ed1403..4037529 100644 --- a/linux/ui-tauri/src-tauri/src/tray.rs +++ b/linux/ui-tauri/src-tauri/src/tray.rs @@ -1,290 +1,25 @@ -//! System-tray icon + menu, spoken as StatusNotifierItem ourselves (ksni). +//! The system-tray icon and menu — one API, two implementations. //! -//! NOT Tauri's tray, and the reason is the click. On Linux Tauri goes through -//! libayatana-appindicator, whose D-Bus item exposes no `Activate` method at -//! all — only `Scroll` and `SecondaryActivate`. GNOME's appindicator extension -//! calls `Activate` on a DOUBLE click and disables the gesture outright when -//! the method is missing (`supportsActivation === false`), so -//! double-click-to-open — what every other tray app does, Telegram included — -//! could not be made to work from the app side. `on_tray_icon_event` never -//! fires there either: tray-icon documents click events as unsupported on -//! Linux. Speaking the spec ourselves puts `Activate` back on the item, and -//! with it the double click (KDE and XFCE activate on a single click). +//! The two desktops disagree about what a tray even is, so this is a facade +//! rather than a shared implementation: //! -//! `run()`'s setup hook calls [`setup`] once at startup. - -use std::sync::{LazyLock, Mutex, OnceLock}; - -use ksni::{menu::StandardItem, Handle, Icon, MenuItem, ToolTip, Tray, TrayMethods}; - -use vortex_l3_daemon::core::appstate::{AppState, EarbudsInfo}; - -use crate::{CmdChannel, UiCmd}; - -/// White monochrome BRAND SPIRAL for the status area. Like Telegram / Cursor, -/// we ship ONE fixed light icon rather than swapping per theme: the -/// GNOME/Ubuntu top bar is dark even in light mode, and Linux SNI hosts render -/// the pixmap as-is (no auto-recolor), so a single white glyph reads -/// everywhere. Embedded via include_bytes so it works from the standalone prod -/// binary. -static ICON: LazyLock> = LazyLock::new(|| { - let Ok(img) = image::load_from_memory_with_format( - include_bytes!("../icons/tray.png"), - image::ImageFormat::Png, - ) else { - tracing::warn!("tray: icon failed to decode; falling back to a themed name"); - return Vec::new(); - }; - // Offer panel sizes rather than the 512px source: the host picks the one it - // wants and the pixmap crosses D-Bus on every read, so a megabyte of ARGB - // per icon change is a megabyte wasted. 64 covers a 1x panel, 128 a HiDPI - // one. - [64u32, 128] - .iter() - .map(|&s| { - let scaled = img.resize_exact(s, s, image::imageops::FilterType::Lanczos3); - let mut data = scaled.into_rgba8().into_vec(); - for px in data.chunks_exact_mut(4) { - px.rotate_right(1); // RGBA → ARGB32, which is what the spec asks for - } - Icon { width: s as i32, height: s as i32, data } - }) - .collect() -}); - -/// The live tray. Its fields ARE the rendered menu: ksni asks for `menu()` -/// again after every [`Handle::update`], so a battery change is a field write. -struct VortexTray { - app: tauri::AppHandle, - /// Battery readout rows, as plain WORD labels ("Buds"/"Phone") rather than - /// emoji or item icons: SNI hosts render neither custom menu-item icons nor - /// color emoji reliably — both come out blank on dark themes. Plain text - /// renders in the theme color everywhere. - buds: String, - phone: String, - tooltip: String, -} - -impl Tray for VortexTray { - fn id(&self) -> String { - "vortex".into() - } - - fn title(&self) -> String { - "Vortex".into() - } - - fn icon_pixmap(&self) -> Vec { - ICON.clone() - } - - fn tool_tip(&self) -> ToolTip { - ToolTip { - title: self.tooltip.clone(), - ..Default::default() - } - } - - /// Double-click on GNOME, single click on KDE/XFCE. - fn activate(&mut self, _x: i32, _y: i32) { - crate::window::toggle_main(&self.app); - } - - /// Middle click — the same thing, since the alternative is nothing at all. - fn secondary_activate(&mut self, _x: i32, _y: i32) { - crate::window::toggle_main(&self.app); - } - - fn menu(&self) -> Vec> { - vec![ - StandardItem { - label: self.phone.clone(), - enabled: false, - ..Default::default() - } - .into(), - StandardItem { - label: self.buds.clone(), - enabled: false, - ..Default::default() - } - .into(), - StandardItem { - label: "Switch earbuds".into(), - // Toggle the buds between this laptop and the phone. The - // callback must not block — the menu is frozen until it - // returns — so it only posts to the worker. - activate: Box::new(|t: &mut Self| { - use tauri::Manager; - if let Some(ch) = t.app.try_state::() { - let _ = ch.0.send(UiCmd::ToggleEarbuds); - } - }), - ..Default::default() - } - .into(), - StandardItem { - label: "Show".into(), - activate: Box::new(|t: &mut Self| crate::window::present_main(&t.app)), - ..Default::default() - } - .into(), - StandardItem { - label: "Quit".into(), - activate: Box::new(|t: &mut Self| t.app.exit(0)), - ..Default::default() - } - .into(), - ] - } -} - -/// Set once the item is registered with the host; `None` until then, and -/// forever on a desktop with no StatusNotifierWatcher at all. -static TRAY: OnceLock> = OnceLock::new(); - -/// Phone-side fields cached from the last inbound AppState, so a local-only -/// refresh (BlueZ rescan — knows nothing about the phone) can still redraw -/// both rows without wiping the phone's data. -struct PhoneSnap { - name: Option, - battery: Option, - charging: bool, - earbuds: Option, - /// When this snapshot arrived, so the tray can stop believing it. - at: std::time::Instant, -} - -/// How long a phone snapshot is worth rendering. The phone's own foreground -/// notification already ages its copy of the laptop's state out at this -/// threshold — the tray simply never did the same in the other direction, so a -/// phone switched off at night left the tray cheerfully reporting "Redmi 9 -/// 67% ⚡" and "Buds 80% (phone)" until the app was next restarted. -const PHONE_SNAP_FRESH: std::time::Duration = std::time::Duration::from_secs(30); - -static LAST_PHONE: Mutex> = Mutex::new(None); - -/// Redraw the tray tooltip + the two battery menu rows. The single render -/// path for all three triggers: inbound phone state over LAN (lan.rs), -/// inbound phone state over BLE (lan_state.rs), and the UI's 5-second local -/// earbuds rescan (cmd_earbuds.rs). The last one is what makes the buds row -/// appear the moment they connect to the laptop, instead of sitting on -/// "Buds --" until the phone's next heartbeat happens to arrive. -pub(crate) fn update_battery_rows( - local_earbuds: Option<&EarbudsInfo>, - phone: Option<&AppState>, -) { - // A fresh phone state refreshes the cache; a local-only refresh reuses it. - let mut cache = LAST_PHONE.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(p) = phone { - *cache = Some(PhoneSnap { - name: p.name.clone(), - battery: p.battery, - charging: p.charging, - earbuds: p.earbuds.clone(), - at: std::time::Instant::now(), - }); - } - // Stale is the same as absent: better an honest "--" than a battery - // percentage from hours ago presented as current. - if cache - .as_ref() - .is_some_and(|s| s.at.elapsed() > PHONE_SNAP_FRESH) - { - *cache = None; - } - let snap = &*cache; - - let pf = |v: Option| v.map(|x| format!("{x}%")).unwrap_or_else(|| "--".to_string()); - let trunc = |s: &str, max: usize| -> String { - if s.chars().count() > max { - let head: String = s.chars().take(max.saturating_sub(3)).collect(); - format!("{}...", head.trim_end()) - } else { - s.to_string() - } - }; - - let phone_buds = snap.as_ref().and_then(|s| s.earbuds.as_ref()); - let laptop_owns = local_earbuds.map(|e| e.connected).unwrap_or(false); - let phone_has = phone_buds.map(|e| e.connected).unwrap_or(false); - let buds_pct = if laptop_owns { - local_earbuds.and_then(|e| e.battery) - } else { - phone_buds.and_then(|e| e.battery) - }; - let owner = if laptop_owns { - "laptop" - } else if phone_has { - "phone" - } else { - "—" - }; - let tip = format!( - "Vortex 🎧 {} ({}) 📱 {}", - pf(buds_pct), - owner, - pf(snap.as_ref().and_then(|s| s.battery)) - ); - let buds_name = if laptop_owns { - local_earbuds.map(|e| e.name.clone()) - } else { - phone_buds.map(|e| e.name.clone()) - } - .filter(|n| !n.is_empty()) - .or_else(|| vortex_l3_daemon::core::earbuds_store::load().map(|s| s.name)) - .unwrap_or_else(|| "Buds".to_string()); - let buds_text = format!("{} {} ({})", trunc(&buds_name, 18), pf(buds_pct), owner); - // ⚡ (U+26A1, present in DejaVu Sans — portable) marks a charging device. - // No phone seen yet this session → leave the row on its "Phone --" - // placeholder rather than inventing a name. - let phone_text = snap.as_ref().map(|s| { - let bolt = if s.charging { " \u{26A1}" } else { "" }; - let name = s - .name - .clone() - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| "Phone".to_string()); - format!("{} {}{}", trunc(&name, 18), pf(s.battery), bolt) - }); - drop(cache); - - let Some(handle) = TRAY.get().cloned() else { - return; - }; - tauri::async_runtime::spawn(async move { - handle - .update(move |t: &mut VortexTray| { - t.buds = buds_text; - t.tooltip = tip; - if let Some(pt) = phone_text { - t.phone = pt; - } - }) - .await; - }); -} - -pub(crate) fn setup(app: &tauri::App) -> tauri::Result<()> { - let handle = app.handle().clone(); - // Registering with the host is a D-Bus round-trip, so it happens off the - // setup hook. A desktop with no StatusNotifierWatcher (a bare wlroots - // session, GNOME without the appindicator extension) simply has no tray — - // the app keeps running headless in exactly the way it already did. - tauri::async_runtime::spawn(async move { - let tray = VortexTray { - app: handle, - buds: "Buds --".into(), - phone: "Phone --".into(), - tooltip: "Vortex".into(), - }; - match tray.spawn().await { - Ok(h) => { - let _ = TRAY.set(h); - tracing::info!("tray: StatusNotifierItem registered"); - } - Err(e) => tracing::warn!("tray: no status-notifier host ({e}); running without a tray"), - } - }); - Ok(()) -} +//! * [`crate::tray_ksni`] speaks **StatusNotifierItem** over D-Bus directly. +//! That is what a modern Linux desktop actually consumes, and speaking it +//! ourselves is what makes the menu render — with live battery rows — on +//! hosts where Tauri's own tray came out blank. +//! * [`crate::tray_tauri`] uses **Tauri's `TrayIconBuilder`**, which is the +//! Windows notification area. There is no StatusNotifierItem there to talk +//! to, and no D-Bus to talk over. +//! +//! Both expose exactly [`setup`] and [`update_battery_rows`], so nothing above +//! this line knows which one it is talking to. +//! +//! The icon differs too, and deliberately: Linux gets a white monochrome glyph +//! because the top bar is dark even in light mode and SNI hosts do not recolor, +//! while Windows gets the full-colour brand icon because its taskbar is light +//! by default and does not recolor either. Each implementation carries its own. + +#[cfg(target_os = "linux")] +pub(crate) use crate::tray_ksni::{setup, update_battery_rows}; +#[cfg(not(target_os = "linux"))] +pub(crate) use crate::tray_tauri::{setup, update_battery_rows}; diff --git a/linux/ui-tauri/src-tauri/src/tray_ksni.rs b/linux/ui-tauri/src-tauri/src/tray_ksni.rs new file mode 100644 index 0000000..4ed1403 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/tray_ksni.rs @@ -0,0 +1,290 @@ +//! System-tray icon + menu, spoken as StatusNotifierItem ourselves (ksni). +//! +//! NOT Tauri's tray, and the reason is the click. On Linux Tauri goes through +//! libayatana-appindicator, whose D-Bus item exposes no `Activate` method at +//! all — only `Scroll` and `SecondaryActivate`. GNOME's appindicator extension +//! calls `Activate` on a DOUBLE click and disables the gesture outright when +//! the method is missing (`supportsActivation === false`), so +//! double-click-to-open — what every other tray app does, Telegram included — +//! could not be made to work from the app side. `on_tray_icon_event` never +//! fires there either: tray-icon documents click events as unsupported on +//! Linux. Speaking the spec ourselves puts `Activate` back on the item, and +//! with it the double click (KDE and XFCE activate on a single click). +//! +//! `run()`'s setup hook calls [`setup`] once at startup. + +use std::sync::{LazyLock, Mutex, OnceLock}; + +use ksni::{menu::StandardItem, Handle, Icon, MenuItem, ToolTip, Tray, TrayMethods}; + +use vortex_l3_daemon::core::appstate::{AppState, EarbudsInfo}; + +use crate::{CmdChannel, UiCmd}; + +/// White monochrome BRAND SPIRAL for the status area. Like Telegram / Cursor, +/// we ship ONE fixed light icon rather than swapping per theme: the +/// GNOME/Ubuntu top bar is dark even in light mode, and Linux SNI hosts render +/// the pixmap as-is (no auto-recolor), so a single white glyph reads +/// everywhere. Embedded via include_bytes so it works from the standalone prod +/// binary. +static ICON: LazyLock> = LazyLock::new(|| { + let Ok(img) = image::load_from_memory_with_format( + include_bytes!("../icons/tray.png"), + image::ImageFormat::Png, + ) else { + tracing::warn!("tray: icon failed to decode; falling back to a themed name"); + return Vec::new(); + }; + // Offer panel sizes rather than the 512px source: the host picks the one it + // wants and the pixmap crosses D-Bus on every read, so a megabyte of ARGB + // per icon change is a megabyte wasted. 64 covers a 1x panel, 128 a HiDPI + // one. + [64u32, 128] + .iter() + .map(|&s| { + let scaled = img.resize_exact(s, s, image::imageops::FilterType::Lanczos3); + let mut data = scaled.into_rgba8().into_vec(); + for px in data.chunks_exact_mut(4) { + px.rotate_right(1); // RGBA → ARGB32, which is what the spec asks for + } + Icon { width: s as i32, height: s as i32, data } + }) + .collect() +}); + +/// The live tray. Its fields ARE the rendered menu: ksni asks for `menu()` +/// again after every [`Handle::update`], so a battery change is a field write. +struct VortexTray { + app: tauri::AppHandle, + /// Battery readout rows, as plain WORD labels ("Buds"/"Phone") rather than + /// emoji or item icons: SNI hosts render neither custom menu-item icons nor + /// color emoji reliably — both come out blank on dark themes. Plain text + /// renders in the theme color everywhere. + buds: String, + phone: String, + tooltip: String, +} + +impl Tray for VortexTray { + fn id(&self) -> String { + "vortex".into() + } + + fn title(&self) -> String { + "Vortex".into() + } + + fn icon_pixmap(&self) -> Vec { + ICON.clone() + } + + fn tool_tip(&self) -> ToolTip { + ToolTip { + title: self.tooltip.clone(), + ..Default::default() + } + } + + /// Double-click on GNOME, single click on KDE/XFCE. + fn activate(&mut self, _x: i32, _y: i32) { + crate::window::toggle_main(&self.app); + } + + /// Middle click — the same thing, since the alternative is nothing at all. + fn secondary_activate(&mut self, _x: i32, _y: i32) { + crate::window::toggle_main(&self.app); + } + + fn menu(&self) -> Vec> { + vec![ + StandardItem { + label: self.phone.clone(), + enabled: false, + ..Default::default() + } + .into(), + StandardItem { + label: self.buds.clone(), + enabled: false, + ..Default::default() + } + .into(), + StandardItem { + label: "Switch earbuds".into(), + // Toggle the buds between this laptop and the phone. The + // callback must not block — the menu is frozen until it + // returns — so it only posts to the worker. + activate: Box::new(|t: &mut Self| { + use tauri::Manager; + if let Some(ch) = t.app.try_state::() { + let _ = ch.0.send(UiCmd::ToggleEarbuds); + } + }), + ..Default::default() + } + .into(), + StandardItem { + label: "Show".into(), + activate: Box::new(|t: &mut Self| crate::window::present_main(&t.app)), + ..Default::default() + } + .into(), + StandardItem { + label: "Quit".into(), + activate: Box::new(|t: &mut Self| t.app.exit(0)), + ..Default::default() + } + .into(), + ] + } +} + +/// Set once the item is registered with the host; `None` until then, and +/// forever on a desktop with no StatusNotifierWatcher at all. +static TRAY: OnceLock> = OnceLock::new(); + +/// Phone-side fields cached from the last inbound AppState, so a local-only +/// refresh (BlueZ rescan — knows nothing about the phone) can still redraw +/// both rows without wiping the phone's data. +struct PhoneSnap { + name: Option, + battery: Option, + charging: bool, + earbuds: Option, + /// When this snapshot arrived, so the tray can stop believing it. + at: std::time::Instant, +} + +/// How long a phone snapshot is worth rendering. The phone's own foreground +/// notification already ages its copy of the laptop's state out at this +/// threshold — the tray simply never did the same in the other direction, so a +/// phone switched off at night left the tray cheerfully reporting "Redmi 9 +/// 67% ⚡" and "Buds 80% (phone)" until the app was next restarted. +const PHONE_SNAP_FRESH: std::time::Duration = std::time::Duration::from_secs(30); + +static LAST_PHONE: Mutex> = Mutex::new(None); + +/// Redraw the tray tooltip + the two battery menu rows. The single render +/// path for all three triggers: inbound phone state over LAN (lan.rs), +/// inbound phone state over BLE (lan_state.rs), and the UI's 5-second local +/// earbuds rescan (cmd_earbuds.rs). The last one is what makes the buds row +/// appear the moment they connect to the laptop, instead of sitting on +/// "Buds --" until the phone's next heartbeat happens to arrive. +pub(crate) fn update_battery_rows( + local_earbuds: Option<&EarbudsInfo>, + phone: Option<&AppState>, +) { + // A fresh phone state refreshes the cache; a local-only refresh reuses it. + let mut cache = LAST_PHONE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(p) = phone { + *cache = Some(PhoneSnap { + name: p.name.clone(), + battery: p.battery, + charging: p.charging, + earbuds: p.earbuds.clone(), + at: std::time::Instant::now(), + }); + } + // Stale is the same as absent: better an honest "--" than a battery + // percentage from hours ago presented as current. + if cache + .as_ref() + .is_some_and(|s| s.at.elapsed() > PHONE_SNAP_FRESH) + { + *cache = None; + } + let snap = &*cache; + + let pf = |v: Option| v.map(|x| format!("{x}%")).unwrap_or_else(|| "--".to_string()); + let trunc = |s: &str, max: usize| -> String { + if s.chars().count() > max { + let head: String = s.chars().take(max.saturating_sub(3)).collect(); + format!("{}...", head.trim_end()) + } else { + s.to_string() + } + }; + + let phone_buds = snap.as_ref().and_then(|s| s.earbuds.as_ref()); + let laptop_owns = local_earbuds.map(|e| e.connected).unwrap_or(false); + let phone_has = phone_buds.map(|e| e.connected).unwrap_or(false); + let buds_pct = if laptop_owns { + local_earbuds.and_then(|e| e.battery) + } else { + phone_buds.and_then(|e| e.battery) + }; + let owner = if laptop_owns { + "laptop" + } else if phone_has { + "phone" + } else { + "—" + }; + let tip = format!( + "Vortex 🎧 {} ({}) 📱 {}", + pf(buds_pct), + owner, + pf(snap.as_ref().and_then(|s| s.battery)) + ); + let buds_name = if laptop_owns { + local_earbuds.map(|e| e.name.clone()) + } else { + phone_buds.map(|e| e.name.clone()) + } + .filter(|n| !n.is_empty()) + .or_else(|| vortex_l3_daemon::core::earbuds_store::load().map(|s| s.name)) + .unwrap_or_else(|| "Buds".to_string()); + let buds_text = format!("{} {} ({})", trunc(&buds_name, 18), pf(buds_pct), owner); + // ⚡ (U+26A1, present in DejaVu Sans — portable) marks a charging device. + // No phone seen yet this session → leave the row on its "Phone --" + // placeholder rather than inventing a name. + let phone_text = snap.as_ref().map(|s| { + let bolt = if s.charging { " \u{26A1}" } else { "" }; + let name = s + .name + .clone() + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "Phone".to_string()); + format!("{} {}{}", trunc(&name, 18), pf(s.battery), bolt) + }); + drop(cache); + + let Some(handle) = TRAY.get().cloned() else { + return; + }; + tauri::async_runtime::spawn(async move { + handle + .update(move |t: &mut VortexTray| { + t.buds = buds_text; + t.tooltip = tip; + if let Some(pt) = phone_text { + t.phone = pt; + } + }) + .await; + }); +} + +pub(crate) fn setup(app: &tauri::App) -> tauri::Result<()> { + let handle = app.handle().clone(); + // Registering with the host is a D-Bus round-trip, so it happens off the + // setup hook. A desktop with no StatusNotifierWatcher (a bare wlroots + // session, GNOME without the appindicator extension) simply has no tray — + // the app keeps running headless in exactly the way it already did. + tauri::async_runtime::spawn(async move { + let tray = VortexTray { + app: handle, + buds: "Buds --".into(), + phone: "Phone --".into(), + tooltip: "Vortex".into(), + }; + match tray.spawn().await { + Ok(h) => { + let _ = TRAY.set(h); + tracing::info!("tray: StatusNotifierItem registered"); + } + Err(e) => tracing::warn!("tray: no status-notifier host ({e}); running without a tray"), + } + }); + Ok(()) +} diff --git a/linux/ui-tauri/src-tauri/src/tray_tauri.rs b/linux/ui-tauri/src-tauri/src/tray_tauri.rs new file mode 100644 index 0000000..5e12981 --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/tray_tauri.rs @@ -0,0 +1,249 @@ +//! System-tray icon + menu. Split out of lib.rs; `run()`'s setup hook +//! calls [`setup`] once at startup. + +use std::sync::Mutex; + +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, +}; + +use vortex_l3_daemon::core::appstate::{AppState, EarbudsInfo}; + +use crate::{CmdChannel, UiCmd}; + +/// The tray's battery-readout menu items, kept in managed state so the +/// heartbeat can update their text as batteries change. Two plain `MenuItem` +/// rows (earbuds + phone) with word labels ("Buds"/"Phone") instead of emoji +/// or image icons: the Linux backend (libayatana-appindicator) renders +/// neither custom menu-item icons (IconMenuItem) nor color emoji — both come +/// out blank/invisible on dark themes. Plain text renders in the theme color +/// (visible in dark mode) AND updates live via `set_text`. +pub(crate) struct BatteryMenuItem { + pub(crate) buds: MenuItem, + pub(crate) phone: MenuItem, +} + +/// Phone-side fields cached from the last inbound AppState, so a local-only +/// refresh (BlueZ rescan — knows nothing about the phone) can still redraw +/// both rows without wiping the phone's data. +struct PhoneSnap { + name: Option, + battery: Option, + charging: bool, + earbuds: Option, +} + +static LAST_PHONE: Mutex> = Mutex::new(None); + +/// Redraw the tray tooltip + the two battery menu rows. The single render +/// path for all three triggers: inbound phone state over LAN (lan.rs), +/// inbound phone state over BLE (lan_state.rs), and the UI's 5-second local +/// earbuds rescan (cmd_earbuds.rs). The last one is what makes the buds row +/// appear the moment they connect to the laptop, instead of sitting on +/// "Buds --" until the phone's next heartbeat happens to arrive. +/// The app handle, stashed at [`setup`]. +/// +/// The ksni implementation keeps its own tray handle in a static for the same +/// reason, and its `update_battery_rows` therefore takes no `app`. Matching +/// that signature is what lets one facade serve both — and every caller is +/// somewhere that has no `AppHandle` to hand anyway. +static APP: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub(crate) fn update_battery_rows( + local_earbuds: Option<&EarbudsInfo>, + phone: Option<&AppState>, +) { + // Before setup has run there is no tray to update; the next heartbeat does + // it. This is startup ordering, not an error. + let Some(app) = APP.get() else { return }; + // A fresh phone state refreshes the cache; a local-only refresh reuses it. + let mut cache = LAST_PHONE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(p) = phone { + *cache = Some(PhoneSnap { + name: p.name.clone(), + battery: p.battery, + charging: p.charging, + earbuds: p.earbuds.clone(), + }); + } + let snap = &*cache; + + let pf = |v: Option| v.map(|x| format!("{x}%")).unwrap_or_else(|| "--".to_string()); + let trunc = |s: &str, max: usize| -> String { + if s.chars().count() > max { + let head: String = s.chars().take(max.saturating_sub(3)).collect(); + format!("{}...", head.trim_end()) + } else { + s.to_string() + } + }; + + let phone_buds = snap.as_ref().and_then(|s| s.earbuds.as_ref()); + let laptop_owns = local_earbuds.map(|e| e.connected).unwrap_or(false); + let phone_has = phone_buds.map(|e| e.connected).unwrap_or(false); + let buds_pct = if laptop_owns { + local_earbuds.and_then(|e| e.battery) + } else { + phone_buds.and_then(|e| e.battery) + }; + let owner = if laptop_owns { + "laptop" + } else if phone_has { + "phone" + } else { + "—" + }; + let tip = format!( + "Vortex 🎧 {} ({}) 📱 {}", + pf(buds_pct), + owner, + pf(snap.as_ref().and_then(|s| s.battery)) + ); + if let Some(tray) = app.tray_by_id("vortex") { + let _ = tray.set_tooltip(Some(tip)); + } + let buds_name = if laptop_owns { + local_earbuds.map(|e| e.name.clone()) + } else { + phone_buds.map(|e| e.name.clone()) + } + .filter(|n| !n.is_empty()) + .or_else(|| vortex_l3_daemon::core::earbuds_store::load().map(|s| s.name)) + .unwrap_or_else(|| "Buds".to_string()); + let buds_text = format!("{} {} ({})", trunc(&buds_name, 18), pf(buds_pct), owner); + // ⚡ (U+26A1, present in DejaVu Sans — portable) marks a charging device. + // No phone seen yet this session → leave the row on its "Phone --" + // placeholder rather than inventing a name. + let phone_text = snap.as_ref().map(|s| { + let bolt = if s.charging { " \u{26A1}" } else { "" }; + let name = s + .name + .clone() + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "Phone".to_string()); + format!("{} {}{}", trunc(&name, 18), pf(s.battery), bolt) + }); + drop(cache); + // Menu mutations must run on the main thread. + let app_menu = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(item) = app_menu.try_state::() { + let _ = item.buds.set_text(buds_text); + if let Some(pt) = phone_text { + let _ = item.phone.set_text(pt); + } + } + }); +} + +pub(crate) fn setup(app: &tauri::App) -> tauri::Result<()> { + use tauri::Manager as _; + let _ = APP.set(app.handle().clone()); + // System tray (Telegram-style): icon in the top status area; + // left-click shows/hides the main window; right-click → menu. + // Battery readout: two disabled rows (earbuds + phone) with plain + // WORD labels. We deliberately avoid glyph/icon prefixes: color + // emoji (🎧/📱) render blank in the appindicator menu, and the + // monochrome icon glyphs that do render here (Font Awesome / Nerd + // Font PUA, e.g. U+F025/U+F10B) are NOT installed on a stock Linux + // box, so they'd show empty boxes on other machines. Plain text + // renders everywhere in the theme color. Refreshed from heartbeat. + let buds_i = MenuItem::with_id( + app, "buds_batt", "Buds --", false, None::<&str>, + )?; + let phone_i = MenuItem::with_id( + app, "phone_batt", "Phone --", false, None::<&str>, + )?; + // Earbuds hand-off is the audio backend plus BlueZ, so off Linux there is + // nothing behind this row. A tray item is not a Tauri command — a click has + // no return value and nowhere to report an error — so an unsupported one + // cannot say so and would simply do nothing. Leave it out instead. + #[cfg(target_os = "linux")] + let switch_i = + MenuItem::with_id(app, "switch", "Switch earbuds", true, None::<&str>)?; + let show_i = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + #[cfg(target_os = "linux")] + let menu = Menu::with_items( + app, + &[&phone_i, &buds_i, &switch_i, &show_i, &quit_i], + )?; + #[cfg(not(target_os = "linux"))] + let menu = Menu::with_items(app, &[&phone_i, &buds_i, &show_i, &quit_i])?; + app.manage(BatteryMenuItem { buds: buds_i, phone: phone_i }); + // The status-area icon, and it has to differ per platform. + // + // Linux: white monochrome BRAND SPIRAL. Like Telegram / Cursor, we ship ONE + // fixed light icon rather than swapping per theme — the GNOME/Ubuntu top bar + // is dark even in light mode, and Linux SNI hosts render a raw PNG as-is + // (no auto-recolor), so a single white glyph reads everywhere. + // + // Windows: the FULL-COLOUR icon. That same white glyph is invisible there, + // which is precisely what the first Windows run showed — a tray entry that + // was present and clickable with nothing drawn in it. Windows 11's taskbar + // is light under the default theme, it does not recolor notification-area + // icons either, and full-colour is the convention every other tray app + // follows. 64px rather than the 512px master so the downscale to 16/24/32 + // starts from something closer to the target. + // + // Embedded via include_bytes so it works from the standalone prod binary. + #[cfg(target_os = "windows")] + let tray_png: &[u8] = include_bytes!("../icons/64x64.png"); + #[cfg(not(target_os = "windows"))] + let tray_png: &[u8] = include_bytes!("../icons/tray.png"); + let tray_icon = tauri::image::Image::from_bytes(tray_png) + .unwrap_or_else(|_| app.default_window_icon().unwrap().clone()); + let _ = TrayIconBuilder::with_id("vortex") + .icon(tray_icon) + .tooltip("Vortex") + .menu(&menu) + .on_menu_event(|app, event| match event.id.as_ref() { + // Only built on Linux (see the menu above), so only handled there. + #[cfg(target_os = "linux")] + "switch" => { + // Toggle the buds between this laptop and the phone. + use tauri::Manager; + if let Some(ch) = app.try_state::() { + let _ = ch.0.send(UiCmd::ToggleEarbuds); + } + } + "show" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + "quit" => { + // Before the process goes. A FUSE mount outlives its server + // and answers ENOTCONN afterwards, so leaving one behind is + // worse than not having mounted at all; a ProjFS instance is + // tidier but still ours to stop. + crate::fs_mount::unmount_on_exit(); + app.exit(0) + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(w) = app.get_webview_window("main") { + if w.is_visible().unwrap_or(false) { + let _ = w.hide(); + } else { + let _ = w.show(); + let _ = w.set_focus(); + } + } + } + }) + .build(app)?; + + Ok(()) +} diff --git a/linux/ui-tauri/src-tauri/src/universal_control.rs b/linux/ui-tauri/src-tauri/src/universal_control.rs index d6734fc..2196fde 100644 --- a/linux/ui-tauri/src-tauri/src/universal_control.rs +++ b/linux/ui-tauri/src-tauri/src/universal_control.rs @@ -437,6 +437,9 @@ fn ensure_injector_health() { crate::mirror_inject::spawn_health_check(|| RUNNING.load(Ordering::SeqCst)); } +/// Linux-only: the fallback it registers is a BlueZ HID profile over D-Bus. +/// Elsewhere Universal Control rides the adb transport, which needs none of it. +#[cfg(target_os = "linux")] pub(crate) fn ensure_bt_hid() { if BT_HID_INIT.swap(true, Ordering::SeqCst) { return; @@ -465,6 +468,8 @@ static HOGP_INIT: AtomicBool = AtomicBool::new(false); /// peripheral announcing itself as a mouse; leaving that up permanently would /// put the laptop on every nearby scanner's list for a feature the user is not /// using. `uc_stop` withdraws it. +/// Linux-only: HID-over-GATT means a BlueZ GATT server. +#[cfg(target_os = "linux")] pub(crate) fn ensure_hogp() { if HOGP_INIT.swap(true, Ordering::SeqCst) { return; @@ -506,7 +511,9 @@ pub(crate) fn stop_hogp() { if !HOGP_INIT.swap(false, Ordering::SeqCst) { return; } + #[cfg(target_os = "linux")] let Some(server) = crate::mirror_inject::get_hogp() else { return }; + #[cfg(target_os = "linux")] tauri::async_runtime::spawn(async move { server.stop().await; }); diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index a09b996..fad8dea 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -179,6 +179,7 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { // teardown BlueZ keeps the GATT connection, the phone goes on believing a // peer is attached and stops advertising discoverably, and the next login's // instance scans for something it will never see. + #[cfg(target_os = "linux")] rt.spawn(async { use tokio::signal::unix::{signal, SignalKind}; let (Ok(mut term), Ok(mut int)) = ( @@ -786,7 +787,10 @@ pub(crate) fn run_worker(app: AppHandle, cmd_rx: Receiver) { Ok(g) => g, Err(_) => continue, }; + #[cfg(target_os = "linux")] let ble_live = !auto_ble_writers.lock().await.is_empty(); + #[cfg(not(target_os = "linux"))] + let ble_live = crate::ble_portable::link_is_up(); // Cooldown: mdns-sd re-resolves the service every few // seconds even while we're already connected, so this // gate — not the resolve rate — is what decides how diff --git a/linux/ui-tauri/src-tauri/src/x11_focus.rs b/linux/ui-tauri/src-tauri/src/x11_focus.rs index 5d091e2..0177473 100644 --- a/linux/ui-tauri/src-tauri/src/x11_focus.rs +++ b/linux/ui-tauri/src-tauri/src/x11_focus.rs @@ -24,6 +24,19 @@ use std::sync::atomic::{AtomicU32, Ordering}; /// `cache` holds the resolved X id between calls: the tree walk costs a /// round-trip PER window, which is both slow and pointless for a window we only /// ever hide (close is `hide()` + `prevent_close()`, so the id outlives it). +/// Nothing to force where there is no X server: the window manager's own focus +/// rules apply, and Tauri's `set_focus()` is honoured. The X11 version exists +/// because Mutter grants focus and then takes it back a beat later. +#[cfg(not(target_os = "linux"))] +pub(crate) fn raise_and_focus( + matches: fn(&str) -> bool, + cache: &'static AtomicU32, + tag: &'static str, +) { + let _ = (matches, cache, tag); +} + +#[cfg(target_os = "linux")] pub(crate) fn raise_and_focus( matches: fn(&str) -> bool, cache: &'static AtomicU32, From b14894c3f0f8ad3324b0202b5663d6acd2ef0c60 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sat, 12 Sep 2026 21:32:03 +0200 Subject: [PATCH 67/71] fix(windows): keep the log out of the temp directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's `applog` resolves its directory from `XDG_STATE_HOME`, else `$HOME/.local/state`, else the temp directory. Neither variable is set on Windows, so the log fell all the way through to `%TEMP%\vortex` — where a disk cleanup is entitled to delete exactly the evidence a first run on an untested platform exists to leave. It is also not where I told the user to look. Linux keeps XDG state, which is the right home for a log and better than the cache directory it used before. Everywhere else goes through the platform seam, which answers `%LOCALAPPDATA%\Vortex` — beside the app's other state, and the path the Windows notes have been quoting all along. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/applog.rs | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/applog.rs b/linux/ui-tauri/src-tauri/src/applog.rs index eb9fa16..b99ea3c 100644 --- a/linux/ui-tauri/src-tauri/src/applog.rs +++ b/linux/ui-tauri/src-tauri/src/applog.rs @@ -27,11 +27,28 @@ const MAX_LOG_BYTES: u64 = 8 * 1024 * 1024; const SIZE_CHECK_EVERY: u64 = 512; pub fn log_dir() -> PathBuf { - let base = std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .filter(|p| p.is_absolute()) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state"))); - base.unwrap_or_else(std::env::temp_dir).join("vortex") + // XDG state is the right home for a log on Linux — not cache, which a + // cleaner may delete, and deleting the log of the run that just failed is + // the opposite of useful. + #[cfg(target_os = "linux")] + { + let base = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state"))); + base.unwrap_or_else(std::env::temp_dir).join("vortex") + } + // Elsewhere, through the seam. Neither `XDG_STATE_HOME` nor `HOME` is set + // on Windows, so this fell all the way through to the temp directory — + // where a disk cleanup is entitled to remove exactly the evidence a first + // run on an untested platform exists to leave. The seam answers + // `%LOCALAPPDATA%\Vortex`, beside the app's other state. + #[cfg(not(target_os = "linux"))] + { + vortex_l3_daemon::core::platform::paths() + .logs() + .unwrap_or_else(|| std::env::temp_dir().join("vortex")) + } } pub fn log_path() -> PathBuf { From 39554b3440dd17fdd24af99232bd57bc8a6e0c41 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sun, 13 Sep 2026 08:43:20 +0200 Subject: [PATCH 68/71] fix(projfs): never hold a filesystem callback for twenty seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ProjFS callback occupies a pool thread for as long as it runs, and every Explorer action on the projection is a callback — so the protocol's 20 s reply timeout was also the length of time Explorer appears hung when the phone stops answering. A live run showed exactly that: a phone that went quiet mid transfer left the folder frozen, and the log ends with two requests expiring at the full twenty seconds, one after the other. Metadata operations — enumeration, placeholder info, name queries, open — are now bounded at 5 s and answer ERROR_TIMEOUT. That is still far longer than a listing takes on a working link (11 ms over Wi-Fi, ~2.5 s over BLE), so it only fires when something is genuinely wrong, and then a message in a moment beats a frozen window for twenty seconds. Hydration is deliberately NOT held to it: copying a large file over a slow link legitimately takes minutes, and ProjFS is built to render that as a slow copy rather than a hang. The distinction is the point — it is opening a folder that must never feel dead, not finishing a copy. Abandoning a request early is safe: `fs_link` keys its in-flight entry by request id and drops it when the late reply lands or when the session ends. This addresses the freeze, NOT the stall that triggered it. Why the phone stopped answering after ~16 MB of a 59 MB download is still open — its logcat had rolled over by the time I looked. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/fs_projfs.rs | 48 +++++++++++++++++++++-- linux/ui-tauri/src-tauri/src/worker.rs | 3 ++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/fs_projfs.rs b/linux/ui-tauri/src-tauri/src/fs_projfs.rs index 7c3f7c0..f3cbf13 100644 --- a/linux/ui-tauri/src-tauri/src/fs_projfs.rs +++ b/linux/ui-tauri/src-tauri/src/fs_projfs.rs @@ -55,7 +55,7 @@ use windows::core::{GUID, HRESULT, PCWSTR}; use windows::Win32::Foundation::{ ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_HOST_DOWN, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_HANDLE, ERROR_INVALID_PARAMETER, ERROR_IO_DEVICE, ERROR_NOT_SUPPORTED, - ERROR_WRITE_PROTECT, + ERROR_TIMEOUT, ERROR_WRITE_PROTECT, }; use windows::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY}; use windows::Win32::Storage::ProjectedFileSystem::*; @@ -83,6 +83,19 @@ const INSTANCE_ID: GUID = GUID::from_u128(0x7b1f9a42_5d33_4c86_9e10_2f6a4c8d1b57 /// use, since every chunk but the file's last must be a multiple of it. const HYDRATE_CHUNK: u32 = 1024 * 1024; +/// How long a metadata callback waits on the phone before giving up. +/// +/// Deliberately far below the protocol's own 20 s reply timeout: that one +/// bounds a REQUEST, this one bounds how long Explorer is allowed to look +/// frozen. Hydration is not held to it — copying a large file over a slow link +/// legitimately takes minutes, and ProjFS is built to show that as a slow copy +/// rather than a hang. +const META_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Locally-generated: the phone did not answer in time. Never on the wire, the +/// same way [`crate::fs_link::NO_LINK`] is not. +const TIMED_OUT: i32 = -1; + /// ProjFS threads. Above [`MAX_INFLIGHT`] on purpose — see the module docs: /// the shared semaphore is meant to be what limits concurrency, not the pool. const POOL_THREADS: u32 = MAX_INFLIGHT as u32 * 2; @@ -110,6 +123,8 @@ fn hresult_of(c: i32) -> HRESULT { code::ISDIR => ERROR_ACCESS_DENIED, code::ROFS => ERROR_WRITE_PROTECT, crate::fs_link::NO_LINK => ERROR_HOST_DOWN, + // Asked, and nothing came back in the time a file manager can wait. + TIMED_OUT => ERROR_TIMEOUT, // Includes `code::IO`, and anything a future peer invents. _ => ERROR_IO_DEVICE, }; @@ -215,9 +230,34 @@ impl Provider { self.rt.block_on(f) } + /// Run one metadata operation, and give up quickly if the phone is silent. + /// + /// A callback holds a ProjFS pool thread for as long as it runs, and every + /// Explorer action on the projection is a callback — so the protocol's 20 s + /// reply timeout is the length of time Explorer appears hung when the phone + /// stops answering. Observed exactly that: a phone that went quiet mid + /// transfer left the folder frozen until the whole timeout expired, twice. + /// + /// Five seconds is already far longer than a listing takes on a working + /// link (11 ms over Wi-Fi, ~2.5 s over BLE), so this only ever fires when + /// something is genuinely wrong — and then "the host is down" in a moment + /// beats a frozen window for twenty seconds. + /// + /// Abandoning the future is safe: `fs_link` keeps its in-flight entry keyed + /// by request id, and drops it when the late reply arrives or when the + /// session ends, so nothing accumulates. + fn block_meta(&self, f: impl std::future::Future>) -> Result { + self.block(async move { + match tokio::time::timeout(META_TIMEOUT, f).await { + Ok(v) => v, + Err(_) => Err(TIMED_OUT), + } + }) + } + /// Resolve a ProjFS-relative path to the peer's opaque address. fn resolve(&self, rel: &str) -> Result { - self.block(self.vfs.resolve(rel)) + self.block_meta(self.vfs.resolve(rel)) } } @@ -281,7 +321,7 @@ unsafe extern "system" fn start_enumeration( if !entry.is_dir { return HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0); } - let mut entries = match prov.block(prov.vfs.listing(&entry.path)) { + let mut entries = match prov.block_meta(prov.vfs.listing(&entry.path)) { Ok(v) => (*v).clone(), Err(c) => return hresult_of(c), }; @@ -440,7 +480,7 @@ unsafe extern "system" fn get_file_data( Ok(e) => e, Err(c) => return hresult_of(c), }; - let fh = match prov.block(prov.vfs.open(&entry.path)) { + let fh = match prov.block_meta(prov.vfs.open(&entry.path)) { Ok(fh) => fh, Err(c) => return hresult_of(c), }; diff --git a/linux/ui-tauri/src-tauri/src/worker.rs b/linux/ui-tauri/src-tauri/src/worker.rs index fad8dea..09d4c41 100644 --- a/linux/ui-tauri/src-tauri/src/worker.rs +++ b/linux/ui-tauri/src-tauri/src/worker.rs @@ -120,6 +120,9 @@ pub fn stop_screen_mirror(_state: State<'_, CmdChannel>) -> Result<(), String> { /// Backs off 2s → 30s for [`STARTUP_RETRY_WINDOW_SECS`] — long enough to /// outlast a slow boot or someone typing their keyring password — and only then /// calls it fatal, out loud. +// Every user of this is a BlueZ or Secret Service startup step, all of which +// are Linux-gated — so off Linux it is an unused macro rather than dead weight. +#[cfg(target_os = "linux")] macro_rules! retry_startup { ($app:expr, $what:expr, $call:expr) => {{ let started = std::time::Instant::now(); From d76a2cb42df071b31defbf4b485cfd7893fec38b Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sun, 13 Sep 2026 08:59:51 +0200 Subject: [PATCH 69/71] fix(windows): let the state beat end a session the listener cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconnecting after the phone restarted took minutes, and the cause is a gap I opened when the LAN heartbeat learned to relax to 4 minutes while BLE is up. That relaxation trusts `LINK_UP`, which is cleared when `connect_and_run` returns — and it only returns when the LISTENER returns. The listener cannot notice a phone that has gone: it is parked waiting for a notification that will never arrive, and WinRT can take minutes to surface the disconnect. So `LINK_UP` stayed true, the LAN heartbeat kept sleeping on the strength of a BLE link that no longer existed, and nothing reconnected until one or the other finally timed out. Before the relaxation this was invisible, because LAN ticked every 12 s regardless and covered for it. The beat already knows. It writes every 12 s and gives up after six consecutive failures, which is proof the link is gone — it just had nowhere to report it, so it returned and left the session standing. It now races the listener: whichever ends first ends the session, which clears `LINK_UP`, wakes the LAN heartbeat and lets the loop reconnect. A failing beat also retries at 2 s rather than at the beat interval, so a verdict takes ~12 s instead of ~72 s. A healthy link wants a heartbeat; a failing one wants an answer. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/ble_portable.rs | 35 +++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/ble_portable.rs b/linux/ui-tauri/src-tauri/src/ble_portable.rs index 5f96872..b6af0c1 100644 --- a/linux/ui-tauri/src-tauri/src/ble_portable.rs +++ b/linux/ui-tauri/src-tauri/src/ble_portable.rs @@ -336,12 +336,12 @@ async fn connect_and_run( crate::presence::touch_peer_contact(); publish_writers(&link, &transport, writers).await; - // The liveness beat. Runs until the listener below returns. - let beat = tokio::spawn(state_beat(link.clone(), transport.clone())); + // The liveness beat, which is also how a dead session is detected. + let mut beat = tokio::spawn(state_beat(link.clone(), transport.clone())); // Runs until the phone stops notifying — a disconnect, or the cipher // desync escalation dropping the session on purpose. - let r = audio_signal::run_listener( + let run = audio_signal::run_listener( &*link, transport, peer.peer_static_pub, @@ -362,8 +362,25 @@ async fn connect_and_run( Some(sinks.clipboard_offer.clone()), Some(sinks.handoff.clone()), Some(sinks.raw.clone()), - ) - .await; + ); + // Whichever ends first ends the session. + // + // The beat is a liveness PROBE, not just a state push: it writes every + // 12 s and gives up after six consecutive failures, which is proof the + // link is gone. The listener cannot prove that — it is parked waiting + // for a notification that will never arrive, and on a phone that + // restarted, WinRT can take minutes to surface the disconnect. + // + // Letting the beat end the session is what closes that gap. Without + // it, `LINK_UP` stayed true, the LAN heartbeat kept its relaxed + // 4-minute cadence on the strength of a BLE link that no longer + // existed, and reconnecting took minutes. That cost arrived with the + // relaxed cadence; before it, LAN ticked every 12 s regardless and + // covered for this. + let r = tokio::select! { + r = run => r.map_err(|e| e.to_string()), + _ = &mut beat => Err("state beat gave up — the link is dead".to_string()), + }; beat.abort(); let _ = link.disconnect().await; return match r { @@ -447,6 +464,8 @@ async fn state_beat(link: Arc, transport: Arc, transport: Arc Date: Sun, 13 Sep 2026 09:00:53 +0200 Subject: [PATCH 70/71] feat(windows): "Share via Vortex" in Explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counterpart of the Nautilus extension and the Dolphin ServiceMenu that install_linux.sh writes: a classic shell verb under HKEY_CURRENT_USER, on files (`*`) and on folders (`Directory`, which the Linux side shares too by zipping them on the way out). Registered by the app on every start rather than by an installer. Same reasoning as the toast AUMID shortcut: someone who unzipped a standalone .exe should get a working app, and this needs no admin rights and no packaging. Rewritten each run rather than only when absent, because the command carries this exe's full path — Explorer will not go looking for a binary that moved, and the Dolphin ServiceMenu is regenerated each install for the same reason. Two limits, both stated in the module and neither fixable with a registry write. Windows 11 files classic verbs under "Show more options"; its top-level menu only takes entries from an IExplorerCommand in a signed MSIX package. And a multiple selection launches the verb once per file, capped at 15 — each launch forwards one path to the running instance, so the files do arrive, as several offers rather than one batch, which the phone's share queue already copes with. Failure is logged and swallowed: a missing context-menu entry is a diminished app, not a broken one, and every other way of sharing still works. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/Cargo.toml | 2 + linux/ui-tauri/src-tauri/src/lib.rs | 11 ++ linux/ui-tauri/src-tauri/src/win_shell.rs | 129 ++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 linux/ui-tauri/src-tauri/src/win_shell.rs diff --git a/linux/ui-tauri/src-tauri/Cargo.toml b/linux/ui-tauri/src-tauri/Cargo.toml index f190d07..5d746a8 100644 --- a/linux/ui-tauri/src-tauri/Cargo.toml +++ b/linux/ui-tauri/src-tauri/Cargo.toml @@ -162,4 +162,6 @@ windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Storage_ProjectedFileSystem", + # The Explorer context-menu verb ("Share via Vortex"), written under HKCU. + "Win32_System_Registry", ] } diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 2db9810..0979e75 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -143,6 +143,10 @@ mod universal_control; mod virtual_display; mod voice_settings; mod window; +// Explorer's "Share via Vortex", the counterpart of the Nautilus extension and +// Dolphin ServiceMenu that install_linux.sh writes. +#[cfg(target_os = "windows")] +mod win_shell; mod worker; mod x11_focus; @@ -401,6 +405,13 @@ pub fn run() { // log that ends mid-startup with no reason given. log_panics(); tracing::info!(version = env!("CARGO_PKG_VERSION"), "vortex starting"); + // Explorer's "Share via Vortex". Re-registered every start rather than + // once, because the command has this exe's full path in it and Explorer + // will not go looking for a binary that moved. Cheap, and it means an + // unzipped standalone .exe gets the menu entry without an installer — + // which is the same reason the toast AUMID shortcut registers itself. + #[cfg(target_os = "windows")] + win_shell::register_share_verb(); let (cmd_tx, cmd_rx) = mpsc::channel::(); // Tray heartbeat: the 5-second local-earbuds rescan used to live in the diff --git a/linux/ui-tauri/src-tauri/src/win_shell.rs b/linux/ui-tauri/src-tauri/src/win_shell.rs new file mode 100644 index 0000000..8ec884e --- /dev/null +++ b/linux/ui-tauri/src-tauri/src/win_shell.rs @@ -0,0 +1,129 @@ +//! "Share via Vortex" in Windows Explorer — the counterpart of the Nautilus +//! extension and the Dolphin ServiceMenu that `install_linux.sh` writes. +//! +//! A classic shell verb under `HKEY_CURRENT_USER`, registered by the app on +//! every start. Deliberately not an installer step, for the same reason the +//! toast AUMID shortcut is not: a standalone .exe that someone unzipped should +//! work, and this needs no admin rights and no packaging. +//! +//! Rewritten on every run rather than only when absent. The command has the +//! exe's full path baked into it — Explorer will not search `PATH` — so a +//! binary that moved would otherwise leave a menu entry that launches nothing. +//! The Dolphin ServiceMenu is regenerated each install for the same reason. +//! +//! # Two limits worth knowing +//! +//! **Windows 11 files it under "Show more options".** The modern top-level +//! menu only takes entries from an `IExplorerCommand` in a signed MSIX +//! package; a registry verb cannot reach it. Shift+F10 opens the classic menu +//! directly, and on Windows 10 it is top-level as usual. +//! +//! **Multi-select launches the verb once per file.** Explorer's default for a +//! classic verb, capped at 15 selected items. Each launch forwards one path to +//! the running instance, so a multiple selection does arrive — as several +//! offers rather than one batch, which is what the phone's share queue already +//! copes with. + +use windows::core::PCWSTR; +use windows::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE, + REG_OPTION_NON_VOLATILE, REG_SZ, +}; + +/// Our verb's key name. Prefixed so it cannot collide with another app's. +const VERB: &str = "Vortex.Share"; + +/// A NUL-terminated UTF-16 buffer, for the registry APIs. +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// A UTF-16 buffer as the BYTES `RegSetValueExW` wants. +/// +/// `cbData` is a byte count, not a character count — passing the `u16` length +/// writes half the string, and the truncation lands mid-path. +fn utf16_as_bytes(v: &[u16]) -> &[u8] { + // SAFETY: same allocation and lifetime, length scaled; no alignment + // concern going from wider to narrower. + unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) } +} + +/// Write one string value (`None` name = the key's default value). +fn set_string(path: &str, name: Option<&str>, value: &str) -> Result<(), String> { + let path_w = wide(path); + let mut key = HKEY::default(); + // SAFETY: `key` is ours and closed on every path below. + unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + PCWSTR(path_w.as_ptr()), + Some(0), + None, + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + None, + &mut key, + None, + ) + } + .ok() + .map_err(|e| format!("create {path}: {e}"))?; + + let name_w = name.map(wide); + let value_w = wide(value); + let r = unsafe { + RegSetValueExW( + key, + name_w + .as_ref() + .map(|n| PCWSTR(n.as_ptr())) + .unwrap_or(PCWSTR::null()), + Some(0), + REG_SZ, + Some(utf16_as_bytes(&value_w)), + ) + } + .ok() + .map_err(|e| format!("write {path}: {e}")); + unsafe { + let _ = RegCloseKey(key); + } + r +} + +/// Put "Share via Vortex" on files and folders. +/// +/// Best-effort and quiet on failure: a missing context-menu entry is a +/// diminished app, not a broken one, and every other way of sharing still +/// works. +pub(crate) fn register_share_verb() { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + tracing::warn!("shell: cannot resolve own path; no context menu ({e})"); + return; + } + }; + let exe = exe.to_string_lossy().to_string(); + // `%1` is the selected path. Quoted because a path with a space is the + // normal case on Windows, not the exception. + let command = format!("\"{exe}\" --share \"%1\""); + + // `*` is every file; `Directory` is folders, which the Linux side shares + // too (it zips them on the way out). + for class in ["*", "Directory"] { + let base = format!("Software\\Classes\\{class}\\shell\\{VERB}"); + let steps = [ + (base.clone(), None, "Share via Vortex".to_string()), + (base.clone(), Some("Icon"), exe.clone()), + (format!("{base}\\command"), None, command.clone()), + ]; + for (path, name, value) in steps { + if let Err(e) = set_string(&path, name, &value) { + tracing::warn!("shell: context menu for {class} not registered: {e}"); + break; + } + } + } + tracing::info!(%exe, "shell: 'Share via Vortex' registered for files and folders"); +} From ee6bc01085a4702fb0eaf8a624af808959628b1f Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Sun, 13 Sep 2026 21:11:41 +0200 Subject: [PATCH 71/71] perf(ui): ship the logo at the size it is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webview runs with WEBKIT_DISABLE_DMABUF_RENDERER=1 on purpose — some GPU stacks render a blank WebKitGTK window without it, see src-tauri/src/main.rs — and that leaves it with no compositing at all. Layer promotion does nothing (`will-change` measured to the noise floor), so every frame of every animation repaints a good part of the window rather than the damaged rectangle, and whatever sits in that area is paid for again at the frame rate. vortex_logo.png was 512x512. The sidebar draws it at 30px, and no other use is larger than 56px, so each repaint re-sampled a quarter of a megapixel down to nine hundred pixels. That one image was the single largest cost on the devices screen: with the connection dot pulsing, blanking just this took the web process from 68% of a core to 6%, and neither the sidebar's backdrop-blur nor the logo's drop-shadow filter accounted for any of it. Shipped at 128px, which is still more than twice every size it is drawn at. Measured against the installed binary on the devices screen, not in a harness: an offscreen WebKitGTK window silently stops animating, which makes every reading from one meaningless. perf(ui): pulse the connection dot without repainting the card The devices screen animates exactly one thing — the 8px dot next to "Connected" — and that dot was costing most of a core for as long as the window was open, on a screen whose entire message is "everything in sync". It is what the 100% WebKitWebProcess was. `box-shadow` is a paint property, and with no compositor to hand the frame to (see the previous commit, and the WEBKIT_DISABLE_DMABUF_RENDERER note in main.rs) animating one re-rasterises the dot and everything under it, sixty times a second. Scaling and fading a copy of the dot instead is the same ring for a fraction of the work: with the logo already right-sized, ~19% of a core becomes ~16%, and `steps(33, end)` — which caps the halo at 15 updates a second rather than the display's 60, invisible on a soft fade — takes it to ~7%. `will-change` was tried on both and does nothing here; there is no compositing to opt into. The dot itself is now drawn by a masked pseudo-element rather than a background colour clipped by `border-radius`, which fixes a rendering bug the halo rewrite exposed. At eight CSS pixels the dot is eleven device pixels across on a fractional display scale, and a clipped circle that small rasterises to a different silhouette depending on the sub-pixel offset it lands on — from the identical rule, the "This device" dot came out round and the phone's came out a squircle, with a filled halo laid over it squaring it off further. A radial mask is antialiased the same way wherever it falls. It has to be a mask and not a gradient: a gradient fading to `transparent` fades through black and leaves a visible dark rim at this size. The colour therefore rides on `currentColor` rather than bg-*, and the glow moved to `drop-shadow`, which follows the masked circle where a box-shadow would trace the square border box and then be masked away. Verified on the installed binary: the halo renders frame-for-frame as before, both dots are round, and the web process sits at 6-8% of a core where it was at 70%. fix(ui): size the main window so the phone card stops wrapping The default 760x880 predates the browse button on the "Connected" row. With it there, the phone card no longer had the width for its own labels: "Android phone" and "Use phone as webcam" each wrapped to two lines, which pushed the content past the bottom of the window and left the page scrolling on first launch. 920x860 fits all of it on one line — the pairing hint too — and is shorter than before rather than taller, because the two recovered lines more than pay for the extra width. Checked against the rendered window, with the browse button present. fix(android): only hold the screen on while waiting on another device MainActivity.onCreate set three window flags, with a comment saying what they were: "Dev-only: keep the screen on so the lab tester can read the generated identity. Production removes this." It never did. The phone could not sleep for as long as Vortex was in front, and FLAG_DISMISS_KEYGUARD quietly waived a non-secure lock screen every time the activity came up — including from the foreground-service notification, the one place the app launches itself. FLAG_TURN_SCREEN_ON came along for the ride, and both keyguard APIs have been superseded by setShowWhenLocked / requestDismissKeyguard anyway. Nothing depended on them. The phone-to-laptop mirror holds its own SCREEN_DIM_WAKE_LOCK inside ScreenMirrorService, where it has to be, since capture continues with the activity gone; LaptopMirrorActivity sets its own FLAG_KEEP_SCREEN_ON while you watch the laptop; RingActivity wakes the screen with setShowWhenLocked/setTurnScreenOn. What did deserve the flag is the pairing flow, where you read the screen without touching it and a display timeout in the middle of a handshake takes the radio work down with it. So KEEP_SCREEN_ON is now scoped in VortexRoot to the three bounded waits that need it — the pairing window, the SAS comparison, the switch-laptop seek — and released by the DisposableEffect, so it cannot outlive the window closing or the activity going away. Verified on the phone: the live window on an idle home screen is down to LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS, and the display now times out with the app in the foreground. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/vortex/a3/ui/MainActivity.kt | 18 ++++++--- .../main/java/com/vortex/a3/ui/VortexRoot.kt | 22 ++++++++++ linux/ui-tauri/src-tauri/tauri.conf.json | 4 +- linux/ui-tauri/src/assets/vortex_logo.png | Bin 64651 -> 14813 bytes linux/ui-tauri/src/components/Sidebar.vue | 6 +++ linux/ui-tauri/src/pages/home/Devices.vue | 38 ++++++++++++++++-- linux/ui-tauri/src/style.css | 27 ++++++++++--- 7 files changed, 98 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt index 46f35ae..348352e 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivity.kt @@ -11,7 +11,6 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Bundle -import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -354,11 +353,18 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Dev-only: keep the screen on so the lab tester can read the - // generated identity. Production removes this. - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) - window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD) + // No KEEP_SCREEN_ON / TURN_SCREEN_ON / DISMISS_KEYGUARD here. They were + // lab scaffolding — "keep the screen on so the tester can read the + // generated identity" — and left the phone unable to sleep for as long + // as Vortex was in front, with the keyguard flag quietly waiving a + // swipe lock screen whenever this activity came up. Nothing in the app + // depends on them: the phone-to-laptop mirror holds its own + // SCREEN_DIM_WAKE_LOCK inside ScreenMirrorService (it has to, since it + // keeps capturing with the activity gone), LaptopMirrorActivity sets + // its own FLAG_KEEP_SCREEN_ON while you watch the laptop, and + // RingActivity wakes the screen with setShowWhenLocked/setTurnScreenOn. + // If a screen ever genuinely needs to stay lit — the pairing SAS, say — + // scope the flag to that screen, not to the whole activity. uiSettings.load() // saved locale + theme val identity = identityStore.loadOrGenerate(Platform.Android) identityState.value = identity diff --git a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt index 4cb6ea2..efc40b6 100644 --- a/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt +++ b/android/app/src/main/java/com/vortex/a3/ui/VortexRoot.kt @@ -1,6 +1,7 @@ package com.vortex.a3.ui import androidx.activity.ComponentActivity +import android.view.WindowManager import androidx.activity.compose.BackHandler import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme @@ -10,6 +11,7 @@ import androidx.compose.material3.TextButton import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -137,6 +139,26 @@ fun VortexRoot( @Suppress("DEPRECATION") window.statusBarColor = colorScheme.background.toArgb() } + // Hold the screen on, but only while the phone is waiting on another device + // to find it: the pairing window, the SAS comparison, and the switch-laptop + // seek. Those are the bounded stretches where the user is reading the screen + // without touching it, and a display timeout in the middle of a handshake + // takes the radio work down with it. Everywhere else the phone is free to + // sleep — MainActivity.onCreate deliberately sets no window-level + // KEEP_SCREEN_ON, which used to keep the display up for as long as Vortex + // was in front. + val advertising by ui.advertise.collectAsState() + val awaitingSas by ui.pendingApproval.collectAsState() + val seekingLaptop by ui.seekingLaptop.collectAsState() + val holdScreenOn = advertising is AdvertiseState.Starting || + advertising is AdvertiseState.Active || + awaitingSas != null || + seekingLaptop + DisposableEffect(holdScreenOn) { + val w = activity.window + if (holdScreenOn) w.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { w.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + } CompositionLocalProvider(LocalVortexLocale provides activeLocale) { MaterialTheme(colorScheme = colorScheme) { Surface( diff --git a/linux/ui-tauri/src-tauri/tauri.conf.json b/linux/ui-tauri/src-tauri/tauri.conf.json index b1d5b0f..0a90b00 100644 --- a/linux/ui-tauri/src-tauri/tauri.conf.json +++ b/linux/ui-tauri/src-tauri/tauri.conf.json @@ -15,8 +15,8 @@ { "label": "main", "title": "Vortex", - "width": 760, - "height": 880, + "width": 920, + "height": 860, "minWidth": 560, "minHeight": 600, "center": true, diff --git a/linux/ui-tauri/src/assets/vortex_logo.png b/linux/ui-tauri/src/assets/vortex_logo.png index 2c3fc393e27b3d554447a89445b4ae5b3ab16cbb..df5dfb253fbbeb0e388775b205e8678f6e1a6e14 100644 GIT binary patch literal 14813 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 DF4Sjg literal 64651 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%0DK6r1|KQ97C=Ds4K$$DV;Ly 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 IJTmbA0KT$d5&!@I diff --git a/linux/ui-tauri/src/components/Sidebar.vue b/linux/ui-tauri/src/components/Sidebar.vue index 1826dc1..74271d1 100644 --- a/linux/ui-tauri/src/components/Sidebar.vue +++ b/linux/ui-tauri/src/components/Sidebar.vue @@ -16,6 +16,12 @@ import { Settings, Users, } from "lucide-vue-next"; +// The logo asset is kept at 128px on purpose. It is drawn at 30px here and +// at most 56px anywhere else, and the webview has no compositing (see the +// WEBKIT_DISABLE_DMABUF_RENDERER note in src-tauri/src/main.rs), so every +// repaint anywhere in the window re-samples this image. At the original +// 512px that single rescale cost ~60% of a core while the connection dot +// was pulsing; at 128px it is ~16x less work and the same pixels on screen. import logo from "@/assets/vortex_logo.png"; import { unreadConversations } from "@/composables/useMessages"; diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 59d6927..29af706 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -188,7 +188,7 @@ const earbudsStatus = computed(() => { >{{ t("device.this") }}
- + {{ thisDeviceKind }}
@@ -245,7 +245,7 @@ const earbudsStatus = computed(() => {
{{ phoneOnline ? t("peers.connected") : phoneConnecting ? t("peers.connecting") : t("peers.offline") }} @@ -365,7 +365,7 @@ const earbudsStatus = computed(() => {
- + {{ earbudsStatus }}
@@ -442,8 +442,38 @@ const earbudsStatus = computed(() => { @apply flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-xl border border-white/[0.06] bg-white/[0.05]; color: #e8eaed; } +/* The dot is drawn by a masked pseudo-element, not by a background colour + clipped with `border-radius`. It is eight CSS pixels — eleven device pixels at + a fractional display scale — and a clipped circle that small rasterises to a + different silhouette depending on the sub-pixel offset it happens to land on: + from the identical rule, the "This device" dot came out round and the phone's + came out a squircle. A radial mask is antialiased the same way wherever it + falls. It has to be a mask and not a gradient: a gradient fading to + `transparent` fades through black and leaves a dark rim at this size. + The colour rides on `currentColor` (text-primary, …) rather than bg-*. */ .vx-dot { - @apply h-2 w-2 shrink-0 rounded-full; + @apply relative h-[11px] w-[11px] shrink-0; +} +.vx-dot::before, +.vx-pulse::after { + content: ""; + position: absolute; + inset: 0; + background: currentColor; + -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 0 45%, transparent 55%); + mask-image: radial-gradient(circle at 50% 50%, #000 0 45%, transparent 55%); +} +/* The halo: a second copy of the dot growing out of it and fading. See the + `vx-pulse` keyframes in style.css for why it scales rather than animating a + `box-shadow`, and why it steps rather than easing. */ +.vx-pulse::after { + pointer-events: none; + animation: vx-pulse 2.2s steps(33, end) infinite; +} +/* `drop-shadow`, not `box-shadow`: the glow has to follow the masked circle, + and a box-shadow would trace the square border box (and be masked away). */ +.vx-glow { + filter: drop-shadow(0 0 3px hsl(var(--primary) / 0.75)); } /* A smaller sibling of `.vx-icon` for the compact rows. Its own class rather than `vx-icon` plus size utilities: Vue scoped styles compile to diff --git a/linux/ui-tauri/src/style.css b/linux/ui-tauri/src/style.css index c564e0a..7b1cf09 100644 --- a/linux/ui-tauri/src/style.css +++ b/linux/ui-tauri/src/style.css @@ -100,15 +100,32 @@ } } -/* Design-system motion: the connection dot breathes. */ +/* Design-system motion: the connection dot breathes. The halo itself is drawn + in pages/home/Devices.vue (`.vx-pulse::after`), a masked copy of the dot; this + is only the motion. + + Two things here are load-bearing, both because the webview runs on the + software renderer on purpose (see the WEBKIT_DISABLE_DMABUF_RENDERER note in + src-tauri/src/main.rs): there is no compositor fast path, so every frame of + any animation repaints a good part of the window. + + * `transform` and `opacity`, never an animated `box-shadow`. The box-shadow + version of this one 8px dot cost ~55% of a core, for ever, on a window + showing nothing but "everything in sync". + * `steps()` rather than a smooth ease, which caps the halo at 15 updates a + second instead of the display's 60. On a soft fade nobody can tell, and it + is another 2x off the only thing this screen animates. + + The same reasoning applies to whatever else sits in the repainted area: see + the note on the logo import in components/Sidebar.vue, where a 512px PNG + rescaled to 30px on every frame was costing 60% of a core by itself. */ @keyframes vx-pulse { - 0% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.5); } - 70% { box-shadow: 0 0 0 7px hsl(var(--primary) / 0); } - 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0); } + 0% { transform: scale(1); opacity: 0.5; } + 70%, 100% { transform: scale(2.75); opacity: 0; } } @layer utilities { - .vx-pulse { animation: vx-pulse 2.2s ease-out infinite; } + /* `.vx-pulse` only marks the dot; the halo it draws lives with `.vx-dot`. */ /* Small lowercase tag for a feature that ships as Experimental. SOLID (opaque) dark-amber fill so it reads cleanly wherever it sits — including