Conversation
Copying on the phone never reached the laptop, by either route (auto capture or the share sheet). Three separate defects stacked up. **The BLE link could never connect.** With two remembered laptops the presence loop multiplexed their tokens by stopping and restarting the advertising set every MULTIPLEX_DWELL_MS. Each restart re-randomises the RPA, so the laptop resolved a token, began a GATT connect, and spent its whole 8 s timeout dialling an address the phone had already abandoned — observed as eight consecutive attempts to eight distinct addresses, all timing out, then backoff. The dwell can never exceed a connect timeout, so this was unwinnable at any dwell. Moving to the AdvertisingSet API lets the service data be swapped in place, so the address survives the whole pass. A connect is dialled against an address, not a token, so the laptop can now finish connecting even after we have moved on to the next peer's token. Privacy is unaffected — the controller still rotates the RPA on its own timer; we just stop forcing it every dwell. A pairing window still gets a fresh set, since it should not inherit the presence beacon's address. **Clipboard text was the only payload with no fallback.** It is a BLE notify and nothing else on both sides, so with the link down sealAndNotify returned false on its first line and the text was dropped — no retry, no LAN path, and no message, while the share sheet toasted "Sending text to laptop…". File and image offers already ride the bulk-sync done frame when BLE is down; text now does too, via a one-slot outbox (latest wins, bounded by age, because a clipboard holds one item and a stale one would surprise). The daemon feeds it to the same sink the BLE path uses, so the loop guard and history behave identically whichever link carried it. The done frame is logged verbatim, so it is now redacted to a length before logging — clipboard content must never reach the log. **Failures were reported as success.** The collector ignored the return of every send. It now tracks whether a peer actually took the bytes, with a partially-sent chunk burst counted as failure since it reassembles into nothing on the far side. Also adds ACTION_PROCESS_TEXT: "Vortex" in the text-selection toolbar, beside Copy and Share. Automatic capture turns out to be unreachable — the platform gates clipboard access on being the default IME, the focused window, or holding READ_CLIPBOARD_IN_BACKGROUND, and that permission is signature|role. Measured on a LineageOS build with the READ_CLIPBOARD AppOp set to allow, the app doze-whitelisted and a foreground service running: the listener callback was never delivered once in 3.8 days. So ClipboardAccess's claim that the AppOp is "the operative grant" was wrong, and is corrected with the measurements. PROCESS_TEXT needs no clipboard read at all — the selection arrives in the intent. Tested: full daemon and Android unit suites, plus new coverage for the outbox semantics, the done-frame parse, the log redaction, and a guard that the PROCESS_TEXT activity never writes back over the user's selection. NOT verified on hardware: the installed app is signed with a key this machine does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The laptop re-pulled the same 945 KB of SMS history on 26 of 27 consecutive bulk-sync rounds, and re-pulled the call-log history, SMS ids and contacts alongside it — about a megabyte every 12.5 s, indefinitely, with the phone re-reading 5000 messages from its provider each time. The request always asked for `sms_history` since=0. The watermark was being computed correctly and then thrown away. Every per-peer cache file resolves through `peer_dir()`, which is keyed on `arbiter::active()`; with no active peer it returns None, `peer_file()` returns None, and each write is skipped by an `if let Some(p)`. So the watermark read back as 0 forever and the phone dutifully re-sent everything. The give-away on disk was `~/.cache/vortex/peers/` sitting empty after hours of rounds, even though `peer_dir()` calls `create_dir_all` on every one of them. Ownership had only two sources: worker startup — and only when exactly ONE peer is stored, since with several "which phone's data" has no answer — or an explicit pair/switch. A peer paired AFTER startup therefore never became active, and with BLE unable to connect nothing else ever claimed it, so `active()` stayed None for the rest of the process. That is how a laptop ends up mirroring a phone while persisting nothing about it. A completed IK proves who the peer is, so the LAN loop can take ownership on exactly the same evidence the BLE loop already uses. The claim goes in before the bulk datasets are delivered, so the very first round persists rather than costing one more full pull. `claim` is idempotent for the current owner and refuses when another peer owns the session, so this cannot steal an active BLE session; a refusal just leaves the cache pointed at the phone the user is looking at. With two trusted phones the LAN loop now claims whichever answers first, which is the one actually present — and matches what BLE has always done. Not a fix for the 2.5 s reconnect bursts, which turned out not to be a defect: the baseline is 12.5 s, and the bursts are the two documented brisk-poll cases — a mirrored call clearing its pill, and a queued file pull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hold
BLE never worked. Every connect died about 200 ms in, before a single ATT
exchange, and had done for a long time — the phone's own counter read
"Bond loss detected, count: 1031". The laptop reconnected every 12-20 s
forever, LAN carried everything, and nothing in our logs on either side
said why.
The phone held an LE bond for the laptop; the laptop held none. So on each
connect the phone tried to encrypt with its stored LTK, the central answered
by pairing instead, and the phone read that as bond loss:
btm_sec_report_bond_loss: reason: Bonded unencrypted central wants to pair
disconnect_acl: ... reason:HCI_ERR_AUTH_FAILURE
The asymmetry was ours. `pairing.rs` decided against LE bonding deliberately
("NOTE: no BT bond here", after the 2026-06-02 investigation found every bond
attempt routed over BR/EDR on a dual-mode phone and yielding no IRK), and
`PeerStore.loadPeerBtAddr` documents the resulting invariant in its contract:
"Vortex itself never bonds". The `createBond()` at the end of pairing was the
one place contradicting it, so every pairing left us holding a key the laptop
has never had and by design never will.
Nothing wanted that bond. No characteristic in `GattServer` asks for an
encrypted link, and the laptop uses a public static address, so there is no
IRK to want either. Removing the call costs nothing and is the whole fix for
new pairings.
Existing installs need more, because the bad state is already on disk and
Android hides profile-less LE bonds from Settings, so a user cannot clear it
by hand. `clearStaleLaptopBonds()` runs on every service start and drops any
bond held for a stored laptop address — so an affected phone heals itself on
the next start, with no re-pairing. Cheap: `BondCleaner.removeBond` no-ops on
BOND_NONE, over the trusted-peer list we already read.
NOT for Windows. Its `bonded()` fast path reads pairing state, but it is an
optimisation with an explicit scan fallback ("hence the fallback rather than a
failure"), the Windows side never calls a pair API at all, and this call
predates the platform seam by seven weeks. If that fast path is ever worth
having, the bond must be created FROM Windows and on both sides — one-sided
bonding is this bug, not a fix for it.
Verified on hardware. Before: every ACL 0.3-0.5 s, torn down locally by the
phone. After a fresh pairing over BLE with Wi-Fi off, so no LAN path could
mask the result: sessions of 1m54s, 2m02s, 4m35s and 16m00s, all ended by the
laptop rather than aborted, DLE negotiated to 251 octets, zero auth failures
in the phone's persistent history, and a security record showing what we
wanted — le_linkkey_known:F, ble_enc_key_size:0, bond_type:BOND_TYPE_UNKNOWN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BLE was completely dead for a long time and nothing said so. The LAN transport carried every feature, the UI showed the laptop as connected, and the only trace was a log line that never arrived — which nobody notices, because you cannot grep for a line that is not there. It surfaced by accident, while verifying two unrelated commits. So the teardown path now says it. A GATT link that drops without completing IK logs a warning with how long it was held: seconds means our handshake stalled, sub-second means something outside Vortex killed the link before we got a word in, which is what a bond/encryption failure looks like from here. The message points at the BT stack log, because that is where the reason lives and it is not an obvious place to look. The success side already existed as "registered audio session", worded for the call site. It is now "BLE session established", worded for whoever is grepping logcat at the time — the two lines are a pair and only read as one if they sound like one. Tracking is per LINK, deliberately not `deviceToPeerPub`. That map answers a different question and survives a disconnect on purpose (the laptop's address is its public one, so it stays meaningful), which makes it useless here: after a single good session it reads non-null forever and every later failure would log as a success. Caught by testing the warning rather than trusting it — the first version used that map and stayed silent on a link that had plainly failed. Verified on hardware, both branches: a real reconnect logs "BLE session established with peer=2120853e…", and a `bluetoothctl connect` held for eight seconds and dropped — a GATT link that never runs IK — logs "dropped after 8156ms WITHOUT establishing a session". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Tauri app has built for Windows since the platform seam landed, but there was no way to produce a binary without a Windows machine and no documentation saying the platform exists. `install_windows.sh` does it from the same checkout the Linux build uses, and README gains the missing install step. MSVC target, not GNU, though GNU would need no downloads: the app reaches WinRT through the `windows` crate and Tauri links WebView2, both built against the MSVC ABI. That means Microsoft's CRT and SDK, which is the one thing a Linux box does not have and exactly what cargo-xwin fetches — a few hundred MB once, into ~/.cache/cargo-xwin. cargo-xwin accepts Microsoft's licence for that download, so the script and the README both say so plainly and point at building on Windows as the alternative. The script builds the APP, never `--workspace`. The daemon cross-compiles as a library and is used that way, but its binary (`vortex-l3d`) is a Linux CLI on bluer and cannot build for Windows; asking for the workspace only fails on a binary nobody wants here. The NSIS installer is behind --installer rather than default. Tauri will cross-build it happily, but it needs makensis, which is AUR-only on Arch/CachyOS — so requiring it would make the common case fail on the machine this was developed on. Without the flag you get the .exe, which needs no install anyway. README says what does NOT work, because that list is not obvious and finding out by trying is worse: screen mirror/cast, continuity camera and the earbuds hand-off are GStreamer/GTK/PulseAudio/BlueZ and are compiled out. It also covers the two first-run surprises — SmartScreen on an unsigned binary, and a blank window on Windows 10 that means a missing WebView2 runtime. Verified: the script runs end to end here (`--skip-deps`, toolchain already present) and produces "PE32+ executable for MS Windows 6.00 (GUI), x86-64", 18 MB. NOT verified: the dependency-install path, which needs a sudo password this session did not have, and that the binary RUNS — there is no Windows machine or wine here, so everything past "it links" is unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The peer tile pulsed green whenever the phone was reachable, which meant
it pulsed green over a link carrying nothing but LAN traffic. Every
BLE-only feature — the notification mirror, conversation pages — was
silently dead, and the only way to find out was to read the log. Today
that cost hours: two bug reports ("no notifications", "conversation
never loads") that were one dead Bluetooth link wearing a green dot.
So the dot goes yellow when the phone is online without BLE, with a
tooltip saying what it means and where to fix it. Yellow rather than
amber because amber already means "connecting" — this is a healthy link
missing one transport, not a link still being made.
The state it needs did not exist on this side. `ts` freshness only
proves SOME transport is working and LAN alone keeps it fresh, so the
BLE loop now keeps a plain flag beside its writers map — the shape the
portable loop already used — and `ble_link_up()` answers for whichever
loop this build runs. It rides the peer-state DTO, which the UI is
already listening to.
And the way out: a Reset Bluetooth adapter button in Settings.
BlueZ latches `Discovering = true` with no client behind it. In
`stop_discovery_complete()` a failed MGMT_OP_STOP_DISCOVERY removes the
client from `discovery_list` but returns before clearing `discovering` /
`discovery_enable`, so every later StopDiscovery is refused with "no
discovery started" and every StartDiscovery takes the queue-up-a-stop
branch — sent with a NULL callback, so its failure is never seen. The
adapter reports discovering for ever, no scan reaches the kernel, and
every connect is starved. Seen here repeatedly: GATT connects timing out
at 8s against a phone that `bluetoothctl connect` reached instantly.
Nothing gentler clears it, and that was checked rather than assumed:
StopDiscovery cannot reach the clearing code, `discovering_callback()`
only resyncs on a kernel transition that will not happen by itself, and
the mgmt-level `stop-find` returns "Permission Denied" without
CAP_NET_ADMIN. The one unconditional reset is in BlueZ's `adapter_stop()`
— the power-down path — and `Powered` is a plain D-Bus property we can
set unprivileged. Upstream bluez/bluez#807 describes the bug and is
closed as not planned; it is unfixed in 5.87.
It stays a button rather than something the BLE loop does on its own
because powering the adapter down drops every link it holds, the user's
headphones included. Confirmed working against a live wedge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The window opened at the 920x860 from `tauri.conf.json` regardless of
what it had to show, and on a scaled display that is too narrow: buttons
wrapped, the Devices page scrolled, and "Use phone as webcam" hung over
the edge of its own card. A constant cannot be right here — the width the
UI needs depends on the display's scale factor, the UI font and the
locale, none of them known when the config is written.
So the layout is measured once at startup and the window grown to fit.
Measuring is the whole difficulty, and the obvious ways do not work:
* `documentElement.scrollWidth` — the flex and grid containers absorb
the pressure instead of passing it up; the document reports "fits"
while a button visibly overhangs.
* comparing each element to its parent — an element with
`overflow: visible` lets a too-wide child spill out in plain sight
without any of its own metrics changing.
* `width: max-content` on the root — works, but measures PROSE
unwrapped: one setting hint became a single line and asked for a
2845px window.
What does work is asking the layout directly. `html.vx-measuring`
forbids wrapping ON CONTROLS ONLY — a button that wraps looks broken, a
paragraph that wraps is doing its job — and frees the three things that
otherwise swallow the overflow: `min-w-0`, `flex-1`'s `0%` basis, and
`grid-cols-N`, whose `minmax(0, 1fr)` is the same trick at track level
and cannot be reached by element CSS at all. The controls then overflow,
the overflow reaches the document, and `scrollWidth` reports it.
Width first, then height, with a relayout in between. They are not
independent: at a narrow width the content wraps taller, so a height
measured before the widening describes a layout about to stop existing.
Measured together it asked for 961px for a page that needed less.
Height is asked of the elements that actually scroll, in normal
rendering. The page never scrolls — `<main>` is `overflow-y-auto` and
keeps its overflow to itself — so the document always looked like it fit
and the scrollbar survived every "fix" until this one.
Sent as a RATIO, not a pixel count. CSS pixels and Tauri's logical
pixels are not the same unit: this display reports a 538px viewport in a
920px window while `scale_factor` reads 1.0, so a measured 872 handed to
`LogicalSize` looks SMALLER than the window and the fit silently does
nothing. A ratio needs no agreement about units.
**It sizes for the LANDING page and then stops.** Every fit is grow-only,
so a sizing pass left running lets each page in turn redefine the window
and never give the space back: opening Settings stretched it to 3533px,
because that page holds the widest content in the app. Sizing ends when
the landing page settles — nothing left to grow, and the page has
actually populated — or at the first navigation away from it, whichever
comes first. After that the window is the window, and the rest of the app
wraps or scrolls inside it, which is what those layouts are for.
That stop is driven by `router.afterEach`, guarded on
`from.matched.length`. Neither part is optional: `hashchange` never fires
because vue-router's hash mode navigates with `history.pushState`, and
`afterEach` without the guard also catches the INITIAL route resolution,
which killed the sizing before it had measured anything.
Grow-only throughout, so a window widened by hand is never clawed back,
and the measurement is refused outright when the webview reports a zero
viewport — it does that mid-resize, and a window was once grown to
2220px off exactly that.
Re-measured on DOM mutation rather than a timer: the widest things only
exist once peer state arrives over IPC, so any fixed delay is a guess
about how long that takes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a batch of small fixes I've ran into while exercising the software.
createBond()that interacted or refused to bind with existing BT bond (while the application is using GATT only for messaging, the BT bond was useless). So I've removed it (and it takes less battery not to have an active bond for nothing).