Skip to content

Fix BT reconnect and clipboard woes - #18

Open
X-Ryl669 wants to merge 7 commits into
zoir-dev:mainfrom
X-Ryl669:fix/bt-reconnect-and-clipboard-woes
Open

X-Ryl669 wants to merge 7 commits into
zoir-dev:mainfrom
X-Ryl669:fix/bt-reconnect-and-clipboard-woes

Conversation

@X-Ryl669

@X-Ryl669 X-Ryl669 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

This is a batch of small fixes I've ran into while exercising the software.

  1. When the BLE link doesn't come up, the LAN failover doesn't work for clipboard sharing. There are multiple features that are BLE gated, so the later commits fixes the BLE connections issues, but this one is probably the most painful (and useful) so I think it deserves a specific treatment.
  2. When BLE link is down, the LAN can, theoretically reconnect to the phone by itself since it has keys already. This fixes it
  3. The code contained a 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).
  4. This one is cosmetic: there was a log when the BLE link come up, but none when it failed. So grepping the logs couldn't find anything making diagnosing harder.
  5. Add crossbuild instructions to the README and a script to build the windows binary and installer.
  6. Show a yellow (not amber) dot with green pulse when the BLE link is down so the user can know when to restart the bluetooth adapter (this is due to a BlueZ stack bug on linux, still not solved, where a BlueZ client crash or killed while it was in discovery leaves the adapted in discovery mode forever and prevent any further connection). I've reported the issue here, but until then, there was no way for the user to know why the BLE link doesn't come up again.
  7. This is also cosmetic, when the UI bootstraps, its window size was fixed beforehand but failed on some of my laptops where the scaling factor wasn't 1.0x. So instead of increasing for this one and have a giant windows for the other, this commit computes the layout on start and apply the appropriate window size for any display resolution and scaling factor.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants