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/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/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() } 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..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() { @@ -1266,6 +1301,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/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 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/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." 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/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/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/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/lan.rs b/linux/ui-tauri/src-tauri/src/lan.rs index 64e1ce8..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 @@ -756,6 +788,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={}", diff --git a/linux/ui-tauri/src-tauri/src/lib.rs b/linux/ui-tauri/src-tauri/src/lib.rs index 0979e75..50efed5 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")] @@ -550,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()); } } @@ -617,6 +647,11 @@ 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, + 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/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/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/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/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/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") }} +
+
+ +
+
+
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; }