From 4c29e1557047c85aa7827a12b6506fcaa6ffbfba Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Wed, 16 Sep 2026 18:20:44 +0200 Subject: [PATCH 1/7] =?UTF-8?q?fix(clipboard):=20make=20phone=E2=86=92lapt?= =?UTF-8?q?op=20text=20survive=20a=20dead=20BLE=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying on the phone never reached the laptop, by either route (auto capture or the share sheet). Three separate defects stacked up. **The BLE link could never connect.** With two remembered laptops the presence loop multiplexed their tokens by stopping and restarting the advertising set every MULTIPLEX_DWELL_MS. Each restart re-randomises the RPA, so the laptop resolved a token, began a GATT connect, and spent its whole 8 s timeout dialling an address the phone had already abandoned — observed as eight consecutive attempts to eight distinct addresses, all timing out, then backoff. The dwell can never exceed a connect timeout, so this was unwinnable at any dwell. Moving to the AdvertisingSet API lets the service data be swapped in place, so the address survives the whole pass. A connect is dialled against an address, not a token, so the laptop can now finish connecting even after we have moved on to the next peer's token. Privacy is unaffected — the controller still rotates the RPA on its own timer; we just stop forcing it every dwell. A pairing window still gets a fresh set, since it should not inherit the presence beacon's address. **Clipboard text was the only payload with no fallback.** It is a BLE notify and nothing else on both sides, so with the link down sealAndNotify returned false on its first line and the text was dropped — no retry, no LAN path, and no message, while the share sheet toasted "Sending text to laptop…". File and image offers already ride the bulk-sync done frame when BLE is down; text now does too, via a one-slot outbox (latest wins, bounded by age, because a clipboard holds one item and a stale one would surprise). The daemon feeds it to the same sink the BLE path uses, so the loop guard and history behave identically whichever link carried it. The done frame is logged verbatim, so it is now redacted to a length before logging — clipboard content must never reach the log. **Failures were reported as success.** The collector ignored the return of every send. It now tracks whether a peer actually took the bytes, with a partially-sent chunk burst counted as failure since it reassembles into nothing on the far side. Also adds ACTION_PROCESS_TEXT: "Vortex" in the text-selection toolbar, beside Copy and Share. Automatic capture turns out to be unreachable — the platform gates clipboard access on being the default IME, the focused window, or holding READ_CLIPBOARD_IN_BACKGROUND, and that permission is signature|role. Measured on a LineageOS build with the READ_CLIPBOARD AppOp set to allow, the app doze-whitelisted and a foreground service running: the listener callback was never delivered once in 3.8 days. So ClipboardAccess's claim that the AppOp is "the operative grant" was wrong, and is corrected with the measurements. PROCESS_TEXT needs no clipboard read at all — the selection arrives in the intent. Tested: full daemon and Android unit suites, plus new coverage for the outbox semantics, the done-frame parse, the log redaction, and a guard that the PROCESS_TEXT activity never writes back over the user's selection. NOT verified on hardware: the installed app is signed with a key this machine does not hold. Co-Authored-By: Claude Opus 5 (1M context) --- android/app/src/main/AndroidManifest.xml | 30 ++ .../java/com/vortex/a3/core/ble/Advertiser.kt | 321 +++++++++++++----- .../a3/core/clipboard/ClipboardAccess.kt | 35 +- .../a3/core/clipboard/ClipboardOutbox.kt | 62 ++++ .../a3/core/clipboard/ProcessTextActivity.kt | 64 ++++ .../java/com/vortex/a3/core/lan/LanServer.kt | 30 ++ .../java/com/vortex/a3/service/VortexStack.kt | 9 + .../vortex/a3/service/VortexStackClipboard.kt | 33 +- android/app/src/main/res/values/strings.xml | 3 + .../a3/core/clipboard/ClipboardOutboxTest.kt | 48 +++ .../core/clipboard/ProcessTextContractTest.kt | 55 +++ linux/daemon/src/core/lan/tcp_client.rs | 100 +++++- .../ui-tauri/src-tauri/src/clipboard_sync.rs | 22 ++ linux/ui-tauri/src-tauri/src/lan.rs | 12 + 14 files changed, 720 insertions(+), 104 deletions(-) create mode 100644 android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt create mode 100644 android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt create mode 100644 android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt create mode 100644 android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d1b3f5a..476778f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -263,6 +263,36 @@ + + + + + + + + + + Unit)? = null + + @Volatile + private var pendingPayload: AdvPayload? = null /** Returns true while the phone should advertise in reconnect-seeking * (LOW_LATENCY) mode — wired by VortexStack to "no live laptop GATT @@ -61,100 +82,210 @@ class Advertiser(private val context: Context) { } /** - * Start BLE advertising with the supplied [payload]. Used by both the - * pairable-mode entry point and the trusted-presence rotation loop. + * Put [payload] on air. + * + * Reuses the advertising set whenever one is already running at the right + * interval, swapping only the service data. That in-place swap is the + * point: stopping and restarting a set makes the controller mint a fresh + * RPA, and a laptop that has just resolved our token then spends its whole + * connect timeout dialling an address we have already abandoned. With two + * remembered laptops the rotation loop tore the set down every + * [MULTIPLEX_DWELL_MS] — far shorter than any real connect — so the GATT + * link could never be established at all, and every BLE-only payload + * (clipboard text above all) was dropped in silence. + * + * A connect is dialled against an ADDRESS, not a token, so a stable + * address also means the laptop can still finish connecting after we have + * swapped on to the next peer's token — multiplexing no longer costs + * connectability. + * + * Privacy is unaffected: the controller keeps rotating the RPA on its own + * timer. We merely stop forcing a rotation every dwell. * * The advertiser stops itself if [stop] is called or the process exits. */ fun startWith(payload: AdvPayload, onResult: (StartResult) -> Unit) { - if (activeCallback != null) { - onResult(StartResult.Failed("already advertising")) - return - } val advertiser = advertiser if (advertiser == null) { onResult(StartResult.Failed("bluetooth not available")) return } + val lowLatency = wantsLowLatency(payload) + val live = advertisingSet + // A pairing window always gets a FRESH set. It is a new, deliberately + // identity-exposing session, so inheriting the presence beacon's + // address would carry the old one straight into it; the swap is an + // optimisation for the 24/7 presence rotation, not for this. + if (live != null && activeLowLatency == lowLatency && !payload.flags.isPairable) { + swapPayload(live, payload, onResult) + } else { + startSet(advertiser, payload, lowLatency, onResult) + } + } - val payloadBytes = payload.encode() + /** + * Whether [payload] should ride the dense (~100 ms) schedule. + * + * Pairable mode is a short user-opened window where discovery speed + * matters. Trusted-presence runs 24/7 and is normally ~250 ms, BUT while + * the laptop link is DOWN and recently lost ([fastModeProvider]) it goes + * dense too: the laptop's CONNECT_IND is answered at an advertising event, + * so a denser schedule directly cuts the walk-up reconnect (live-measured: + * screen-off connects ~11s vs ~1.5s screen-on — MIUI throttles background + * advertising hard, and a dense request lands in a faster throttle tier). + * + * `seeking` has to be its own term: [fastModeProvider] means "link is DOWN + * and was lost recently", but a seek deliberately keeps the current link UP + * (seek before release), so it evaluates false exactly when we most want + * the dense schedule — the user is walking to another machine right now. + */ + private fun wantsLowLatency(payload: AdvPayload): Boolean = + payload.flags.isPairable || seeking || fastModeProvider?.invoke() == true - // ADV_IND per spec §5.1.1: Flags + Service Data 128-bit AD only. - // The Service Data field already carries the Vortex Service UUID, so - // adding it via addServiceUuid() would duplicate it and overflow the - // 31-byte legacy advertisement budget. - val advertiseData = AdvertiseData.Builder() - .addServiceData(ParcelUuid(Ble.VORTEX_SERVICE_UUID), payloadBytes) + /** + * ADV_IND per spec §5.1.1: Flags + Service Data 128-bit AD only. + * The Service Data field already carries the Vortex Service UUID, so + * adding it via addServiceUuid() would duplicate it and overflow the + * 31-byte legacy advertisement budget. + */ + private fun advertiseDataFor(payload: AdvPayload): AdvertiseData = + AdvertiseData.Builder() + .addServiceData(ParcelUuid(Ble.VORTEX_SERVICE_UUID), payload.encode()) .setIncludeDeviceName(false) .setIncludeTxPowerLevel(false) .build() - // SCAN_RSP carries the device's Bluetooth alias. This DEVIATES - // from spec §5.1.2 ("user-set device name MUST NOT appear - // here") — a deliberate per-user override because the alias - // is needed to disambiguate when several Vortex phones appear - // in the Linux scan list. The standard Bluetooth GAP layer - // already exposes this alias during normal BT discovery; the - // marginal extra exposure here is the time-window difference - // (foreground-bound while no trust). User is aware and accepts. - val scanResponse = AdvertiseData.Builder() + /** + * SCAN_RSP carries the device's Bluetooth alias. This DEVIATES from spec + * §5.1.2 ("user-set device name MUST NOT appear here") — a deliberate + * per-user override because the alias is needed to disambiguate when + * several Vortex phones appear in the Linux scan list. The standard + * Bluetooth GAP layer already exposes this alias during normal BT + * discovery; the marginal extra exposure here is the time-window + * difference (foreground-bound while no trust). User is aware and accepts. + */ + private fun scanResponseData(): AdvertiseData = + AdvertiseData.Builder() .setIncludeDeviceName(true) .build() - // Pairable mode is a short user-opened window where discovery speed - // matters → LOW_LATENCY (~100 ms interval). Trusted-presence runs - // 24/7 → BALANCED (~250 ms) by default, BUT while the laptop link - // is DOWN and recently lost ([fastModeProvider]) it also runs - // LOW_LATENCY: the laptop's CONNECT_IND is answered at an - // advertising event, so a denser schedule directly cuts the - // walk-up reconnect (live-measured: screen-off connects ~11s vs - // ~1.5s screen-on — MIUI throttles background advertising hard, - // and a LOW_LATENCY request lands in a faster throttle tier). - // Re-evaluated at every 60s token rotation. - // `seeking` has to be its own term: [fastModeProvider] means "link is - // DOWN and was lost recently", but a seek deliberately keeps the - // current link UP (seek before release), so it evaluates false exactly - // when we most want the dense schedule — the user is walking to - // another machine right now. This is the first rung of the §D5 ladder. - val advertiseMode = if (payload.flags.isPairable || - seeking || - fastModeProvider?.invoke() == true - ) { - AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY - } else { - AdvertiseSettings.ADVERTISE_MODE_BALANCED + /** Swap the service data on the live set — same set, same address. */ + private fun swapPayload( + set: AdvertisingSet, + payload: AdvPayload, + onResult: (StartResult) -> Unit, + ) { + pendingPayload = payload + pendingResult = onResult + try { + set.setAdvertisingData(advertiseDataFor(payload)) + } catch (e: SecurityException) { + pendingPayload = null + pendingResult = null + onResult(StartResult.Failed("missing BLUETOOTH_ADVERTISE permission: ${e.message}")) } - val settings = AdvertiseSettings.Builder() - .setAdvertiseMode(advertiseMode) + } + + /** Start a fresh advertising set (first time on air, or the interval changed). */ + private fun startSet( + advertiser: android.bluetooth.le.BluetoothLeAdvertiser, + payload: AdvPayload, + lowLatency: Boolean, + onResult: (StartResult) -> Unit, + ) { + // Tear down any set running at the wrong interval first. + stop() + + val parameters = AdvertisingSetParameters.Builder() + // Legacy mode keeps the exact ADV_IND + SCAN_RSP shape the Linux + // scanner already parses; only the control API changes. + .setLegacyMode(true) .setConnectable(true) - .setTimeout(0) // Vortex manages the window - // HIGH (vs MEDIUM): the laptop hears us from farther away, so - // the walk-up reconnect starts at the range edge instead of - // near the desk. TX cost is per-advertising-event — small. - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setScannable(true) + .setInterval( + if (lowLatency) { + AdvertisingSetParameters.INTERVAL_LOW + } else { + AdvertisingSetParameters.INTERVAL_MEDIUM + }, + ) + // HIGH (vs MEDIUM): the laptop hears us from farther away, so the + // walk-up reconnect starts at the range edge instead of near the + // desk. TX cost is per-advertising-event — small. + .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_HIGH) .build() - val callback = object : AdvertiseCallback() { - override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { - val mode = if (payload.flags.isPairable) "pairable" else "trusted-presence" - Log.i(TAG, "advertise started: $mode, instance=${payloadBytes.copyOfRange(2, 10).toHexString()}") - activePayload = payload - onResult(StartResult.Started(payload)) + val callback = object : AdvertisingSetCallback() { + override fun onAdvertisingSetStarted( + set: AdvertisingSet?, + txPower: Int, + status: Int, + ) { + val sink = pendingResult + val started = pendingPayload + pendingResult = null + pendingPayload = null + if (status != ADVERTISE_SUCCESS || set == null) { + Log.e(TAG, "advertise failed: ${errorCodeMessage(status)}") + setCallback = null + advertisingSet = null + activeLowLatency = null + sink?.invoke(StartResult.Failed(errorCodeMessage(status))) + return + } + advertisingSet = set + activeLowLatency = lowLatency + activePayload = started + val mode = if (started?.flags?.isPairable == true) "pairable" else "trusted-presence" + Log.i( + TAG, + "advertise started: $mode, instance=${started?.encode()?.copyOfRange(2, 10)?.toHexString()}", + ) + started?.let { sink?.invoke(StartResult.Started(it)) } + } + + override fun onAdvertisingDataSet(set: AdvertisingSet?, status: Int) { + val sink = pendingResult + val swapped = pendingPayload + pendingResult = null + pendingPayload = null + if (status != ADVERTISE_SUCCESS) { + Log.w(TAG, "advertise payload swap failed: ${errorCodeMessage(status)}") + sink?.invoke(StartResult.Failed(errorCodeMessage(status))) + return + } + activePayload = swapped + // Deliberately quieter than a start: this fires every dwell + // while multiplexing, and the address has NOT changed. + Log.i( + TAG, + "advertise payload swapped: instance=${swapped?.encode()?.copyOfRange(2, 10)?.toHexString()}", + ) + swapped?.let { sink?.invoke(StartResult.Started(it)) } } - override fun onStartFailure(errorCode: Int) { - val msg = errorCodeMessage(errorCode) - Log.e(TAG, "advertise failed: $msg") - activeCallback = null - onResult(StartResult.Failed(msg)) + override fun onAdvertisingSetStopped(set: AdvertisingSet?) { + advertisingSet = null + activeLowLatency = null } } - activeCallback = callback + setCallback = callback + pendingPayload = payload + pendingResult = onResult try { - advertiser.startAdvertising(settings, advertiseData, scanResponse, callback) + advertiser.startAdvertisingSet( + parameters, + advertiseDataFor(payload), + scanResponseData(), + null, + null, + callback, + ) } catch (e: SecurityException) { - activeCallback = null + setCallback = null + pendingPayload = null + pendingResult = null onResult(StartResult.Failed("missing BLUETOOTH_ADVERTISE permission: ${e.message}")) } } @@ -227,9 +358,10 @@ class Advertiser(private val context: Context) { * remembered laptops cannot be addressed at once. With more than one peer * the loop cycles them, dwelling [MULTIPLEX_DWELL_MS] on each, so any of * them sees us within N × dwell — a few seconds, which is nothing on a - * deliberate walk-up. With a single peer it does NOT cycle: restarting the - * advertiser needlessly churns the RPA and costs battery, so the common - * case keeps exactly the old one-advertise-per-bucket behaviour. + * deliberate walk-up. Cycling is free of the old cost: the loop swaps the + * service data on one long-lived advertising set rather than restarting it, + * so the Bluetooth address stays put and a laptop that has resolved its + * token can still complete a connect after the token has moved on. */ fun startPresenceLoop( scope: CoroutineScope, @@ -287,7 +419,9 @@ class Advertiser(private val context: Context) { } if (peers.size == 1) { - stop() + // No stop() first: [startWith] reuses the live set and + // swaps the service data, so the bucket rotation no longer + // costs an address change either. startWith(AdvPayload.trustedPresence(Presence.deriveToken(peers[0], bucket)), onStart) // Sleep until ~5s past the next bucket boundary so we // refresh just inside the new window — OR until a kick @@ -302,7 +436,9 @@ class Advertiser(private val context: Context) { // the peer set changed). for (prs in peers) { if (!isActive) break - stop() + // Swap the token in place. Tearing the set down here is + // what used to re-randomise the RPA every dwell and made + // the laptop's connect time out every single attempt. startWith(AdvPayload.trustedPresence(Presence.deriveToken(prs, bucket)), onStart) val kicked = withTimeoutOrNull(MULTIPLEX_DWELL_MS) { rotationKick.receive() } // A kick means the phase changed — abandon the pass @@ -333,14 +469,18 @@ class Advertiser(private val context: Context) { } fun stop() { - val cb = activeCallback ?: return + val cb = setCallback ?: return try { - advertiser?.stopAdvertising(cb) + advertiser?.stopAdvertisingSet(cb) } catch (e: SecurityException) { - Log.w(TAG, "stopAdvertising threw: ${e.message}") + Log.w(TAG, "stopAdvertisingSet threw: ${e.message}") } - activeCallback = null + setCallback = null + advertisingSet = null + activeLowLatency = null activePayload = null + pendingResult = null + pendingPayload = null Log.i(TAG, "advertise stopped") } @@ -351,16 +491,16 @@ class Advertiser(private val context: Context) { stop() } - fun isAdvertising(): Boolean = activeCallback != null + fun isAdvertising(): Boolean = setCallback != null fun activePayload(): AdvPayload? = activePayload private fun errorCodeMessage(code: Int): String = when (code) { - AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "ADVERTISE_FAILED_DATA_TOO_LARGE" - AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS" - AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "ADVERTISE_FAILED_ALREADY_STARTED" - AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "ADVERTISE_FAILED_INTERNAL_ERROR" - AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "ADVERTISE_FAILED_FEATURE_UNSUPPORTED" + AdvertisingSetCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "ADVERTISE_FAILED_DATA_TOO_LARGE" + AdvertisingSetCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS" + AdvertisingSetCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "ADVERTISE_FAILED_ALREADY_STARTED" + AdvertisingSetCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "ADVERTISE_FAILED_INTERNAL_ERROR" + AdvertisingSetCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "ADVERTISE_FAILED_FEATURE_UNSUPPORTED" else -> "advertise error $code" } @@ -374,11 +514,16 @@ class Advertiser(private val context: Context) { /** How long each peer's token stays on air during multiplexing. * * Long enough for a scanning laptop to catch several advertising - * events (LOW_LATENCY ≈ 100 ms, BALANCED ≈ 250 ms), short enough that - * N peers all get seen within a few seconds. Also the floor on how - * often we restart the advertising set, which re-randomises the RPA — - * cheaper dwells would inflate the laptop's BlueZ device cache and - * feed the stale-RPA connect wedge. */ + * events (dense ≈ 100 ms, normal ≈ 250 ms), short enough that N peers + * all get seen within a few seconds. + * + * This no longer bounds how often the RPA changes: a dwell now swaps + * the service data on the live advertising set instead of restarting + * it, so the address survives the whole pass. That is what closes the + * stale-RPA connect wedge — the laptop dials an address, not a token, + * so it can still finish connecting after we have moved on to the next + * peer. Before the swap, any dwell shorter than a connect timeout + * (~8 s) made the link unestablishable with two or more peers. */ private const val MULTIPLEX_DWELL_MS = 1_500L /** Re-check interval while advertising is suspended (session live) or diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt index 11506fa..b9f3cd7 100644 --- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt @@ -5,19 +5,34 @@ import android.content.Context import android.os.Process /** - * Detects whether THIS app may read the clipboard in the BACKGROUND — i.e. - * whether phone→laptop clipboard sync is fully AUTOMATIC, or falls back to the - * manual Quick Settings tile. + * Whether the `READ_CLIPBOARD` AppOp is set to ALLOW for this app. * - * Android 10+ forbids background clipboard reads for sideloaded apps unless the - * `READ_CLIPBOARD` AppOp is explicitly set to ALLOW (via ADB or root) — the same - * mechanism other BLE phone-link apps use. The user grants it once with: + * **This is NOT sufficient for background clipboard reads, despite the name.** + * Measured on Android 16, LineageOS-based ROM (PJZ110, 2026-09-16): with the op + * set to ALLOW, the app doze-whitelisted, unrestricted in the background and + * running a foreground service, [ClipboardListener]'s change callback was never + * delivered once and the op was not noted for 3.8 days of ordinary copying. * - * adb shell appops set com.vortex.a3 READ_CLIPBOARD allow + * Near-AOSP is the important part: this is the stock platform gate, not an OEM + * clampdown, so it is what every reasonably current Android will do and not + * something a different ROM or a vendor workaround gets around. * - * This is a UX hint only; the real ground truth is whether a background read - * actually returns content. A force-stop / reinstall can reset the op on some - * OEMs, so the listener degrades gracefully regardless. + * The platform gates clipboard access on TWO conditions, not one: the caller + * must first qualify as allowed — by holding + * `android.permission.READ_CLIPBOARD_IN_BACKGROUND`, by being the default IME, + * or by owning the focused window — and only then is this AppOp consulted. A + * sideloaded Vortex is none of the three, and that permission is + * `signature|role`, so it cannot be granted by any route available to us + * (`pm grant` refuses it with "managed by role"). + * + * So automatic background capture is not reachable by setting the op, and the + * ADB incantation this doc used to recommend does nothing on its own. The + * user-triggered paths are the real mechanism: the Quick Settings tile and the + * share sheet both run [ClipboardQuickSendActivity] in the FOREGROUND, which + * satisfies the focused-window condition and is why they work. + * + * Kept as a UX hint, and because the op is still the necessary half of the + * pair for any caller that does qualify. */ object ClipboardAccess { /** True if the AppOp is set to ALLOW (background reads work → auto sync). */ diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt new file mode 100644 index 0000000..e7881fe --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt @@ -0,0 +1,62 @@ +package com.vortex.a3.core.clipboard + +/** + * Clipboard text that could not go out over BLE, held for the LAN sync. + * + * Phone→laptop clipboard text is a BLE notify and nothing else. When the GATT + * link is down the notify fails at its first check and the text is gone — no + * retry, no fallback, no message. That is invisible in exactly the case it + * matters: a laptop can sit for hours with a perfectly healthy LAN session and + * a BLE link that never connected, and every copy the user makes is dropped + * while the UI says "Sending text to laptop…". + * + * File and image offers already solved this: they ride the bulk-sync done + * frame (see `LanServer.pendingOffersProvider`), so the link that works is the + * one that delivers. This is the same escape hatch for text. + * + * **One slot, latest wins.** The clipboard holds one primary item at a time, + * so a queue would only ever deliver stale content the user has already + * replaced — the same reasoning that makes [ClipboardSyncGuard] a single slot. + * + * **Bounded by age.** Text is only worth delivering while it is plausibly + * still what the user wants to paste; a copy from this morning arriving when + * the laptop finally reconnects is a surprise, not a feature. + */ +object ClipboardOutbox { + + private val lock = Any() + private var text: String? = null + private var stashedAt: Long = 0L + + /** Hold text the BLE path could not deliver. Replaces anything older. */ + fun stash(value: String) { + if (value.isEmpty()) return + synchronized(lock) { + text = value + stashedAt = System.currentTimeMillis() + } + } + + /** + * Take the pending text if there is any and it is still fresh, clearing + * the slot. Returns null otherwise. + * + * Taking on read (rather than after an ack) matches what the content is: + * the done frame carrying it is the last frame of a round that has already + * proved itself, and a clipboard is transient enough that re-announcing it + * on every subsequent round would be worse than dropping it once. + */ + fun take(): String? = synchronized(lock) { + val pending = text ?: return@synchronized null + text = null + if (System.currentTimeMillis() - stashedAt > MAX_AGE_MS) null else pending + } + + /** Drop anything pending — the content was delivered another way. */ + fun clear() { + synchronized(lock) { text = null } + } + + /** Beyond this, held text is stale enough that delivering it would surprise. */ + private const val MAX_AGE_MS = 5 * 60 * 1000L +} diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt new file mode 100644 index 0000000..4467bd1 --- /dev/null +++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt @@ -0,0 +1,64 @@ +package com.vortex.a3.core.clipboard + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import com.vortex.a3.service.VortexService + +/** + * "Vortex" in the text-selection toolbar, beside Copy and Share. + * + * Select text anywhere, tap Vortex, and it lands on the laptop's clipboard — + * one tap, no share sheet and no app picker. + * + * This exists because AUTOMATIC capture is not reachable. Reading the clipboard + * in the background needs the caller to be the default IME, the focused window, + * or a holder of `READ_CLIPBOARD_IN_BACKGROUND` — which is `signature|role` and + * cannot be granted to a sideloaded app by any route (see [ClipboardAccess] for + * the measurements). So [ClipboardListener]'s callback is never delivered and + * every working path has to be one the user triggers. + * + * `ACTION_PROCESS_TEXT` is the cheapest of those by a wide margin: the system + * hands us the selected text directly in the intent, so there is no clipboard + * read to be refused and no invisible foreground trampoline to bounce through + * (unlike the Quick Settings tile, which needs [ClipboardQuickSendActivity] to + * take focus first). + */ +class ProcessTextActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + overridePendingTransition(0, 0) + + val selected = intent + ?.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) + ?.toString() + ?.trim() + + if (selected.isNullOrEmpty()) { + Log.w(TAG, "process-text: empty selection — nothing to do") + Toast.makeText(this, "Nothing to send", Toast.LENGTH_SHORT).show() + } else { + // Same bus as the tile and the share sheet, so this inherits the + // cap, the chunking, the per-peer send and the LAN fallback for + // free. Length only in the log — never the content. + VortexService.clipboardBus.tryEmit(selected) + Log.i(TAG, "process-text: forwarded ${selected.length} chars to the laptop clipboard") + Toast.makeText(this, "Sending text to laptop…", Toast.LENGTH_SHORT).show() + } + + // Deliberately NO setResult: returning EXTRA_PROCESS_TEXT would make the + // host app REPLACE the user's selection with whatever we sent back. + // Sending a copy to the laptop must never edit what they were reading — + // and in a read-only view (EXTRA_PROCESS_TEXT_READONLY) it would be + // silently discarded anyway, so the two cases behave the same. + finish() + overridePendingTransition(0, 0) + } + + companion object { + private const val TAG = "VortexProcessText" + } +} 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 26baaa2..af007b3 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 @@ -176,6 +176,18 @@ class LanServer( */ var pendingOffersProvider: () -> List = { emptyList() } + /** + * Clipboard text the BLE notify could not deliver, for the same ride-along + * as [pendingOffersProvider]. + * + * Phone→laptop clipboard text is a BLE notify and nothing else, so a + * laptop whose GATT link never connects loses every copy the user makes + * while this exchange completes cleanly every few seconds. The provider + * hands over at most one pending text (latest wins) and clears it, so the + * done frame delivers it on whichever link is actually up. + */ + var pendingClipboardProvider: () -> String? = { null } + /** * Capture tokens whose gallery rows this phone no longer has. * @@ -1008,6 +1020,24 @@ class LanServer( status.put("deleted", org.json.JSONArray(deleted)) Log.i(TAG, "bulk-sync: reported ${deleted.size} deleted capture(s)") } + // Clipboard text BLE could not deliver. Same + // reasoning as the offers above: send it on the + // link that works. Length only — never the content. + val clip = runCatching { pendingClipboardProvider() } + .getOrElse { + Log.w(TAG, "pending-clipboard provider threw: ${it.message}") + null + } + if (!clip.isNullOrEmpty()) { + status.put( + "clipboard", + org.json.JSONObject().apply { + put("text", clip) + put("ts", System.currentTimeMillis()) + }, + ) + Log.i(TAG, "bulk-sync: carried clipboard text (${clip.length} chars)") + } lockedSealAndWrite( FrameType.BULK_SYNC, 0x02, status.toString().toByteArray(Charsets.UTF_8), 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 2510504..b2148d1 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 @@ -1266,6 +1266,15 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { lan.deletedCapturesProvider = { com.vortex.a3.core.media.CaptureLedger.deletedTokens(ctx) } + // Clipboard text the BLE notify could not deliver, so a laptop with a + // dead GATT link but a live LAN session still gets what was copied. + lan.pendingClipboardProvider = { + if (com.vortex.a3.core.clipboard.ClipboardSyncSetting.isEnabled()) { + com.vortex.a3.core.clipboard.ClipboardOutbox.take() + } else { + null + } + } lanServer = lan // Let code with no handle on the stack ship a snapshot immediately — // the accessibility service reporting an input-focus change, which the 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 0518b27..c53127f 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 @@ -29,22 +29,49 @@ internal fun VortexStack.startClipboardOutbound() { trimmed } val utf8Len = capped.toByteArray(Charsets.UTF_8).size + // Whether the text actually reached a peer. Every send below is a + // BLE notify that returns false on a down link, and the old code + // ignored that: it logged "sent chunked" and let the share sheet + // toast "Sending text to laptop…" while the bytes went nowhere. + var delivered = false if (utf8Len <= com.vortex.a3.core.clipboard.ClipboardText.MAX_SINGLE_FRAME_TEXT_BYTES) { val json = clipboardJsonBytes(capped) for (peer in peerStore.list()) { - gattServer?.sendClipboardEncrypted(peer.peerStaticPub, json) + if (gattServer?.sendClipboardEncrypted(peer.peerStaticPub, json) == true) { + delivered = true + } } } else { // Long text → chunk over CLIPBOARD_TEXT, paced so the BLE // notify queue doesn't drop frames (same 12ms as images). val chunks = com.vortex.a3.core.clipboard.ClipboardText.buildChunks(capped) for (peer in peerStore.list()) { + // A peer counts as delivered only if EVERY chunk got out: + // a partial burst reassembles into nothing on the far side, + // so it must not suppress the LAN fallback. + var whole = true for (chunk in chunks) { - gattServer?.sendClipboardTextChunkEncrypted(peer.peerStaticPub, chunk) + if (gattServer?.sendClipboardTextChunkEncrypted(peer.peerStaticPub, chunk) != true) { + whole = false + } kotlinx.coroutines.delay(12) } + if (whole) delivered = true } - Log.i(VortexStack.TAG, "clipboard: long text sent chunked ($utf8Len bytes, ${chunks.size} chunks)") + if (delivered) { + Log.i(VortexStack.TAG, "clipboard: long text sent chunked ($utf8Len bytes, ${chunks.size} chunks)") + } + } + if (!delivered) { + // Hand it to the LAN sync rather than dropping it. The done + // frame goes out on every bulk-sync round, so with BLE down and + // the LAN session up this arrives within a heartbeat instead of + // never. Content is never logged — only its size. + com.vortex.a3.core.clipboard.ClipboardOutbox.stash(capped) + Log.w( + VortexStack.TAG, + "clipboard text couldn't go out over BLE (link down?); queued for the LAN sync ($utf8Len bytes)", + ) } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9ba35bf..95f4c41 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,6 +2,9 @@ Vortex Vortex Clipboard + + Vortex Lets your paired laptop control this phone (tap, swipe, Back/Home) while screen mirroring. No data is collected. Open on this phone Open on phone diff --git a/android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt b/android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt new file mode 100644 index 0000000..181492b --- /dev/null +++ b/android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt @@ -0,0 +1,48 @@ +package com.vortex.a3.core.clipboard + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * The LAN fallback for clipboard text the BLE notify could not deliver. + * + * Phone→laptop clipboard text used to be a BLE notify and nothing else, so a + * laptop with a dead GATT link lost every copy in silence. These pin the two + * properties the fallback relies on. + */ +class ClipboardOutboxTest { + + @BeforeEach + fun reset() = ClipboardOutbox.clear() + + @Test + fun `text survives until it is taken, then the slot is empty`() { + ClipboardOutbox.stash("copied on the phone") + assertEquals("copied on the phone", ClipboardOutbox.take()) + // Taken once: a second bulk-sync round must not re-deliver it and + // overwrite whatever the user has copied since. + assertNull(ClipboardOutbox.take()) + } + + @Test + fun `latest copy wins over an undelivered older one`() { + ClipboardOutbox.stash("first") + ClipboardOutbox.stash("second") + // A clipboard holds one item; delivering "first" would paste content + // the user has already replaced. + assertEquals("second", ClipboardOutbox.take()) + } + + @Test + fun `nothing pending yields nothing`() { + assertNull(ClipboardOutbox.take()) + } + + @Test + fun `an empty copy is never stashed`() { + ClipboardOutbox.stash("") + assertNull(ClipboardOutbox.take()) + } +} diff --git a/android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt b/android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt new file mode 100644 index 0000000..b34bdef --- /dev/null +++ b/android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt @@ -0,0 +1,55 @@ +package com.vortex.a3.core.clipboard + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Static guards on the text-selection-toolbar entry point. + * + * Both checks are for mistakes that are invisible in review and only show up + * on a device: one silently removes Vortex from the toolbar, the other + * silently edits the user's document. + */ +class ProcessTextContractTest { + + private val manifest = File("src/main/AndroidManifest.xml") + private val activity = + File("src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt") + + @Test + fun `the toolbar entry point is declared and reachable`() { + val xml = manifest.readText() + assertTrue(xml.contains("ProcessTextActivity"), "activity missing from the manifest") + assertTrue( + xml.contains("android.intent.action.PROCESS_TEXT"), + "no PROCESS_TEXT filter — Vortex would not appear in the selection toolbar", + ) + // Not exported = the system cannot launch it, and the toolbar item + // silently never appears. + val block = xml.substringAfter("ProcessTextActivity").substringBefore("") + assertTrue( + block.contains("android:exported=\"true\""), + "the activity must be exported for the system to launch it", + ) + assertTrue( + block.contains("android.intent.category.DEFAULT"), + "an implicit intent needs category DEFAULT to resolve", + ) + } + + /** + * Returning `EXTRA_PROCESS_TEXT` in the result makes the HOST app replace + * the user's selection with whatever we send back. Sending a copy to the + * laptop must never edit what they were reading. + */ + @Test + fun `the activity never writes back over the user's selection`() { + val src = activity.readText() + assertFalse( + src.contains("setResult("), + "setResult would overwrite the selected text in the host app", + ) + } +} diff --git a/linux/daemon/src/core/lan/tcp_client.rs b/linux/daemon/src/core/lan/tcp_client.rs index 2dbdd96..22fd122 100644 --- a/linux/daemon/src/core/lan/tcp_client.rs +++ b/linux/daemon/src/core/lan/tcp_client.rs @@ -68,6 +68,14 @@ pub struct LanReconnectOutcome { /// ours to remove, while deleting the phone's own original would need a /// system consent dialog there for every file. pub deleted: Vec, + /// Clipboard text the phone copied but could not deliver over BLE, from + /// the done frame's `clipboard` object. + /// + /// Phone→laptop clipboard text has no transport but the BLE notify, so a + /// laptop whose GATT link never connects silently loses every copy while + /// this exchange succeeds every few seconds. Carried here so the link that + /// works is the one that delivers, exactly as for [`offers`]. + pub clipboard: Option, } /// Pull the done frame's `offers` array out as real offers. @@ -103,6 +111,45 @@ fn parse_deleted(json: &[u8]) -> Vec { arr.iter().filter_map(|e| e.as_str().map(|s| s.to_string())).collect() } +/// Pull the done frame's `clipboard` object out as text the phone copied. +/// +/// The phone's clipboard text is normally a BLE notify. When the GATT link is +/// down that notify fails and the text is gone, so the phone hands it to this +/// exchange instead — the same escape hatch `offers` uses, for the one payload +/// that had no fallback at all. +fn parse_clipboard(json: &[u8]) -> Option { + let v: serde_json::Value = serde_json::from_slice(json).ok()?; + let clip = v.get("clipboard")?; + let parsed: crate::core::clipboard_mirror::ClipboardMirror = + serde_json::from_value(clip.clone()).ok()?; + if parsed.text.is_empty() { + return None; + } + Some(parsed) +} + +/// The done frame with any clipboard body replaced by its length. +/// +/// The frame is logged verbatim, and clipboard content must never reach the +/// log (the BLE path logs `chars = …` for exactly this reason). Everything +/// else in the frame is dataset names and tokens, which are worth keeping. +fn redact_done_frame(json: &[u8]) -> String { + let Ok(mut v) = serde_json::from_slice::(json) else { + return String::from_utf8_lossy(json).into_owned(); + }; + if let Some(obj) = v.as_object_mut() { + if let Some(clip) = obj.get_mut("clipboard") { + let chars = clip + .get("text") + .and_then(|t| t.as_str()) + .map(|t| t.chars().count()) + .unwrap_or(0); + *clip = serde_json::json!({ "text": format!("<{chars} chars>") }); + } + } + v.to_string() +} + /// The bulk-sync done frame's per-dataset outcome map. #[derive(Debug, Clone, Default)] pub struct BulkStatus(std::collections::HashMap); @@ -375,6 +422,7 @@ pub async fn run_lan_reconnect( let mut bulk_status: Option = None; let mut offers: Vec = Vec::new(); let mut deleted: Vec = Vec::new(); + let mut clipboard: Option = None; if let (Some(req), Some(_)) = (bulk_request, peer_state.as_ref()) { match exchange_bulk(&mut stream, &mut transport, req, wait_per_step).await { Ok(ex) => { @@ -382,6 +430,7 @@ pub async fn run_lan_reconnect( bulk_status = ex.status; offers = ex.offers; deleted = ex.deleted; + clipboard = ex.clipboard; } Err(e) => tracing::warn!("bulk-sync exchange failed: {e}"), } @@ -412,6 +461,7 @@ pub async fn run_lan_reconnect( bulk_status, offers, deleted, + clipboard, }) } @@ -533,6 +583,8 @@ struct BulkExchange { offers: Vec, /// Capture tokens whose originals are gone from the phone. deleted: Vec, + /// Clipboard text the phone could not push over BLE, if any. + clipboard: Option, } /// Send the bulk-sync request and collect the chunked dataset frames the @@ -557,6 +609,7 @@ async fn exchange_bulk( let mut out: Vec<(u8, Vec)> = Vec::new(); let mut status: Option = None; let mut offers: Vec = Vec::new(); + let mut clipboard: Option = None; let mut deleted: Vec = Vec::new(); let mut listing = crate::core::phone_files::ListingAssembler::default(); let mut contacts = crate::core::contacts::ContactsAssembler::default(); @@ -599,7 +652,7 @@ async fn exchange_bulk( pt.truncate(n); match frame.ty { ty::BULK_SYNC if frame.sub == 0x02 => { - info!("← bulk-sync done: {}", String::from_utf8_lossy(&pt)); + info!("← bulk-sync done: {}", redact_done_frame(&pt)); status = BulkStatus::parse(&pt); if status.is_none() { tracing::warn!("bulk-sync: done frame is not a JSON object; no status"); @@ -612,6 +665,10 @@ async fn exchange_bulk( if !deleted.is_empty() { info!("← {} capture(s) deleted on the phone", deleted.len()); } + clipboard = parse_clipboard(&pt); + if let Some(c) = clipboard.as_ref() { + info!(chars = c.text.chars().count(), "← LAN clipboard sync"); + } break; } ty::PHONE_FILES => { @@ -743,13 +800,13 @@ async fn exchange_bulk( } } } - Ok(BulkExchange { datasets: out, status, offers, deleted }) + Ok(BulkExchange { datasets: out, status, offers, deleted, clipboard }) } #[cfg(test)] mod tests { - use super::{parse_deleted, parse_offers, BulkStatus}; + use super::{parse_clipboard, parse_deleted, parse_offers, redact_done_frame, BulkStatus}; /// The done frame carries offers beside the per-dataset outcomes, and the /// status map must not choke on the array sitting next to its strings. @@ -767,6 +824,43 @@ mod tests { assert_eq!(s.get("contacts"), Some("match")); } + /// Clipboard text rides the done frame beside everything else, and the + /// three readers must each ignore the others. This is the phone's only + /// way to deliver a copy when the BLE link never connects. + #[test] + fn clipboard_rides_the_done_frame_beside_the_rest() { + let body = br#"{"contacts":"match","deleted":["x"], + "offers":[{"token":"t","name":"a.jpg","bytes":1,"mime":"image/jpeg","kind":"photo"}], + "clipboard":{"text":"hello laptop","ts":1700000000000}}"#; + let clip = parse_clipboard(body).expect("clipboard parsed"); + assert_eq!(clip.text, "hello laptop"); + // The neighbours still read their own fields. + assert_eq!(BulkStatus::parse(body).unwrap().get("contacts"), Some("match")); + assert_eq!(parse_offers(body).len(), 1); + assert_eq!(parse_deleted(body), vec!["x".to_string()]); + } + + /// A frame with no clipboard, or an empty one, yields nothing — an empty + /// string must not clear the laptop's clipboard. + #[test] + fn absent_or_empty_clipboard_is_not_delivered() { + assert!(parse_clipboard(br#"{"contacts":"match"}"#).is_none()); + assert!(parse_clipboard(br#"{"clipboard":{"text":"","ts":1}}"#).is_none()); + assert!(parse_clipboard(br#"not json"#).is_none()); + } + + /// The done frame is logged verbatim, so the clipboard body must be + /// replaced by its length before it ever reaches the log. + #[test] + fn done_frame_log_never_carries_clipboard_text() { + let body = br#"{"contacts":"match","clipboard":{"text":"hunter2 is secret","ts":1}}"#; + let logged = redact_done_frame(body); + assert!(!logged.contains("hunter2"), "clipboard text leaked into the log: {logged}"); + assert!(logged.contains("17 chars"), "length should survive: {logged}"); + // Everything else is still there to read. + assert!(logged.contains("contacts")); + } + /// Deletions ride the same frame as the offers and the status map, and /// each reader must ignore the other two. #[test] diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 2d0bf61..419dd1d 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -344,6 +344,9 @@ pub(crate) fn spawn_clipboard_sync( // Phone → laptop: a received CLIPBOARD frame → set our system clipboard // + add to history (so phone copies show up in Super+V). let (recv_tx, mut recv_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Publish it for non-BLE transports (the LAN heartbeat's done-frame + // fallback), which have no other way to reach this consumer. + let _ = CLIPBOARD_RECV_SINK.set(recv_tx.clone()); { let app = app.clone(); tokio::spawn(async move { @@ -793,6 +796,25 @@ pub(crate) fn submit_offer(offer: Offer) -> bool { OFFER_SINK.get().map(|tx| tx.send(offer).is_ok()).unwrap_or(false) } +/// The incoming-clipboard consumer's sender, so a transport that isn't BLE can +/// hand it text. +/// +/// Same reasoning as [`OFFER_SINK`]: the BLE listener gets its own clone at +/// wiring time, and the LAN heartbeat runs far from that but must reach the +/// same consumer — so the loop guard, the tidy pass and the clipboard history +/// all behave identically no matter which link carried the text. +static CLIPBOARD_RECV_SINK: OnceLock< + tokio::sync::mpsc::UnboundedSender, +> = OnceLock::new(); + +/// Hand received clipboard text to the consumer. `false` when there is no +/// consumer yet (before wiring) or it has stopped. +pub(crate) fn submit_clipboard( + clip: vortex_l3_daemon::core::clipboard_mirror::ClipboardMirror, +) -> bool { + CLIPBOARD_RECV_SINK.get().map(|tx| tx.send(clip).is_ok()).unwrap_or(false) +} + pub(crate) fn spawn_image_offer_consumer() -> tokio::sync::mpsc::UnboundedSender { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let _ = OFFER_SINK.set(tx.clone()); diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 64e1ce8..7406204 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -756,6 +756,18 @@ pub(crate) async fn try_lan_reconnect( tracing::warn!("offer sink is not up; LAN-announced offer dropped"); } } + // Clipboard text the phone could not push over BLE. Same sink + // the BLE path uses, so the loop guard and history behave the + // same whichever link delivered it. Length only in the log. + if let Some(clip) = outcome.clipboard.clone() { + tracing::info!( + chars = clip.text.chars().count(), + "clipboard text carried over LAN" + ); + if !crate::clipboard_sync::submit_clipboard(clip) { + tracing::warn!("clipboard sink is not up; LAN-carried text dropped"); + } + } if outcome.peer_counter < local_counter { tracing::warn!( "possible trust rollback: peer counter={} local={}", From 7c832ec48204a3a0546a469d235c6dcdecbf93f8 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Wed, 16 Sep 2026 18:21:12 +0200 Subject: [PATCH 2/7] fix(lan): claim the peer on a LAN handshake so mirror caches persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The laptop re-pulled the same 945 KB of SMS history on 26 of 27 consecutive bulk-sync rounds, and re-pulled the call-log history, SMS ids and contacts alongside it — about a megabyte every 12.5 s, indefinitely, with the phone re-reading 5000 messages from its provider each time. The request always asked for `sms_history` since=0. The watermark was being computed correctly and then thrown away. Every per-peer cache file resolves through `peer_dir()`, which is keyed on `arbiter::active()`; with no active peer it returns None, `peer_file()` returns None, and each write is skipped by an `if let Some(p)`. So the watermark read back as 0 forever and the phone dutifully re-sent everything. The give-away on disk was `~/.cache/vortex/peers/` sitting empty after hours of rounds, even though `peer_dir()` calls `create_dir_all` on every one of them. Ownership had only two sources: worker startup — and only when exactly ONE peer is stored, since with several "which phone's data" has no answer — or an explicit pair/switch. A peer paired AFTER startup therefore never became active, and with BLE unable to connect nothing else ever claimed it, so `active()` stayed None for the rest of the process. That is how a laptop ends up mirroring a phone while persisting nothing about it. A completed IK proves who the peer is, so the LAN loop can take ownership on exactly the same evidence the BLE loop already uses. The claim goes in before the bulk datasets are delivered, so the very first round persists rather than costing one more full pull. `claim` is idempotent for the current owner and refuses when another peer owns the session, so this cannot steal an active BLE session; a refusal just leaves the cache pointed at the phone the user is looking at. With two trusted phones the LAN loop now claims whichever answers first, which is the one actually present — and matches what BLE has always done. Not a fix for the 2.5 s reconnect bursts, which turned out not to be a defect: the baseline is 12.5 s, and the bursts are the two documented brisk-poll cases — a mirrored call clearing its pill, and a queued file pull. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/lan.rs | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/linux/ui-tauri/src-tauri/src/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 7406204..c68cd1f 100644 --- a/linux/ui-tauri/src-tauri/src/lan.rs +++ b/linux/ui-tauri/src-tauri/src/lan.rs @@ -723,6 +723,38 @@ pub(crate) async fn try_lan_reconnect( .await { Ok(outcome) => { + // A completed IK proves who this is, so LAN may take ownership + // exactly as the BLE loop does on its own handshake. + // + // Without this, ownership had only two sources: worker startup + // (and only when exactly ONE peer is stored) and an explicit + // pair/switch. A peer paired AFTER startup therefore never + // became active, and on a laptop whose BLE never connects + // nothing else ever claimed it — leaving `arbiter::active()` + // None for the rest of the process. + // + // That is not cosmetic: the per-peer cache directory is keyed + // on the active peer, so with none, `peer_file()` returns None + // and every mirror cache silently fails to persist. The visible + // cost is the SMS history watermark, which is read back as 0 + // forever — measured here re-pulling the same 945 KB of history + // on 26 of 27 consecutive rounds, and making the phone re-read + // 5000 messages from its provider each time. + // + // `claim` is idempotent for the current owner and refuses when + // another peer owns the session, so this can never steal an + // active BLE session; a refusal just means the cache stays + // pointed at the phone the user is actually looking at. + crate::arbiter::note_connected(&peer.peer_static_pub); + if let crate::arbiter::Claim::Busy { current } = + crate::arbiter::claim(&peer.peer_static_pub) + { + tracing::debug!( + peer = %hex::encode(&peer.peer_static_pub[..4]), + active = %hex::encode(¤t[..4]), + "LAN session up for a peer that does not own the session" + ); + } // The handshake at this address just SUCCEEDED — that's the // strongest possible "this is our phone's IP" signal, stronger // than any discovery guess. Cache it whichever path picked it From 482852448ad5ffe1519df35f2fec70a1110ecbfe Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 17 Sep 2026 21:09:25 +0200 Subject: [PATCH 3/7] fix(android): stop bonding to the laptop, and clear bonds we already hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLE never worked. Every connect died about 200 ms in, before a single ATT exchange, and had done for a long time — the phone's own counter read "Bond loss detected, count: 1031". The laptop reconnected every 12-20 s forever, LAN carried everything, and nothing in our logs on either side said why. The phone held an LE bond for the laptop; the laptop held none. So on each connect the phone tried to encrypt with its stored LTK, the central answered by pairing instead, and the phone read that as bond loss: btm_sec_report_bond_loss: reason: Bonded unencrypted central wants to pair disconnect_acl: ... reason:HCI_ERR_AUTH_FAILURE The asymmetry was ours. `pairing.rs` decided against LE bonding deliberately ("NOTE: no BT bond here", after the 2026-06-02 investigation found every bond attempt routed over BR/EDR on a dual-mode phone and yielding no IRK), and `PeerStore.loadPeerBtAddr` documents the resulting invariant in its contract: "Vortex itself never bonds". The `createBond()` at the end of pairing was the one place contradicting it, so every pairing left us holding a key the laptop has never had and by design never will. Nothing wanted that bond. No characteristic in `GattServer` asks for an encrypted link, and the laptop uses a public static address, so there is no IRK to want either. Removing the call costs nothing and is the whole fix for new pairings. Existing installs need more, because the bad state is already on disk and Android hides profile-less LE bonds from Settings, so a user cannot clear it by hand. `clearStaleLaptopBonds()` runs on every service start and drops any bond held for a stored laptop address — so an affected phone heals itself on the next start, with no re-pairing. Cheap: `BondCleaner.removeBond` no-ops on BOND_NONE, over the trusted-peer list we already read. NOT for Windows. Its `bonded()` fast path reads pairing state, but it is an optimisation with an explicit scan fallback ("hence the fallback rather than a failure"), the Windows side never calls a pair API at all, and this call predates the platform seam by seven weeks. If that fast path is ever worth having, the bond must be created FROM Windows and on both sides — one-sided bonding is this bug, not a fix for it. Verified on hardware. Before: every ACL 0.3-0.5 s, torn down locally by the phone. After a fresh pairing over BLE with Wi-Fi off, so no LAN path could mask the result: sessions of 1m54s, 2m02s, 4m35s and 16m00s, all ended by the laptop rather than aborted, DLE negotiated to 251 octets, zero auth failures in the phone's persistent history, and a security record showing what we wanted — le_linkkey_known:F, ble_enc_key_size:0, bond_type:BOND_TYPE_UNKNOWN. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/vortex/a3/service/VortexStack.kt | 35 +++++++++++++++++++ .../com/vortex/a3/ui/MainActivityPairing.kt | 14 ++++---- 2 files changed, 42 insertions(+), 7 deletions(-) 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 b2148d1..e27d8be 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 @@ -396,9 +396,44 @@ class VortexStack(internal val service: Service) : VortexNotification.Host { feature("lanServer") { startLanServer(identity) } // mDNS + TCP IK + AppState sync feature("outboxDrain") { startOutboxDrain() } + feature("bondHygiene") { clearStaleLaptopBonds() } // one-sided LE bonds kill BLE return true } + /** + * Drop any Bluetooth bond this phone still holds for a trusted laptop. + * + * Vortex is bondless on both sides. The laptop deliberately skips + * `Device::pair()` ("NOTE: no BT bond here" in `pairing.rs`), no + * characteristic in [com.vortex.a3.core.ble.GattServer] asks for an + * encrypted link, and the laptop advertises a public static address so + * there is no IRK worth having. A bond is therefore never an asset here. + * + * It is a liability, because it can only ever be one-sided. A stored LTK + * the laptop does not share can never be used: on each LE connect we try to + * encrypt, the central answers by pairing instead, we read that as bond loss + * and drop the ACL with HCI_ERR_AUTH_FAILURE about 200 ms in, before a + * single ATT exchange. The result is that BLE silently never works at all, + * while LAN carries on and hides it — the failure looks like "the laptop + * just never connects over Bluetooth", with nothing in our own log to say + * why. Live-measured 2026-09-17: every connect died that way for hours, the + * phone's counter reading "Bond loss detected, count: 1031". + * + * Android hides profile-less LE bonds from the Settings UI, so the user + * cannot clear this by hand; it has to happen here. Running it on every + * start (rather than only at pairing) is what heals a phone bonded by an + * older build, or by someone connecting from the desktop's Bluetooth panel. + * Cheap either way: [com.vortex.a3.core.ble.BondCleaner.removeBond] no-ops + * on BOND_NONE, and the address list is the trusted peers we already read. + */ + private fun clearStaleLaptopBonds() { + val adapter = service.getSystemService(BluetoothManager::class.java)?.adapter ?: return + for (peer in peerStore.list()) { + val addr = peerStore.loadPeerBtAddr(peer.peerStaticPub) ?: continue + com.vortex.a3.core.ble.BondCleaner.removeBond(adapter, addr) + } + } + /** Send anything buffered for each trusted peer. Returns immediately when * there is nothing queued. */ internal suspend fun flushNotificationOutbox() { 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 3102f12..ea38070 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 @@ -71,13 +71,13 @@ internal fun MainActivity.wirePairingOrchestrator(identity: IdentityRecord) { peerName = outcome.peerName, ) ) - try { - if (outcome.device.bondState == android.bluetooth.BluetoothDevice.BOND_NONE) { - outcome.device.createBond() - } - } catch (e: Exception) { - android.util.Log.w("Pairing", "createBond: ${e.message}") - } + // Deliberately NO createBond() here: the laptop skips + // `Device::pair()` on purpose, so bonding would leave us + // holding an LTK it has never had — which kills every later + // BLE connect. VortexStack.clearStaleLaptopBonds() has the + // mechanism and the measurements, and clears bonds that + // arrive from elsewhere. + // 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 From 315bc7f4ccf5edb261cd0bc746b70186caa52344 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 17 Sep 2026 21:30:33 +0200 Subject: [PATCH 4/7] feat(android): say when a BLE link ends without ever becoming a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLE was completely dead for a long time and nothing said so. The LAN transport carried every feature, the UI showed the laptop as connected, and the only trace was a log line that never arrived — which nobody notices, because you cannot grep for a line that is not there. It surfaced by accident, while verifying two unrelated commits. So the teardown path now says it. A GATT link that drops without completing IK logs a warning with how long it was held: seconds means our handshake stalled, sub-second means something outside Vortex killed the link before we got a word in, which is what a bond/encryption failure looks like from here. The message points at the BT stack log, because that is where the reason lives and it is not an obvious place to look. The success side already existed as "registered audio session", worded for the call site. It is now "BLE session established", worded for whoever is grepping logcat at the time — the two lines are a pair and only read as one if they sound like one. Tracking is per LINK, deliberately not `deviceToPeerPub`. That map answers a different question and survives a disconnect on purpose (the laptop's address is its public one, so it stays meaningful), which makes it useless here: after a single good session it reads non-null forever and every later failure would log as a success. Caught by testing the warning rather than trusting it — the first version used that map and stayed silent on a link that had plainly failed. Verified on hardware, both branches: a real reconnect logs "BLE session established with peer=2120853e…", and a `bluetoothctl connect` held for eight seconds and dropped — a GATT link that never runs IK — logs "dropped after 8156ms WITHOUT establishing a session". Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/vortex/a3/core/ble/GattServer.kt | 51 ++++++++++++++++++- 1 file changed, 50 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 1fb413f..ae07b71 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 @@ -456,7 +456,15 @@ class GattServer( audioRecvCiphers[device.address] = recvCipher audioRecvNonce[device.address] = 0L // fresh handshake → nonce starts at 0 deviceToPeerPub[device.address] = peerStaticPub.copyOf() - Log.i(TAG, "registered audio session for peer=${peerHex.take(8)}… device=${device.address}") + // Worded for someone grepping logcat, not for the call site: this is the + // moment BLE becomes usable — IK done, ciphers installed, frames can + // flow. Its ABSENCE is the interesting signal (see the teardown branch + // of onConnectionStateChange), so the two lines are phrased as a pair. + sessionUpAddrs.add(device.address) + Log.i( + TAG, + "BLE session established with peer=${peerHex.take(8)}… device=${device.address}", + ) } /** Which peer a connected device authenticated as, or null if IK has not @@ -818,6 +826,21 @@ class GattServer( private val connectedAddrs = java.util.concurrent.ConcurrentHashMap.newKeySet() + /** When each live link came up, so a disconnect can report how long it + * lasted. Only read for logging — see the teardown branch of + * [onConnectionStateChange] for why that number is worth having. */ + private val connectedSinceMs: ConcurrentHashMap = ConcurrentHashMap() + + /** Addresses that reached a session ON THE CURRENT LINK. + * + * Deliberately not [deviceToPeerPub], which answers a different question. + * That map survives a disconnect on purpose (the address is the laptop's + * public one, so it stays meaningful), which makes it useless for "did + * THIS link get anywhere" — after one good session it reads non-null + * forever and every later failure looks like a success. Cleared on + * connect, set by [registerAudioSession], read once on teardown. */ + private val sessionUpAddrs = java.util.concurrent.ConcurrentHashMap.newKeySet() + @Volatile var lastDisconnectAtMs: Long = android.os.SystemClock.elapsedRealtime() private set @@ -880,9 +903,35 @@ class GattServer( Log.i(TAG, "GATT $state device=${device?.address ?: "?"} status=$status") if (newState == BluetoothProfile.STATE_CONNECTED && device != null) { connectedAddrs.add(device.address) + connectedSinceMs[device.address] = android.os.SystemClock.elapsedRealtime() + sessionUpAddrs.remove(device.address) // fresh link, nothing proven yet } if (newState == BluetoothProfile.STATE_DISCONNECTED && device != null) { connectedAddrs.remove(device.address) + // A link that never became a session is the failure worth + // shouting about, because it is otherwise INVISIBLE: the LAN + // transport carries everything, the UI still shows the laptop + // as connected, and the only trace is a "BLE session + // established" line that never arrives — which nobody notices, + // because you cannot grep for a line that is not there. + // + // That is not hypothetical. A one-sided LE bond made every + // connect die about 200 ms in, before a single ATT exchange, + // and it stayed hidden long enough for the phone's own counter + // to read "Bond loss detected, count: 1031". The lifetime is + // the tell: a handshake needs seconds, so anything sub-second + // means the link was torn down before Vortex got a word in. + val heldMs = connectedSinceMs.remove(device.address) + ?.let { android.os.SystemClock.elapsedRealtime() - it } + if (!sessionUpAddrs.remove(device.address)) { + Log.w( + TAG, + "BLE link to ${device.address} dropped after ${heldMs ?: -1}ms " + + "WITHOUT establishing a session — no IK completed. " + + "Sub-second here means something outside Vortex killed the " + + "link (check for bond/encryption failures in the BT stack log)", + ) + } if (connectedAddrs.isEmpty()) { lastDisconnectAtMs = android.os.SystemClock.elapsedRealtime() } From 918bce9436cc84dbe6eaaae0673fa660f0e10590 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 17 Sep 2026 21:30:48 +0200 Subject: [PATCH 5/7] build(windows): cross-build the Windows app from Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tauri app has built for Windows since the platform seam landed, but there was no way to produce a binary without a Windows machine and no documentation saying the platform exists. `install_windows.sh` does it from the same checkout the Linux build uses, and README gains the missing install step. MSVC target, not GNU, though GNU would need no downloads: the app reaches WinRT through the `windows` crate and Tauri links WebView2, both built against the MSVC ABI. That means Microsoft's CRT and SDK, which is the one thing a Linux box does not have and exactly what cargo-xwin fetches — a few hundred MB once, into ~/.cache/cargo-xwin. cargo-xwin accepts Microsoft's licence for that download, so the script and the README both say so plainly and point at building on Windows as the alternative. The script builds the APP, never `--workspace`. The daemon cross-compiles as a library and is used that way, but its binary (`vortex-l3d`) is a Linux CLI on bluer and cannot build for Windows; asking for the workspace only fails on a binary nobody wants here. The NSIS installer is behind --installer rather than default. Tauri will cross-build it happily, but it needs makensis, which is AUR-only on Arch/CachyOS — so requiring it would make the common case fail on the machine this was developed on. Without the flag you get the .exe, which needs no install anyway. README says what does NOT work, because that list is not obvious and finding out by trying is worse: screen mirror/cast, continuity camera and the earbuds hand-off are GStreamer/GTK/PulseAudio/BlueZ and are compiled out. It also covers the two first-run surprises — SmartScreen on an unsigned binary, and a blank window on Windows 10 that means a missing WebView2 runtime. Verified: the script runs end to end here (`--skip-deps`, toolchain already present) and produces "PE32+ executable for MS Windows 6.00 (GUI), x86-64", 18 MB. NOT verified: the dependency-install path, which needs a sudo password this session did not have, and that the binary RUNS — there is no Windows machine or wine here, so everything past "it links" is unproven. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 43 +++++++++- install_windows.sh | 210 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 1 deletion(-) create mode 100755 install_windows.sh diff --git a/README.md b/README.md index 1442cee..d521461 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ them as if they were one device: **Requirements:** Android 10+ phone · Linux with BlueZ (Ubuntu/Debian, Fedora, Arch, openSUSE) · Bluetooth (BLE) on both devices. Build tools (Rust/Node) are -installed by the script itself. +installed by the script itself. A Windows build is available too, cross-built +from Linux — see [step 7](#7-laptop-windows--experimental). ### 1. Laptop (Linux) @@ -220,6 +221,46 @@ lets Vortex keep one keyboard for the whole session, so you see the notification once instead of on every crossing. The notification itself can also be switched off: long-press it → turn off that category. Nothing else uses it. +### 7. Laptop (Windows) — experimental + +There is no Windows installer yet. The app is **cross-built from Linux**: if +you already build the Linux app on this machine, one more script produces the +Windows `.exe` from the same checkout. + +```bash +./install_windows.sh # toolchain + vortex-ui-tauri.exe +./install_windows.sh --installer # also an NSIS setup.exe +``` + +It installs what is missing (clang/lld/llvm, the `x86_64-pc-windows-msvc` Rust +target, `cargo-xwin`, node) and builds. The first run additionally downloads +Microsoft's CRT and Windows SDK — a few hundred MB, once, into +`~/.cache/cargo-xwin`; `cargo-xwin` accepts Microsoft's licence for that +download on your behalf, so build on Windows instead if you would rather not. +The result lands in +`linux/ui-tauri/src-tauri/target/x86_64-pc-windows-msvc/release/`. + +Copy the `.exe` to the Windows machine and run it — there is nothing to +install, it sits in the tray like the Linux build, and pairing is the same BLE +flow from the same phone app. + +**What works:** pairing, reconnect, notification mirroring, clipboard, file +transfer, Universal Control. + +**What does not:** screen mirror/cast, the continuity camera and the earbuds +hand-off. Those are GStreamer/GTK/PulseAudio/BlueZ and have no Windows +implementation, so they are compiled out rather than shipped broken. + +**Two things to expect on first run.** The binary is unsigned, so SmartScreen +warns — *More info → Run anyway*. And on Windows 10 the window can open blank: +that is a missing **WebView2 runtime** (Windows 11 ships it). Install +Microsoft's Evergreen WebView2 Runtime and relaunch. + +> ⚠ **Experimental, and less tested than Linux.** The BLE layer here is a +> separate implementation against a platform seam (WinRT rather than BlueZ), +> and it has had far less real-world use. Treat it as a preview and please +> report what breaks. + ## 🔐 Security All device-to-device traffic is **end-to-end encrypted**: diff --git a/install_windows.sh b/install_windows.sh new file mode 100755 index 0000000..956b667 --- /dev/null +++ b/install_windows.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# +# install_windows.sh — cross-build the Vortex laptop app for Windows, from Linux. +# +# Produces `vortex-ui-tauri.exe` (and optionally an NSIS installer) without ever +# touching a Windows machine. Run it on the same checkout you build Linux from: +# +# ./install_windows.sh # deps + .exe +# ./install_windows.sh --installer # also the NSIS setup.exe +# ./install_windows.sh --skip-deps # I already have the toolchain +# +# WHY the MSVC target and not `x86_64-pc-windows-gnu`, which needs no downloads: +# the app talks to WinRT through the `windows` crate (Bluetooth LE lives in +# Devices_Bluetooth), and Tauri links WebView2. Both are built against the MSVC +# ABI; the GNU target is a different ABI and does not link them. So the MSVC +# target it is, which means we need Microsoft's CRT and SDK — that is the one +# thing a Linux box does not have, and exactly what `cargo-xwin` fetches. +# +# What gets installed: +# • clang / lld / llvm — cargo-xwin drives clang-cl, lld-link and llvm-rc +# • rustup target x86_64-pc-windows-msvc +# • cargo-xwin — downloads the MSVC CRT + Windows SDK on first build +# • node/npm — only if missing (same reasoning as install-deps.sh) +# • makensis — ONLY with --installer +# +# What you get is a Windows build of everything except the Linux-only +# subsystems. Screen mirror/cast, the continuity camera and the earbuds +# hand-off are GStreamer/GTK/PulseAudio/BlueZ and are compiled out (see the +# "Linux-only subsystems" gate in linux/ui-tauri/src-tauri/src/lib.rs). Pair, +# reconnect, notifications, clipboard, file transfer and Universal Control all +# build. +set -uo pipefail + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BOLD='\033[1m'; NC='\033[0m' +ok() { printf "${GREEN}✓ %s${NC}\n" "$1"; } +warn() { printf "${YELLOW}⚠ %s${NC}\n" "$1"; } +err() { printf "${RED}✗ %s${NC}\n" "$1"; } +info() { printf "${BOLD}▶ %s${NC}\n" "$1"; } + +REPO="$(cd "$(dirname "$0")" && pwd)" +UI="$REPO/linux/ui-tauri" +TARGET="x86_64-pc-windows-msvc" +OUT_DIR="$UI/src-tauri/target/$TARGET/release" +EXE="$OUT_DIR/vortex-ui-tauri.exe" + +export PATH="$HOME/.cargo/bin:$PATH" + +SKIP_DEPS=0 +WANT_INSTALLER=0 +ASSUME_YES=1 +for a in "$@"; do + case "$a" in + --skip-deps) SKIP_DEPS=1 ;; + --installer) WANT_INSTALLER=1 ;; + --ask) ASSUME_YES=0 ;; + --yes) ASSUME_YES=1 ;; + -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + esac +done + +# ── 0. system dependencies ──────────────────────────────────────────────────── +if [ "$SKIP_DEPS" -eq 0 ]; then + PM="" + for c in apt-get dnf pacman zypper; do + if command -v "$c" >/dev/null 2>&1; then PM="$c"; break; fi + done + + SUDO="" + [ "$(id -u)" -ne 0 ] && SUDO="sudo" + + YES_FLAG="" + [ "$ASSUME_YES" -eq 1 ] && case "$PM" in + apt-get|dnf|zypper) YES_FLAG="-y" ;; + pacman) YES_FLAG="--noconfirm" ;; + esac + + # clang-cl, lld-link and llvm-rc are the three tools cargo-xwin shells out to. + # They are split across packages differently per distro, hence the mapping + # rather than one name. + declare -a PKGS=() + case "$PM" in + apt-get) PKGS=(clang lld llvm) ;; + dnf) PKGS=(clang lld llvm) ;; + pacman) PKGS=(clang lld llvm) ;; + zypper) PKGS=(clang lld llvm) ;; + "") warn "No supported package manager found — install clang, lld and llvm by hand." ;; + esac + + if [ -n "$PM" ] && [ "${#PKGS[@]}" -gt 0 ]; then + info "installing the cross toolchain (clang / lld / llvm)…" + case "$PM" in + apt-get) $SUDO apt-get update -qq; $SUDO apt-get install $YES_FLAG "${PKGS[@]}" ;; + dnf) $SUDO dnf install $YES_FLAG --skip-broken "${PKGS[@]}" ;; + pacman) $SUDO pacman -Sy $YES_FLAG --needed "${PKGS[@]}" ;; + zypper) $SUDO zypper install $YES_FLAG "${PKGS[@]}" ;; + esac || warn "toolchain install had issues — continuing; the build will say what's missing." + fi + + # Node only when absent: installing it unconditionally is what breaks a box + # that already has NodeSource node (see the note in packaging/install-deps.sh). + if ! command -v node >/dev/null 2>&1; then + info "installing node/npm…" + case "$PM" in + apt-get) $SUDO apt-get install $YES_FLAG nodejs npm ;; + dnf) $SUDO dnf install $YES_FLAG nodejs npm ;; + pacman) $SUDO pacman -Sy $YES_FLAG --needed nodejs npm ;; + zypper) $SUDO zypper install $YES_FLAG nodejs npm ;; + esac || warn "node install failed — install node ≥18 yourself and re-run." + fi + + if ! command -v rustup >/dev/null 2>&1 && ! command -v cargo >/dev/null 2>&1; then + info "installing Rust via rustup (distro Rust is usually too old for Tauri)…" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" + fi +fi + +command -v cargo >/dev/null 2>&1 || { err "cargo not on PATH — install Rust and re-run."; exit 1; } + +# ── 1. the Windows target ───────────────────────────────────────────────────── +if rustup target list --installed 2>/dev/null | grep -qx "$TARGET"; then + ok "rust target $TARGET already installed" +else + info "adding rust target $TARGET…" + rustup target add "$TARGET" || { err "could not add $TARGET"; exit 1; } +fi + +# ── 2. cargo-xwin ───────────────────────────────────────────────────────────── +# First build downloads Microsoft's CRT and Windows SDK into ~/.cache/cargo-xwin +# (a few hundred MB, once). cargo-xwin accepts the Microsoft licence on your +# behalf for that download — if that is not acceptable to you, stop here and +# build on Windows instead. +if command -v cargo-xwin >/dev/null 2>&1; then + ok "cargo-xwin already installed ($(cargo-xwin --version 2>/dev/null | head -1))" +else + info "installing cargo-xwin (compiles from source, a few minutes)…" + cargo install cargo-xwin || { err "cargo install cargo-xwin failed"; exit 1; } +fi + +# ── 3. NSIS, only when asked ────────────────────────────────────────────────── +if [ "$WANT_INSTALLER" -eq 1 ] && ! command -v makensis >/dev/null 2>&1; then + info "installing NSIS (makensis) for the installer…" + case "${PM:-}" in + apt-get) $SUDO apt-get install $YES_FLAG nsis ;; + dnf) $SUDO dnf install $YES_FLAG mingw32-nsis ;; + zypper) $SUDO zypper install $YES_FLAG mingw32-nsis ;; + # Arch/CachyOS: nsis is AUR-only, so this needs an AUR helper. Not fatal — + # the .exe below is built either way. + pacman) + if command -v paru >/dev/null 2>&1; then paru -S --needed --noconfirm nsis + elif command -v yay >/dev/null 2>&1; then yay -S --needed --noconfirm nsis + else warn "nsis is in the AUR; install it with an AUR helper (e.g. 'paru -S nsis')." + fi ;; + esac || warn "NSIS install failed — the .exe will still be built." +fi + +# ── 4. UI dependencies ──────────────────────────────────────────────────────── +# Same pnpm-then-npm fallback as install_linux.sh: npm needs --legacy-peer-deps +# because one dep declares an optional peer on a newer vite than we pin, and +# npm hard-fails the whole install on it where pnpm does not. +info "installing UI dependencies…" +( cd "$UI" + if command -v pnpm >/dev/null 2>&1; then + pnpm install --frozen-lockfile || pnpm install + else + npm install --legacy-peer-deps + fi ) || { err "UI dependency install failed"; exit 1; } + +# ── 5. build ────────────────────────────────────────────────────────────────── +# Build the Tauri APP, never `--workspace`. The daemon is consumed as a LIBRARY +# and cross-compiles fine, but its binary (daemon/src/main.rs, `vortex-l3d`) is +# a Linux CLI that uses bluer unconditionally and cannot build for Windows — +# asking for the workspace just fails on a binary nobody wants here. +BUNDLE_ARGS=(--no-bundle) +if [ "$WANT_INSTALLER" -eq 1 ] && command -v makensis >/dev/null 2>&1; then + BUNDLE_ARGS=(--bundles nsis) +fi + +info "cross-building for $TARGET (first run also downloads the MSVC CRT + SDK)…" +( cd "$UI" && npm run tauri build -- \ + --runner cargo-xwin --target "$TARGET" "${BUNDLE_ARGS[@]}" ) || { + err "build failed" + exit 1 +} + +# lld-link prints a wall of LNK4099 "cannot use debug info for libcmt.lib(…)" +# while linking. That is Microsoft shipping its CRT without the matching PDBs, +# not a problem with this build — the binary is complete and correct. + +# ── 6. report ───────────────────────────────────────────────────────────────── +[ -f "$EXE" ] || { err "build reported success but no .exe at $EXE"; exit 1; } +echo +ok "Windows binary: $EXE" +file "$EXE" 2>/dev/null | sed 's/^/ /' +SETUP="$(ls "$OUT_DIR"/bundle/nsis/*-setup.exe 2>/dev/null | head -1)" +[ -n "$SETUP" ] && ok "Windows installer: $SETUP" +echo +info "On the Windows machine:" +echo " • Copy the .exe (or run the installer) and launch it — no install step" +echo " is needed for the bare .exe; it sits in the tray like the Linux build." +echo " • Windows 11 already ships the WebView2 runtime. On Windows 10 without" +echo " it the window opens blank: install the Evergreen WebView2 Runtime" +echo " from Microsoft, then relaunch." +echo " • Pair from the phone exactly as with Linux — same BLE + Noise flow." +[ "$WANT_INSTALLER" -eq 1 ] && [ -z "$SETUP" ] && \ + warn "--installer was requested but no setup.exe was produced (makensis missing?)." +echo +warn "Cross-built binaries are UNSIGNED. SmartScreen will warn on first run" +warn "(\"More info\" → \"Run anyway\"). Signing needs a certificate and is set up" +warn "via bundler > windows > sign_command in tauri.conf.json." From 32c2d5d5ea26d22da9b545bdc59438c90f4a4d74 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Fri, 18 Sep 2026 15:10:12 +0200 Subject: [PATCH 6/7] feat(ui): show when BLE is down, and offer a way out of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The peer tile pulsed green whenever the phone was reachable, which meant it pulsed green over a link carrying nothing but LAN traffic. Every BLE-only feature — the notification mirror, conversation pages — was silently dead, and the only way to find out was to read the log. Today that cost hours: two bug reports ("no notifications", "conversation never loads") that were one dead Bluetooth link wearing a green dot. So the dot goes yellow when the phone is online without BLE, with a tooltip saying what it means and where to fix it. Yellow rather than amber because amber already means "connecting" — this is a healthy link missing one transport, not a link still being made. The state it needs did not exist on this side. `ts` freshness only proves SOME transport is working and LAN alone keeps it fresh, so the BLE loop now keeps a plain flag beside its writers map — the shape the portable loop already used — and `ble_link_up()` answers for whichever loop this build runs. It rides the peer-state DTO, which the UI is already listening to. And the way out: a Reset Bluetooth adapter button in Settings. BlueZ latches `Discovering = true` with no client behind it. In `stop_discovery_complete()` a failed MGMT_OP_STOP_DISCOVERY removes the client from `discovery_list` but returns before clearing `discovering` / `discovery_enable`, so every later StopDiscovery is refused with "no discovery started" and every StartDiscovery takes the queue-up-a-stop branch — sent with a NULL callback, so its failure is never seen. The adapter reports discovering for ever, no scan reaches the kernel, and every connect is starved. Seen here repeatedly: GATT connects timing out at 8s against a phone that `bluetoothctl connect` reached instantly. Nothing gentler clears it, and that was checked rather than assumed: StopDiscovery cannot reach the clearing code, `discovering_callback()` only resyncs on a kernel transition that will not happen by itself, and the mgmt-level `stop-find` returns "Permission Denied" without CAP_NET_ADMIN. The one unconditional reset is in BlueZ's `adapter_stop()` — the power-down path — and `Powered` is a plain D-Bus property we can set unprivileged. Upstream bluez/bluez#807 describes the bug and is closed as not planned; it is unfixed in 5.87. It stays a button rather than something the BLE loop does on its own because powering the adapter down drops every link it holds, the user's headphones included. Confirmed working against a live wedge. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/ble.rs | 73 +++++++++++++++++++ linux/ui-tauri/src-tauri/src/ipc.rs | 9 +++ linux/ui-tauri/src-tauri/src/lib.rs | 20 +++++ linux/ui-tauri/src/composables/useHome.ts | 11 +++ linux/ui-tauri/src/lib/bridge.ts | 4 + linux/ui-tauri/src/lib/locales/en.json | 10 ++- linux/ui-tauri/src/lib/locales/ru.json | 8 +- linux/ui-tauri/src/lib/locales/uz.json | 8 +- linux/ui-tauri/src/pages/home/Devices.vue | 12 ++- .../src/pages/settings/SettingsPage.vue | 54 ++++++++++++++ 10 files changed, 203 insertions(+), 6 deletions(-) diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs index ca9b3de..8e1284d 100644 --- a/linux/ui-tauri/src-tauri/src/ble.rs +++ b/linux/ui-tauri/src-tauri/src/ble.rs @@ -447,6 +447,75 @@ pub(crate) async fn find_trusted_presence_peer( res } +/// Power the adapter off and back on, to clear a wedged discovery state. +/// +/// BlueZ can latch `Discovering = true` with no client behind it. In +/// `stop_discovery_complete()` a failed `MGMT_OP_STOP_DISCOVERY` removes the +/// client from `discovery_list` but returns before clearing `discovering` / +/// `discovery_enable`, so afterwards every `StopDiscovery` is refused with "no +/// discovery started" and every `StartDiscovery` takes the "queue up a stop" +/// branch — sent with a NULL callback, so its failure is never seen. The +/// adapter then reports discovering forever and no scan reaches the kernel, +/// which starves every connect (bluez/bluez#807, open as of 5.87). +/// +/// Nothing else clears it. `StopDiscovery` cannot reach the clearing code, +/// `discovering_callback()` only resyncs on a kernel transition that will not +/// happen by itself, and the mgmt-level `stop-find` needs CAP_NET_ADMIN, which +/// a desktop app does not have. The one unconditional reset is in BlueZ's +/// `adapter_stop()` — the power-down path — and `Powered` is a plain D-Bus +/// property we can set unprivileged. +/// +/// Destructive by nature: powering the adapter down drops every link it holds, +/// the user's audio included. That is exactly why the BLE loop will not do this +/// on its own and it is offered as a button instead. +#[tauri::command] +pub(crate) async fn reset_bluetooth_adapter() -> Result<(), String> { + let session = bluer::Session::new() + .await + .map_err(|e| format!("bluetooth service unavailable: {e}"))?; + let adapter = session + .default_adapter() + .await + .map_err(|e| format!("no bluetooth adapter: {e}"))?; + + adapter + .set_powered(false) + .await + .map_err(|e| format!("could not power the adapter down: {e}"))?; + // BlueZ tears the adapter down asynchronously; powering back up too early + // races `adapter_stop()` and can leave the state we came here to clear. + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + adapter + .set_powered(true) + .await + .map_err(|e| format!("could not power the adapter back up: {e}"))?; + + // The link is gone with the adapter; say so rather than let the UI keep + // showing a session that died under it. + note_link_up(false); + tracing::info!("bluetooth adapter reset (power off/on) to clear wedged discovery"); + Ok(()) +} + +/// Whether a BLE audio-signal session is live right now. +/// +/// The writers map is the real source of truth, but it lives inside the BLE +/// loop and is reached only by the tasks handed a clone. The peer-state DTO is +/// built far from there and needs the same answer, so this mirrors it as a +/// plain flag — the shape `ble_portable::link_is_up` already uses on Windows, +/// so one accessor can serve both platforms. +static LINK_UP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// True while the laptop holds a BLE session to a phone. +pub(crate) fn link_is_up() -> bool { + LINK_UP.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Record link up/down alongside the writers-map insert/remove. +pub(crate) fn note_link_up(up: bool) { + LINK_UP.store(up, std::sync::atomic::Ordering::Relaxed); +} + /// Set when BlueZ rejects advertisement-monitor registration (old daemon / /// controller without passive-scan support) so we stop re-trying it and log /// the downgrade exactly once. @@ -1184,6 +1253,7 @@ pub(crate) async fn run_ble_persistent_loop( let mut m = ble_audio_writers.lock().await; m.insert(peer.peer_static_pub, writer_fn); } + note_link_up(true); // Wake the proximity watcher NOW — link-up is its eager-unlock // trigger; waiting out its 2s sampling tick is wasted unlock time. crate::proximity::nudge().notify_one(); @@ -1523,6 +1593,9 @@ pub(crate) async fn run_ble_persistent_loop( { let mut m = ble_audio_writers.lock().await; m.remove(&peer.peer_static_pub); + // Only dark once NOTHING is linked: a two-phone laptop tearing one + // session down still has BLE. + note_link_up(!m.is_empty()); } // Drop the session device's BlueZ entry: its cached advertisement // (token valid for up to ±2 buckets ≈ 3 min) would otherwise diff --git a/linux/ui-tauri/src-tauri/src/ipc.rs b/linux/ui-tauri/src-tauri/src/ipc.rs index b92efbd..ead99f5 100644 --- a/linux/ui-tauri/src-tauri/src/ipc.rs +++ b/linux/ui-tauri/src-tauri/src/ipc.rs @@ -104,6 +104,14 @@ pub(crate) struct PeerStateDto { earbuds: Option, charging: bool, ts: u64, + /// Whether a BLE session to the phone is live *right now*. + /// + /// `ts` freshness only proves SOME transport is working, and LAN alone is + /// enough to keep it fresh — so a phone can look perfectly connected while + /// every BLE-only feature (notification mirror, conversation pages) is + /// silently dead. The UI colours its dot on this so that state is one + /// glance rather than a log dig. + ble_linked: bool, } #[derive(Serialize, Clone)] @@ -145,6 +153,7 @@ pub(crate) fn app_state_to_dto(peer_pub_hex: String, s: AppState) -> PeerStateDt connected: e.connected, }), charging: s.charging, + ble_linked: crate::ble_link_up(), // Stamp OUR receive time, not the phone's `s.ts`. The UI's "online" // check is `laptop_now - ts < 180s`; trusting the phone's clock made a // connected phone read "offline" whenever its clock lagged ours (or it diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 0979e75..9ac83c6 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -97,6 +97,22 @@ mod fs_pull; mod contacts; mod desktop_apps; mod diagnostics; + +/// Whether a BLE session to a phone is live, on whichever loop this build runs. +/// +/// Linux drives BLE through the BlueZ loop in `ble`; every other platform uses +/// the portable one. Both keep the same flag, so callers that just want the +/// answer — the peer-state DTO, the heartbeat cadence — need not know which. +pub(crate) fn ble_link_up() -> bool { + #[cfg(target_os = "linux")] + { + crate::ble::link_is_up() + } + #[cfg(not(target_os = "linux"))] + { + crate::ble_portable::link_is_up() + } +} mod dnd; mod first_run; #[cfg(target_os = "linux")] @@ -617,6 +633,10 @@ pub fn run() { phone_files::fetch_phone_file, send_to_phone::send_to_phone, diagnostics::diagnostics_report, + // Linux-only: the wedged-discovery escape hatch. Elsewhere BlueZ + // is not the stack, so there is nothing to reset. + #[cfg(target_os = "linux")] + ble::reset_bluetooth_adapter, worker::start_scan, worker::refresh_state, ipc::get_peer_states, diff --git a/linux/ui-tauri/src/composables/useHome.ts b/linux/ui-tauri/src/composables/useHome.ts index 4dccfd2..8d24718 100644 --- a/linux/ui-tauri/src/composables/useHome.ts +++ b/linux/ui-tauri/src/composables/useHome.ts @@ -350,6 +350,17 @@ export const phoneOnline = computed(() => { // the pair, then fall back to the honest "Offline" if the phone never checks in. export const justPairedAt = ref(null); +/** Online, but with no BLE session behind it. + * + * LAN alone keeps the heartbeat fresh, so [phoneOnline] stays true while the + * BLE-only features — the notification mirror, conversation pages — are all + * silently dead. Worth its own colour: without it the only way to tell is + * reading the log. + */ +export const phoneBleDown = computed( + () => phoneOnline.value && primaryPeerState.value?.ble_linked === false, +); + /** Bridge state right after pairing: paired, not yet seen a heartbeat, but * within ~30 s of a successful pair. Flips to phoneOnline the instant the * first heartbeat arrives (primaryPeerState is reactive), or to phoneOffline diff --git a/linux/ui-tauri/src/lib/bridge.ts b/linux/ui-tauri/src/lib/bridge.ts index 5ffd227..fc18ec8 100644 --- a/linux/ui-tauri/src/lib/bridge.ts +++ b/linux/ui-tauri/src/lib/bridge.ts @@ -45,6 +45,10 @@ export interface PeerState { earbuds: { name: string; battery: number | null; connected: boolean } | null; charging: boolean; ts: number; + /** Whether a BLE session is live. `ts` freshness only proves SOME transport + * works, and LAN alone keeps it fresh — so this is what separates "fully + * connected" from "connected, but every BLE-only feature is dead". */ + ble_linked: boolean; } export interface PairingStartedEvent { diff --git a/linux/ui-tauri/src/lib/locales/en.json b/linux/ui-tauri/src/lib/locales/en.json index 6a6ceab..78dbfd2 100644 --- a/linux/ui-tauri/src/lib/locales/en.json +++ b/linux/ui-tauri/src/lib/locales/en.json @@ -65,13 +65,14 @@ "forget_all": "Forget all", "trusted": "trusted ✓", "connected": "Connected", + "ble_down_hint": "No BLE connection — reset adapter in settings", "connecting": "Connecting…", "offline": "Offline", "forget_title": "Forget device", "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", + "browse_tip": "Browse the phone’s files", "switch_scanning": "Looking for your other phones…", "switch_pick": "Switch to which phone?", "switch_none": "No other paired phone nearby.", @@ -182,7 +183,12 @@ "sec_appearance": "Appearance", "sec_continuity": "Continuity", "sec_privacy": "Privacy & proximity", - "footer": "End-to-end encrypted over your own link" + "footer": "End-to-end encrypted over your own link", + "ble_reset": "Reset Bluetooth adapter", + "ble_reset_hint": "Fixes a stuck Bluetooth link. Briefly disconnects headphones and other devices.", + "ble_reset_busy": "Resetting…", + "ble_reset_done": "Adapter reset — reconnecting", + "ble_reset_failed": "Could not reset the adapter" }, "nav": { "home": "My devices", diff --git a/linux/ui-tauri/src/lib/locales/ru.json b/linux/ui-tauri/src/lib/locales/ru.json index 8d6a01b..f266617 100644 --- a/linux/ui-tauri/src/lib/locales/ru.json +++ b/linux/ui-tauri/src/lib/locales/ru.json @@ -65,6 +65,7 @@ "forget_all": "Забыть все", "trusted": "доверенное ✓", "connected": "Подключено", + "ble_down_hint": "Нет BLE-соединения — сбросьте адаптер в настройках", "connecting": "Подключение…", "offline": "Не в сети", "forget_title": "Забыть устройство", @@ -182,7 +183,12 @@ "sec_appearance": "Внешний вид", "sec_continuity": "Непрерывность", "sec_privacy": "Конфиденциальность и близость", - "footer": "Сквозное шифрование через ваше собственное соединение" + "footer": "Сквозное шифрование через ваше собственное соединение", + "ble_reset": "Сбросить Bluetooth-адаптер", + "ble_reset_hint": "Исправляет зависшее Bluetooth-соединение. Наушники и другие устройства ненадолго отключатся.", + "ble_reset_busy": "Сброс…", + "ble_reset_done": "Адаптер сброшен — переподключение", + "ble_reset_failed": "Не удалось сбросить адаптер" }, "nav": { "home": "Мои устройства", diff --git a/linux/ui-tauri/src/lib/locales/uz.json b/linux/ui-tauri/src/lib/locales/uz.json index d2ffd96..5a1a3d8 100644 --- a/linux/ui-tauri/src/lib/locales/uz.json +++ b/linux/ui-tauri/src/lib/locales/uz.json @@ -65,6 +65,7 @@ "forget_all": "Hammasini unutish", "trusted": "ishonchli ✓", "connected": "Ulangan", + "ble_down_hint": "BLE aloqasi yo‘q — sozlamalarda adapterni qayta ishga tushiring", "connecting": "Ulanmoqda…", "offline": "Oflayn", "forget_title": "Qurilmani unutish", @@ -182,7 +183,12 @@ "sec_appearance": "Ko'rinish", "sec_continuity": "Uzluksizlik", "sec_privacy": "Maxfiylik va yaqinlik", - "footer": "O'z ulanishingiz orqali uchidan-uchigacha shifrlangan" + "footer": "O'z ulanishingiz orqali uchidan-uchigacha shifrlangan", + "ble_reset": "Bluetooth adapterini qayta ishga tushirish", + "ble_reset_hint": "Qotib qolgan Bluetooth aloqasini tuzatadi. Quloqchin va boshqa qurilmalar qisqa muddatga uziladi.", + "ble_reset_busy": "Qayta ishga tushirilmoqda…", + "ble_reset_done": "Adapter qayta ishga tushdi — ulanmoqda", + "ble_reset_failed": "Adapterni qayta ishga tushirib bo‘lmadi" }, "nav": { "home": "Mening qurilmalarim", diff --git a/linux/ui-tauri/src/pages/home/Devices.vue b/linux/ui-tauri/src/pages/home/Devices.vue index 29af706..09bda03 100644 --- a/linux/ui-tauri/src/pages/home/Devices.vue +++ b/linux/ui-tauri/src/pages/home/Devices.vue @@ -32,6 +32,7 @@ import { openPairPhoneModal, phoneOnline, phoneConnecting, + phoneBleDown, primaryPeer, primaryPeerState, startMirror, @@ -243,11 +244,18 @@ const earbudsStatus = computed(() => {
+ - + {{ phoneOnline ? t("peers.connected") : phoneConnecting ? t("peers.connecting") : t("peers.offline") }} +
+
+ + + +
+
{{ t("settings.ble_reset") }}
+
+ {{ bleResetMsg ?? t("settings.ble_reset_hint") }} +
+
+ +
+
+
From 2f246f81f772921a3cbcbe6d4e6ed6bd7ce78edc Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Fri, 18 Sep 2026 15:10:43 +0200 Subject: [PATCH 7/7] fix(ui): size the main window to the content instead of a fixed guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window opened at the 920x860 from `tauri.conf.json` regardless of what it had to show, and on a scaled display that is too narrow: buttons wrapped, the Devices page scrolled, and "Use phone as webcam" hung over the edge of its own card. A constant cannot be right here — the width the UI needs depends on the display's scale factor, the UI font and the locale, none of them known when the config is written. So the layout is measured once at startup and the window grown to fit. Measuring is the whole difficulty, and the obvious ways do not work: * `documentElement.scrollWidth` — the flex and grid containers absorb the pressure instead of passing it up; the document reports "fits" while a button visibly overhangs. * comparing each element to its parent — an element with `overflow: visible` lets a too-wide child spill out in plain sight without any of its own metrics changing. * `width: max-content` on the root — works, but measures PROSE unwrapped: one setting hint became a single line and asked for a 2845px window. What does work is asking the layout directly. `html.vx-measuring` forbids wrapping ON CONTROLS ONLY — a button that wraps looks broken, a paragraph that wraps is doing its job — and frees the three things that otherwise swallow the overflow: `min-w-0`, `flex-1`'s `0%` basis, and `grid-cols-N`, whose `minmax(0, 1fr)` is the same trick at track level and cannot be reached by element CSS at all. The controls then overflow, the overflow reaches the document, and `scrollWidth` reports it. Width first, then height, with a relayout in between. They are not independent: at a narrow width the content wraps taller, so a height measured before the widening describes a layout about to stop existing. Measured together it asked for 961px for a page that needed less. Height is asked of the elements that actually scroll, in normal rendering. The page never scrolls — `
` is `overflow-y-auto` and keeps its overflow to itself — so the document always looked like it fit and the scrollbar survived every "fix" until this one. Sent as a RATIO, not a pixel count. CSS pixels and Tauri's logical pixels are not the same unit: this display reports a 538px viewport in a 920px window while `scale_factor` reads 1.0, so a measured 872 handed to `LogicalSize` looks SMALLER than the window and the fit silently does nothing. A ratio needs no agreement about units. **It sizes for the LANDING page and then stops.** Every fit is grow-only, so a sizing pass left running lets each page in turn redefine the window and never give the space back: opening Settings stretched it to 3533px, because that page holds the widest content in the app. Sizing ends when the landing page settles — nothing left to grow, and the page has actually populated — or at the first navigation away from it, whichever comes first. After that the window is the window, and the rest of the app wraps or scrolls inside it, which is what those layouts are for. That stop is driven by `router.afterEach`, guarded on `from.matched.length`. Neither part is optional: `hashchange` never fires because vue-router's hash mode navigates with `history.pushState`, and `afterEach` without the guard also catches the INITIAL route resolution, which killed the sizing before it had measured anything. Grow-only throughout, so a window widened by hand is never clawed back, and the measurement is refused outright when the webview reports a zero viewport — it does that mid-resize, and a window was once grown to 2220px off exactly that. Re-measured on DOM mutation rather than a timer: the widest things only exist once peer state arrives over IPC, so any fixed delay is a guess about how long that takes. Co-Authored-By: Claude Opus 5 (1M context) --- linux/ui-tauri/src-tauri/src/lib.rs | 15 ++ linux/ui-tauri/src-tauri/src/window.rs | 107 +++++++++ linux/ui-tauri/src/lib/fitWindow.ts | 293 +++++++++++++++++++++++++ linux/ui-tauri/src/main.ts | 18 ++ linux/ui-tauri/src/style.css | 76 +++++++ 5 files changed, 509 insertions(+) create mode 100644 linux/ui-tauri/src/lib/fitWindow.ts diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 9ac83c6..50efed5 100644 --- a/linux/ui-tauri/src-tauri/src/lib.rs +++ b/linux/ui-tauri/src-tauri/src/lib.rs @@ -566,6 +566,20 @@ pub fn run() { let special = std::env::args() .any(|a| a == "--hidden" || a == "--clipboard" || a == "--share"); if !special { + // Shown here, at the configured size, rather than after the + // webview reports its layout. + // + // Measuring first would be better — open once, already the + // right size — but it cannot work on this stack: WebKitGTK + // does not lay out an unmapped window, so everything the + // page measures while hidden comes back at or near zero. + // Tried it; the window opened at the 560x600 minimum. + // + // So the window appears at its configured size and + // `fit_main_window` GROWS it afterwards if the content + // turns out not to fit. That costs a visible resize on + // exactly the displays that need one, and nothing at all + // on the displays that do not. window::present_main(app.handle()); } } @@ -637,6 +651,7 @@ pub fn run() { // is not the stack, so there is nothing to reset. #[cfg(target_os = "linux")] ble::reset_bluetooth_adapter, + window::fit_main_window, worker::start_scan, worker::refresh_state, ipc::get_peer_states, diff --git a/linux/ui-tauri/src-tauri/src/window.rs b/linux/ui-tauri/src-tauri/src/window.rs index d3b55a7..814f0b7 100644 --- a/linux/ui-tauri/src-tauri/src/window.rs +++ b/linux/ui-tauri/src-tauri/src/window.rs @@ -18,6 +18,113 @@ use tauri::Manager; /// `hide()` + `prevent_close()`), so the id stays valid for the process. static MAIN_XID: AtomicU32 = AtomicU32::new(0); +/// Size the main window to the content the webview just measured, then reveal it. +/// +/// The window is built `visible: false` and stays hidden until this runs, so +/// the user never sees the wrong size: the layout happens off-screen, gets +/// measured, and the window is shown once at the size that fits. A fixed width +/// in `tauri.conf.json` cannot do that — the content's real width depends on +/// the display's scale factor, the UI font and the locale (German and Russian +/// labels are materially wider than English), none of which are known at build +/// time. +/// +/// `width`/`height` are CSS pixels, which are logical pixels — the same units +/// `LogicalSize` takes — so the scale factor needs no conversion here. +/// +/// Clamped on both ends: never below the configured minimum (a measurement +/// that comes back absurdly small must not produce an unusable window) and +/// never past the monitor's usable area (a long unwrapped line must not push +/// the window off-screen or behind the panel). +#[tauri::command] +pub(crate) fn fit_main_window( + app: tauri::AppHandle, + width_ratio: f64, + height_ratio: f64, + reason: Option, + probe: Option, +) -> Result { + let Some(w) = app.get_webview_window("main") else { + return Err("no main window".into()); + }; + + // The monitor's usable box. `monitor.size()` is PHYSICAL, so divide by the + // scale factor to land back in the logical units LogicalSize wants. + let (max_w, max_h) = match w.current_monitor() { + Ok(Some(m)) => { + let sf = m.scale_factor(); + let sz = m.size(); + // 0.92 rather than 1.0: leave room for the shell's panel/dock and a + // window border, which the monitor size does not account for. + ( + (sz.width as f64 / sf) * 0.92, + (sz.height as f64 / sf) * 0.92, + ) + } + // No monitor info (headless, race at startup): trust the measurement + // rather than refuse to size at all. + _ => (f64::MAX, f64::MAX), + }; + + // Mirrors tauri.conf.json's minWidth/minHeight. + const MIN_W: f64 = 560.0; + const MIN_H: f64 = 600.0; + + // GROW ONLY, never shrink. + // + // The measurement is a lower bound on what the content needs, not a + // statement about what the window should be: a webview that reports early + // (or reports a viewport the compositor has not laid out yet) comes back + // far too small, and honouring that shrinks the window to the minimum — + // which is precisely the bug this guard exists to stop. It also means a + // window the user widened by hand is never clawed back. + let cur = w + .inner_size() + .ok() + .and_then(|sz| w.scale_factor().ok().map(|sf| (sz.width as f64 / sf, sz.height as f64 / sf))) + .unwrap_or((0.0, 0.0)); + + // The webview measured in CSS pixels, which are not this side's logical + // pixels on a scaled display, so it sends how much MORE room it needs + // rather than an absolute size. Applied to the width we actually have, the + // units cancel. + let target_w = (cur.0 * width_ratio).max(MIN_W).max(cur.0).min(max_w); + let target_h = (cur.1 * height_ratio).max(MIN_H).max(cur.1).min(max_h); + + // Nothing to do — don't churn the window (or move it) for a no-op. Also the + // caller's stop condition: it re-measures until this says "no". + if (target_w - cur.0).abs() < 1.0 && (target_h - cur.1).abs() < 1.0 { + tracing::info!( + want_w = target_w, + cur_w = cur.0, + probe = probe.as_deref().unwrap_or(""), + "window fit: already wide enough" + ); + return Ok(false); + } + + let win = w.clone(); + let _ = w.run_on_main_thread(move || { + let _ = win.set_size(tauri::LogicalSize::new(target_w, target_h)); + // Re-centre: the window was centred at the old size, so growing it + // from the top-left would drift it off centre (and possibly off-screen). + let _ = win.center(); + }); + // Debug, not info: this fires a few times per launch while the layout + // converges, and says nothing a working app needs to report. It is kept + // because the `probe` string is what made this diagnosable at all — the + // measurements disagreed with each other for a long time, and reading them + // side by side is what settled it. `RUST_LOG=vortex_ui_tauri_lib::window=debug`. + tracing::debug!( + target_w, + target_h, + reason = reason.as_deref().unwrap_or("boot"), + probe = probe.as_deref().unwrap_or(""), + "main window grown to fit its content" + ); + + Ok(true) +} + /// Show the main window and bring it to the front. Safe from any thread. pub(crate) fn present_main(app: &tauri::AppHandle) { if let Some(w) = app.get_webview_window("main") { diff --git a/linux/ui-tauri/src/lib/fitWindow.ts b/linux/ui-tauri/src/lib/fitWindow.ts new file mode 100644 index 0000000..d20f07b --- /dev/null +++ b/linux/ui-tauri/src/lib/fitWindow.ts @@ -0,0 +1,293 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Open the window wide enough that the UI never has to wrap. + * + * A fixed width in `tauri.conf.json` cannot do this: how wide the UI needs to + * be depends on the display's scale factor, the UI font and the locale (the + * Russian and Uzbek labels run materially wider than the English ones), none of + * which are known when the config is written. + * + * The measurement is the whole trick, and the obvious ways to take it do not + * work. `scrollWidth` on the document reports "fits" because the flex and grid + * containers absorb the pressure instead of passing it up. Comparing each + * element against its parent misses the same thing, because an element with + * `overflow: visible` lets a too-wide child spill out in plain sight without + * any of its own metrics changing. Both were tried against a live window whose + * button was visibly hanging out of its card, and both reported zero. + * + * So the layout is asked directly instead: `html.vx-measuring` (see + * `style.css`) forbids wrapping and lets the root size to its content, which + * makes the UI state the width it actually wants. One read, class off, done. + */ + +/** Width the UI needs in order not to wrap, in CSS pixels, plus the raw + * numbers behind it — reported to the log so a wrong answer can be told apart + * from a measurement that never happened. */ +function measureNatural(): { + natural: number; + naturalHeight: number; + appRect: number; + appScroll: number; + htmlScroll: number; + bodyScroll: number; + inner: number; +} { + const root = document.getElementById("app"); + const html = document.documentElement; + const inner = window.innerWidth; + if (!root) { + return { + natural: 0, + naturalHeight: 0, + appRect: 0, + appScroll: 0, + htmlScroll: 0, + bodyScroll: 0, + inner, + }; + } + + html.classList.add("vx-measuring"); + // Reading a layout property forces the reflow, so everything below is taken + // against the measuring styles rather than the ones being replaced. + void root.offsetWidth; + + const appRect = Math.ceil(root.getBoundingClientRect().width); + const appScroll = root.scrollWidth; + const htmlScroll = html.scrollWidth; + const bodyScroll = document.body.scrollWidth; + // Height has to be read HERE, with the measuring styles on, for the same + // reason the width does: the page itself never scrolls — `
` is + // `overflow-y-auto` and keeps the overflow to itself — so the document + // reports no overflow at all and the window is never grown. Under + // `overflow: visible` that inner scroll is released and the content pushes + // the page to its true height. + const naturalHeight = Math.max( + html.scrollHeight, + document.body.scrollHeight, + root.scrollHeight, + ); + + html.classList.remove("vx-measuring"); + void root.offsetWidth; + + // The root keeps its normal width, so the controls that cannot wrap overflow + // it instead — and with the flex and grid minimums freed (see `style.css`) + // that overflow reaches the document, where `scrollWidth` reports it. Prose + // is left wrapping, so this is the width the CONTROLS need and nothing more. + const natural = Math.max(appScroll, htmlScroll, bodyScroll, appRect); + return { natural, naturalHeight, appRect, appScroll, htmlScroll, bodyScroll, inner }; +} + +/** + * Measure and, if the window is too small for the result, grow it. + * + * Grow-only: the measurement is what the content needs, not an opinion about + * what the window should be, so it can widen a window that is too narrow but + * never claw back one the user widened themselves. + */ +/** + * Wait for the viewport to actually change after a resize, then settle. + * + * The backend resize is asynchronous — it returns as soon as the request is + * made, not when the webview has been relaid out — so anything measured + * immediately afterwards is still the OLD layout. + */ +/** + * How much taller the content is than the box actually scrolling it. + * + * Measured in NORMAL rendering, not in measuring mode, and asked of the real + * scrollers rather than the document. The page itself never scrolls here — + * `
` is `overflow-y-auto`, so it keeps its overflow to itself and + * `documentElement.scrollHeight` equals its client height no matter how much + * content there is. Inferring the height from measuring mode instead + * under-reported it and left the scrollbar exactly where it was. + * + * So: find every element that can scroll vertically, and take the largest gap + * between what it holds and what it shows. That gap IS the scrollbar. + */ +function verticalDeficit(): number { + let worst = 0; + for (const el of Array.from(document.querySelectorAll("*"))) { + const overflowY = getComputedStyle(el).overflowY; + if (overflowY !== "auto" && overflowY !== "scroll") continue; + const deficit = el.scrollHeight - el.clientHeight; + if (deficit > worst) worst = deficit; + } + const doc = document.documentElement; + return Math.max(worst, doc.scrollHeight - doc.clientHeight); +} + +/** Let the engine finish laying out before anything is measured. Raced against + * a timer because WebKitGTK does not drive animation frames for a window that + * is not mapped, so the callback pair can simply never fire. */ +function layoutSettled(): Promise { + const frames = new Promise((r) => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ); + return Promise.race([frames, new Promise((r) => setTimeout(r, 300))]); +} + +function waitForViewport(previousWidth: number, ms = 600): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + window.removeEventListener("resize", onResize); + resolve(); + }; + const onResize = () => { + if (window.innerWidth !== previousWidth) finish(); + }; + window.addEventListener("resize", onResize); + // The resize may never come (already the right size, or refused at the + // monitor's limit); this is not a failure, just nothing to wait for. + setTimeout(finish, ms); + }); +} + +/** + * Measure and, if the window is too small for the result, grow it. + * + * WIDTH FIRST, THEN HEIGHT — in two passes, with a relayout in between. + * + * The two are not independent: at a narrow width the content wraps into more + * lines and stands taller, so a height measured before the widening describes a + * layout that is about to stop existing. Measured together in one pass the + * height came out 961px for a page that needs far less once the cards sit side + * by side. So the width is applied first, the webview is allowed to reflow, and + * only then is the height worth asking about. + * + * Grow-only in both directions: the measurement says what the content needs, + * never what the window should be, so this can widen a cramped window but never + * claw back one sized by hand. + */ +async function fitOnce(): Promise { + // Refuse to measure a layout that is not currently laid out. + // + // The webview reports a zero viewport while the window is being resized or + // has not been mapped yet, and in that state the numbers are not small — they + // are wrong. Caught live: `inner=0 app=2 appScroll=2220`, off which the + // window was grown to 2220px. + if (window.innerWidth < 200 || window.innerHeight < 200) return false; + + const first = measureNatural(); + if (first.natural < 200 || first.natural < window.innerWidth * 0.5) return false; + + let grew = false; + + // Phase 1 — width only. `heightRatio: 1` leaves the height alone, because + // the height we could measure right now is the wrong one. + if (first.natural > first.inner + 1) { + const before = window.innerWidth; + try { + await invoke("fit_main_window", { + widthRatio: first.natural / first.inner, + heightRatio: 1, + probe: `phase=width w=${first.natural} inner=${first.inner}`, + }); + grew = true; + } catch { + return grew; + } + await waitForViewport(before); + await layoutSettled(); + if (window.innerWidth < 200 || window.innerHeight < 200) return grew; + } + + // Phase 2 — height, measured against the width the window now actually has, + // and against the element that is actually doing the scrolling. + const deficit = verticalDeficit(); + if (deficit > 1) { + try { + await invoke("fit_main_window", { + widthRatio: 1, + heightRatio: (window.innerHeight + deficit) / window.innerHeight, + probe: `phase=height deficit=${deficit} inner=${window.innerWidth}x${window.innerHeight}`, + }); + grew = true; + } catch { + // Non-fatal: costs the right size, never the window. + } + } + + return grew; +} + +/** Coalesce bursts of DOM changes into one measurement. */ +function debounce(fn: () => void, ms: number): () => void { + let t: ReturnType | undefined; + return () => { + if (t !== undefined) clearTimeout(t); + t = setTimeout(fn, ms); + }; +} + +/** + * Fit at boot, and again whenever the DOM changes enough to need it. + * + * Watching the DOM rather than measuring on a timer is the point. The widest + * things in the UI — the device tiles and their buttons — only exist once peer + * state has arrived over IPC, so any fixed delay is a guess about how long that + * takes: too short and it measures an empty page (observed: the first pass + * found nothing while a button was visibly overhanging), too long and the user + * watches the window resize under them. A mutation IS the signal that something + * appeared, so it is what triggers the re-measure. + * + * Safe to leave running for the session: every fit is grow-only, so this + * converges and then costs one debounced measurement per DOM change. + */ +/** Set by [`initWindowFit`]; called by [`endWindowFit`]. */ +let endFit: (() => void) | null = null; + +/** + * Stop fitting. Sizing belongs to the LANDING page only. + * + * Every fit is grow-only, so left running it would let each page in turn + * redefine the window and never give the space back — open Settings once and + * the window is as wide as the widest thing on it for the rest of the session. + * Observed: Devices → Settings stretched the window to 3533px. + * + * Called from the router rather than a `hashchange` listener, which does not + * fire: vue-router's `createWebHashHistory` navigates with `history.pushState`, + * so in-app navigation changes the hash without ever raising that event. The + * first version of this stop condition could therefore never trigger. + */ +export function endWindowFit(): void { + endFit?.(); +} + +export function initWindowFit(): void { + const root = document.getElementById("app"); + if (!root) return; + + let populated = false; + let stopped = false; + + const observer = new MutationObserver(() => { + populated = true; + schedule(); + }); + + function stop() { + if (stopped) return; + stopped = true; + observer.disconnect(); + } + endFit = stop; + + async function run() { + if (stopped) return; + const grew = await fitOnce(); + // Settled: the page has content and asked for nothing more. Sizing is a + // startup job, and it is now done. + if (!grew && populated) stop(); + } + + const schedule = debounce(() => void run(), 200); + + observer.observe(root, { childList: true, subtree: true, characterData: true }); + void run(); +} diff --git a/linux/ui-tauri/src/main.ts b/linux/ui-tauri/src/main.ts index 68b4fce..04e4aae 100644 --- a/linux/ui-tauri/src/main.ts +++ b/linux/ui-tauri/src/main.ts @@ -9,6 +9,7 @@ import { initContacts } from "@/composables/useContacts"; import { initRecents } from "@/composables/useRecents"; import { initMessages } from "@/composables/useMessages"; import "@/lib/theme"; // initialise theme (side-effect) +import { initWindowFit, endWindowFit } from "@/lib/fitWindow"; // Subscribe to peer/identity state + home logic once, app-wide, so it persists // across route changes (no "offline" flash / no re-subscribe on remount). @@ -19,3 +20,20 @@ initRecents(); initMessages(); createApp(App).use(i18n).use(router).mount("#app"); + +// The window is still hidden at this point (tauri.conf `visible: false`). +// Measure the laid-out UI and hand Rust the size to open at, so the first +// frame the user sees already fits its own buttons. +initWindowFit(); +// The landing page decides the window size; the first navigation away from it +// ends the sizing. Wired to the router because vue-router's hash mode uses +// pushState, so no DOM event reports an in-app route change. +// +// `from.matched.length` is what separates a real navigation from the initial +// one: vue-router fires `afterEach` for the first route resolution as well, and +// treating that as "the user navigated" stopped the fit before it had measured +// anything at all — the window stayed at its configured size with not one fit +// in the log. +router.afterEach((_to, from) => { + if (from.matched.length > 0) endWindowFit(); +}); diff --git a/linux/ui-tauri/src/style.css b/linux/ui-tauri/src/style.css index 7b1cf09..220fbb5 100644 --- a/linux/ui-tauri/src/style.css +++ b/linux/ui-tauri/src/style.css @@ -88,6 +88,82 @@ cursor: text; } + /* Measurement mode — only ever on for the instant `lib/fitWindow.ts` reads + the layout, never during normal rendering. + + The window has to open wide enough that nothing wraps, which means knowing + how wide the UI would be if it could not wrap. Normal rendering cannot + answer that: wrapped text reports "fits" at every width, which is how the + window stayed too narrow. Forcing nowrap and letting the root size to its + content makes the layout state its real width, we read it once, and the + class comes straight back off. + + Doing it this way rather than pinning controls to nowrap permanently also + keeps the failure mode kind: if the window cannot be made wide enough (a + small display), text wraps as it always did instead of hanging out of its + card. + + `.selectable-text` is exempt: message bodies and other prose are meant to + wrap, and measuring them unwrapped would ask for a window as wide as the + longest message ever received. */ + /* CONTROLS only — never prose. + + A button that wraps looks broken, so buttons are what the window must be + wide enough for. Paragraphs are the opposite: they are supposed to wrap, + and measuring them unwrapped asks for a window as wide as the longest + sentence in the app. Measured live with prose included: the Settings page + reported 2845px, nearly the whole screen, because one setting hint became + a single line. */ + html.vx-measuring button, + html.vx-measuring label, + html.vx-measuring [role="button"], + html.vx-measuring [role="tab"] { + white-space: nowrap !important; + overflow-wrap: normal !important; + word-break: normal !important; + hyphens: none !important; + } + + /* Grid tracks have to be freed too. + + Tailwind's `grid-cols-N` is `repeat(N, minmax(0, 1fr))`, and that `0` + minimum is the grid's version of `min-w-0`: the tracks may shrink to + nothing and will never grow to fit their contents, so the grid never asks + the page for more room no matter how wide its cards want to be. Element- + level `min-width` cannot help — a track sizing function is not an element. + + Restated with a `max-content` minimum, each track asks for the width its + content needs while the column COUNT is preserved, so the measurement + reflects the layout the user will actually see. Enumerated rather than + matched with a wildcard because the count cannot be recovered from a + selector, and because there are only two of them in the app. */ + html.vx-measuring .grid-cols-2 { + grid-template-columns: repeat(2, minmax(max-content, 1fr)) !important; + } + + html.vx-measuring .grid-cols-7 { + grid-template-columns: repeat(7, minmax(max-content, 1fr)) !important; + } + + html.vx-measuring * { + /* Let the layout push outwards instead of absorbing the pressure. + + These three are what normally stop content from widening anything, and + each has to go or the measurement just reads the viewport back (observed: + want_w came back as exactly the current window width every time). + + `min-width: auto` undoes Tailwind's `min-w-0`, which exists precisely so + a flex item MAY shrink below its content — sensible in normal rendering, + fatal to a measurement. `flex-basis: auto` undoes `flex-1`'s `0%` basis + so items size to their content, and `flex-shrink: 0` stops them being + compressed back. `overflow: visible` keeps a scroll container from + hiding the very content being measured. */ + overflow: visible !important; + min-width: auto !important; + flex-basis: auto !important; + flex-shrink: 0 !important; + } + * { @apply border-border; }