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 @@
VortexVortex Clipboard
+
+ VortexLets your paired laptop control this phone (tap, swipe, Back/Home) while screen mirroring. No data is collected.Open on this phoneOpen 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(() => {
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;
}