Skip to content

Add useful file browsing (and windows port too) - #17

Open
X-Ryl669 wants to merge 71 commits into
zoir-dev:mainfrom
X-Ryl669:feat/file-browsing
Open

X-Ryl669 wants to merge 71 commits into
zoir-dev:mainfrom
X-Ryl669:feat/file-browsing

Conversation

@X-Ryl669

Copy link
Copy Markdown
Contributor

This commit queue applied on the previous PR.

It adds:

Windows port for Vortex laptop application

It's built on linux, using the x86_64-pc-windows-gnu cargo target.
It supports all features that do exist on Windows (everything except distance locking/unlock & virtual screens).
It adds a ProjFS layer (equivalent to FUSE on Windows, made by Microsoft and available since Windows 10) so the Android phone filesystem is mounted as a standard folder that can be used in standard Windows applications. It's not just a local file browser with limited actions. Even the system tray is working like Linux.

File browsing

I've seen your progress and merged them with mine.
The implementation adds 2 bidirectional primitives (READ any part of a file, LIST/STAT files).
It changes the file transfer code to use those instead of reading the whole file to heap and streaming from here (which fails for large file transfer since the application is limited to 150MB).
Then it implements a proxy layer that's cross platform specific. On linux a FUSE layer is implemented (so the Android phone appears like a local folder that can be used in any application). On windows, a ProjFS layer is used instead.

On Android, there a laptop files' browser that implemented as an activity (since there is no guaranteed FUSE support on Android). Clicking on a file transfer it directly to the phone.

It also fixes a protocol issue on linux. On linux, BlueZ keeps the ACL open even after the app is killed/ended. So the phone still see the connection even if the laptop application is ended.

The catch

Since I had started and implemented file browsing independently from yours (mine based on remote mounting the Android phone filesystem, yours on adding a vue to browse the files), you'll have to decide what's worth keeping. I obviously prefer my version because it's using a true file browser for this task (like Dolphin, or Nautilus or File Explorer) so not reinventing the wheel, and it works with any software. The vue solution is a hard problem to get right unless it's extremely limited, which will limit its interest anyway).

claude and others added 30 commits September 12, 2026 18:31
Forgetting a peer left the Bluetooth bond in place on both sides, so the
next pairing attempt fought a bond only one side still held: the link is
torn down during encryption, `ServicesResolved` never arrives, and pairing
dies with `timeout: service discovery`. That is the "retry several times
until it works" symptom. Reproduced live 2026-08-25 — the phone held a
`[DUAL]` bond for a laptop whose own bond had been dropped, and pairing
failed three times in a row until the bond was cleared by hand.

Vortex never creates these bonds itself (Linux deliberately skips
`Device::pair()` — see the 2026-06-02 note in pairing.rs), which is exactly
why nothing ever cleaned them up: they arrive via the desktop's Bluetooth
panel or an older build, and Android hides profile-less LE bonds from
Settings, so the user cannot clear one by hand either.

Android — the side that actually holds a bond:
  * PeerStore gains load/savePeerBtAddr (defaulted, so the no-op stores
    are unaffected). BondCleaner needs an address and the phone stored
    none, which is why BondCleaner was reachable only from the DEBUG
    `remove_bond` intent and never from Forget.
  * Recorded at pairing from the central's address, and backfilled on
    every successful IK so pairings predating this commit get cleaned
    without re-pairing.
  * onForgetPeerClicked / onForgetAllClicked now drop the bond *before*
    the store entry goes away, while the address is still on file.

Linux — no bond to drop, but the peer's BlueZ device object lingers, and
its stale RPA is what feeds the RPA-churn connect wedge on the next
pairing. Recorded per peer in PEER_BLE_ADDRS and evicted on forget.

The address is recorded only *after* IK succeeds, on both sides. Before IK
we would only be trusting a presence-token match, and acting on that could
evict a stranger's device object or clear an unrelated bond.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phone-specific caches lived directly in ~/.cache/vortex/ — sms.json,
contacts.json, call_log.json, the history files, last_peer_ip,
last_adb_port. With one trusted phone that was fine. With two it is silent
data corruption: each phone's sync overwrites the other's file, so the SMS
and Contacts pages show whichever phone synced last. That has to be fixed
before a laptop can remember several phones.

Caches move to:

    ~/.cache/vortex/peers/<hex(peer_static_pub)[0..16]>/…

Keyed on the public key, not the peer's name: the display name arrives from
the peer's APPROVE payload, so it is attacker-influenced (hence the
existing sanitize_peer_name), it can contain path separators, it collides
("Laptop"), and it changes when the device is renamed. A public key is
stable, unique, and safe as a path component.

Genuinely shared state deliberately stays global: notes/todos are one list
across all devices by design, and so is clipboard history.

Existing installs migrate on first use. The rename is safe precisely
because the old layout could only ever hold ONE phone's data, so whatever
sits in the legacy path belongs to the peer we now key under. It runs once
per file, and a failed rename only means the cache refills on the next
sync. Verified against real data: contacts.json, sms.json, a 1.1 MB
sms_history.json, sms_history.since and last_peer_ip all moved intact.

Both directories are chmod 0700 explicitly. create_dir_all applies the
umask, which on most desktops yields 0755 — and these hold SMS bodies and
the full contact list. The 0700 cache root meant nothing was actually
exposed, but relying on an ancestor's mode to protect this is fragile.

Forget now removes the peer's directory as well, so a forgotten phone
leaves no messages or contacts behind.

ACTIVE_PEER is the interim owner of "which peer is active": set from the
single trusted peer at startup and refreshed whenever a BLE session
completes IK. Deliberately only when exactly one peer exists — with
several, "which phone's data" has no answer until a session picks one, and
guessing would render the wrong phone's messages. The arbiter in the
multi-peer design (docs/design/multi-peer.md §D4) takes this over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the multi-peer work (docs/design/multi-peer.md): the phone can
now be offered to another laptop without forgetting the first, and the laptop
tracks session *ownership* separately from transport links.

Phone — "Pair another laptop":

Pairable mode was reachable only with an EMPTY peer list (onResume's
auto-start and selectLaunchMode), so a phone that had ever paired could not
be offered to a second laptop at all. That was the single-peer trap.

The window has to *preempt* presence rather than run beside it: Advertiser
holds one advertising set and startWith refuses while another is active, and
once trust exists VortexService owns the radio, GATT server and LAN listener
(an Activity-local LanServer would race it for port 51820). So it stops the
service, advertises pairable from the Activity, and hands the radio back on
close. Bounded at 120 s — while the window is open the paired laptop cannot
see this phone, and would read it as "away" for proximity auto-lock.

Verified on device: service releases NSD -> activity takes port 51820 ->
"advertise started: pairable" WITH trust on file, mDNS instance matching the
BLE payload_8 per spec §5.4; Cancel restores trusted-presence and the
service. The permission-grant callback routes to the window via
pendingPairingWindow instead of falling through to startAdvertising(), which
would have started presence — silently the opposite of what was asked.

Protocol — PeerHandoff (payload oneof, field 30):

RELEASE / BUSY / CLAIM, for agreeing which peer owns the single active
session. Additive and gated on the existing capability_flags, so an older
build ignores the unknown field. Nothing in AdvPayload, the advertising
flags, the GATT UUIDs or the presence-token derivation changes — deliberately
no wire break. BUSY exists because a peer that cannot tell refusal from
packet loss retries in a tight loop against the phone's single GATT link.

Laptop — the arbiter:

Separates *connected* (a transport link exists; several may overlap, and
briefly do during a handoff) from *active* (owns the mirrored state:
notifications, clipboard, SMS/contacts/call-log, media; exactly one, ever).
Without the split, connecting to a replacement before the old link finished
dropping would give two phones ownership at once, both mirroring
notifications into the same laptop.

  * claim() is idempotent for the current owner, so a reconnect is never
    mistaken for a competing peer;
  * force_activate() displaces and returns the displaced peer, so the caller
    can send it RELEASE;
  * note_disconnected() deliberately does NOT release ownership — BLE drops
    during RPA churn are routine and must not blank the UI's data source;
  * the switch window is bounded, because seeking on top of a live connection
    is the most expensive radio state there is.

peer_cache now delegates to it rather than keeping a second notion of
"active", so cache paths and session logic cannot disagree about which
phone's data is on screen. Two unit tests pin the ownership rules.

The switch half of the arbiter API is written but not yet called; the Switch
button and candidate picker land next. Scoped allow(dead_code) with a note to
remove it then — the ownership rules belong in one reviewed place with the
tests that pin them, not added piecemeal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the laptop half of switching between remembered phones: a
tablet-smartphone button on the phone card that keeps the current device
connected while it looks for another already-trusted one.

Seek before release (design doc §D3). The active link is held for the whole
scan, so the persistent reconnect loop has nothing to race back into and the
laptop can never end up connected to nothing. A cancelled or fruitless switch
leaves you exactly where you were, which is why no standby/suppression window
is needed at all.

The scan needs to know WHICH peer it found, not just "some trusted peer is
nearby". expected_presence_tokens flattens every peer's tokens into one set —
right for reconnect, useless for a switch, which must leave the active peer
out. So scan_other_trusted_peers builds a token->peer map over the same ±2
bucket window, and collects for the full window instead of stopping at the
first hit: whether there is ONE candidate (auto-adopt) or several (ask) must
not depend on which phone advertised first. Strongest RSSI per peer wins,
since it wobbles between advertising events.

Ownership moves only in do_activate, and atomically (§D4) — the displaced
peer stops being active immediately even though its transport may take a
while to drop. Otherwise two phones would briefly both own the session and
both mirror notifications and clipboard into this laptop. The peer hex from
the webview is untrusted input, so it is checked against the peer store
before anything moves.

switch_peer() spawns rather than awaits: the worker's command loop is
strictly sequential, so awaiting a 45 s scan would stall every other command
behind it, including the 5 s earbuds heartbeat. UiCmd::Scan already spawns
for exactly this reason. A second press while a window is open is ignored
instead of starting a competing discovery on the same adapter.

TrustedPeerDto gains `active`, because with several trusted phones the UI has
to distinguish "remembered" from "the one whose SMS you are reading".

UI: lucide tablet-smartphone for consistency with the rest of the icon set,
shown only with more than one paired phone (switching is meaningless
otherwise). Scanning spinner, candidate picker with RSSI, "no other paired
phone nearby", and Cancel throughout. Strings in all three locales.

Not yet wired: sending PeerHandoff.RELEASE to the displaced peer (marked
TODO), and the whole phone-side half — Switch button, Seeking state machine,
token multiplexing. End-to-end verification needs a second paired phone;
what is verified here is cargo build, vue-tsc, and the app running and
syncing against one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phone half of switching laptops: an advertising phase machine, token
multiplexing across remembered laptops, and a "switch laptop" seek window
(docs/design/multi-peer.md §D1/§D5/§D9).

Active = silent. The presence loop now advertises NOTHING while a session is
live; it used to beacon 24/7 regardless. This is the largest avoidable
battery cost in the design, because the phone is connected most of the time.
It is safe for the laptop's proximity auto-lock precisely because that treats
"authenticated session OR token-validated advertisement" as presence, and a
disconnect kicks us back on air immediately. The loop also re-checks every
15 s rather than once per rotation window, so a *dropped* disconnect callback
can leave us silent — and therefore invisible — for seconds, not a minute.

Token multiplexing. The advertisement carries exactly one 8-byte token and
ADV_IND is already at the legacy 31-byte ceiling, so N laptops cannot be
addressed at once. With several peers the loop cycles them at 1.5 s dwell, so
any of them sees us within N × dwell — nothing on a deliberate walk-up. With
ONE peer it deliberately does not cycle: restarting the advertiser churns the
RPA and costs battery, so the common case keeps the old
one-advertise-per-bucket behaviour exactly. The dwell is also the floor on
advertiser restarts, and each restart re-randomises the RPA — shorter dwells
would inflate the laptop's BlueZ device cache and feed the stale-RPA connect
wedge.

Seek before release (§D3): startSeeking() drops nothing. The current link is
held for the whole window and only the arrival of a *different* laptop
completing IK ends it, so a cancelled or expired seek leaves the phone
exactly where it was. Bounded at 45 s to match the laptop's
SWITCH_WINDOW_SECS, and refused outright with fewer than two remembered
laptops rather than opening a window that can only time out.

`seeking` had to become its own term in the advertise-mode choice.
fastModeProvider means "link is DOWN and was lost recently", but a seek keeps
the current link UP by design — so it evaluated false exactly when the dense
schedule matters most, with the user walking to another machine. Seeking now
forces LOW_LATENCY, which is the first rung of the §D5 ladder.

activePeerPub records which laptop a link belongs to, learned from IK (a BLE
address alone does not identify a peer). The seek peer set excludes it: the
user pressed Switch precisely because they want a different laptop, and
spending dwell slots on the current one would only slow the others down.

UI: a switch action on the laptop card, offered only with 2+ remembered
laptops, tinted while seeking; pressing again cancels. The Activity mirrors
the window state from the service on the existing 3 s ticker rather than
keeping a second copy, so a window that expires on its own stops showing as
busy without needing a callback.

Also narrows arbiter.rs's module-wide allow(dead_code) to just is_connected
now that the laptop's Switch UI uses the rest of that API — a blanket allow
would hide genuinely dead code later.

NOT RUNTIME-VERIFIED: compiles, and the existing Android unit tests plus the
arbiter tests pass, but the phone was off the network when this landed. The
switch flow end-to-end still needs a second paired laptop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call log never synced at all. Both reads in CallLogProvider appended
`LIMIT n` to the `sortOrder` argument of ContentResolver.query, and the
call-log provider validates that argument:

    CallLogProvider: emitSnapshot: Invalid token LIMIT
    DatabaseUtils: at com.android.providers.contacts.CallLogProvider.query

Seen on Android 16 / OnePlus PJZ110. The row cap is redundant there anyway —
queryCallLog already stops at `cap` in Kotlin — so dropping it from the SQL is
behaviour-preserving.

Only ONE of the two failures was visible, which is why this hid for so long:

  * readHistorySince() surfaced as `call_log_history: "error"` in the laptop's
    bulk-sync status, on every single sync.
  * readCallLog() failed just as hard but silently: emitSnapshot() catches and
    logs under a different tag, so latestCallLogJson stayed null, and a null
    from the bulk provider means "peer cache is current" — reported to the
    laptop as `"match"`. A dataset that could never be produced was
    indistinguishable from one that needed no update.

Verified on device: the `Invalid token LIMIT` failures are gone and the laptop
went from `call_log_history: "error"` to `"match"` with 49690 bytes of history
actually transferred (`call_log_history sent (49690 bytes since=0)`), where
before it transferred nothing and claimed success.

Also stops the bulk-sync catch from guessing at a cause. It logged every
failure as "(permission denied?)", which is what sent this investigation after
a permissions problem for a query the provider simply would not accept. It now
names the exception class: a denied permission is a SecurityException, a
rejected query an IllegalArgumentException.

Note SmsProvider uses the same `LIMIT`-in-sortOrder pattern against
content://sms and works today, so it is left alone — but it is the same latent
hazard if that provider ever tightens validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ownership

Closes the TODO left in do_activate: when the laptop hands session ownership
to another phone, the displaced phone is now told, instead of discovering it
whenever it next happened to make contact.

Wire contract, mirrored byte-for-byte on both sides (design doc §D4):

    ty::PEER_HANDOFF = 0x4F
    sub::HANDOFF_RELEASE = 0x01   BUSY = 0x02   CLAIM = 0x03

Additive on purpose. Both sides log-and-ignore an unknown frame type
(`post-IK frame type=0x.. ignored` on the phone), so a peer without this build
is unaffected — no version gate needed. 0x4F was the next free type after FRAG
(0x4E); ERROR sits at 0x7F.

Note the protobuf PeerHandoff added earlier is the SPEC-level description. The
live BLE/LAN transport does not use VortexMessage — it uses the u8-typed Frame
format — so the implementation needed this frame type, not the oneof. The two
now describe the same thing at their respective layers.

The kind rides as the first payload byte rather than in Frame.sub: the laptop's
generic sealed-frame writer only takes a frame type, and reusing it beat
threading a sub parameter through every feature's send path. Both sides agree
on that placement; an empty payload is treated as malformed rather than
defaulted to a kind.

Sending needs the live session's cipher state, so it goes through the BLE
sealed writer, now published as BLE_SEALED_WRITER for command handlers outside
the BLE loop. That writer targets whichever peer is CURRENTLY connected, which
happens to be exactly right here: at the moment a switch is confirmed the live
link is still the peer being displaced, because we have not connected to the
replacement yet. That is load-bearing and easy to break later, so it is
documented at both the holder and the send site.

Delivery is best-effort by nature — it only lands while a link to the displaced
peer is still up. When it cannot be sent the peer falls back to learning on next
contact, which is exactly the old behaviour, so a failure logs at debug rather
than surfacing as an error.

Phone side, on RELEASE: clear activePeerPub, abandon any seek in flight (the
laptop already chose someone), and kick the presence loop so it goes back on air
immediately rather than up to ACTIVE_RECHECK_MS later. Without that last step a
released phone would sit silently attached to a laptop that has moved on,
invisible to every other laptop until the link happened to drop.

Still missing for §D4: the mirror direction. A phone-initiated switch does not
yet send RELEASE to the laptop it leaves, so that laptop can still show a stale
"Connected" until next contact — the laptop needs a dedicated raw-frame channel
to receive on, since the existing generic one has a single consumer. BUSY and
CLAIM are defined and logged on receipt but nothing sends them.

Compiles on both sides. Not runtime-verified: exercising a handoff needs two
paired devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes §D4. A phone-initiated switch now tells the laptop it left, so that
laptop drops ownership immediately instead of showing a stale "Connected" until
whenever it next made contact.

Phone: sendPeerHandoffEncrypted() on the existing per-peer sealed-send helper,
called from the reconnect listener's seek-satisfied branch. Ordering is
load-bearing and commented as such — the RELEASE goes out BEFORE stopSeeking()
tears anything down, while the old laptop's session is still registered and can
carry the frame.

Laptop: the generic additive-frame channel is single-consumer and notes/todos
already owned it, so a second additive feature had nowhere to listen. Rather
than add a 21st parameter to run_listener — which that channel's own doc comment
exists to avoid — peer_handoff.rs now owns the channel, takes the frames it
handles, and forwards everything else to notes untouched. PEER_HANDOFF also had
to join the listener's frame allowlist, or it would have been warned-and-dropped
before ever reaching the generic path.

The channel now carries (peer_pub, frame_ty, payload) instead of
(frame_ty, payload). A frame's meaning can depend on WHO sent it: "you are no
longer my active peer" is unactionable without knowing whose statement it is,
and inferring it from "whoever is active right now" would be a guess that is
wrong exactly when it matters. Notes ignores the identity — one shared list, so
the sender does not change the merge — and says so.

On RELEASE the laptop drops ownership via arbiter::release, purges the pages
showing that phone's data, and re-emits peers so the card's `active` flag
clears. Trust is deliberately untouched: the phone still trusts us and may come
back; forgetting is a separate, user-driven act.

sanitize_peer_name is now pub in the daemon so the successor name — peer-
supplied text reaching a UI — goes through the same sanitiser as the pairing
path, rather than growing a second copy that would drift from it.

Known gap, both directions: RELEASE rides the BLE sealed path only. A switch
that happens with only a LAN session up will not deliver it, and the peer falls
back to noticing on next contact — the pre-existing behaviour, so no
regression, but not yet the full promise.

Verified: cargo test 141 passed, Android unit tests pass, both sides build, and
the laptop app runs and reconnects normally on the reworked channel. NOT
verified: an actual handoff (needs two paired devices), and the notes fan-out —
notes.json is `[]` here and notes only logs when a merge changes something, so
its silence confirms nothing either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the last SQL `LIMIT`/`OFFSET` smuggled through a `sortOrder` argument,
so both companion providers now use the same safe shape. `content://sms`
tolerates the clause today — unlike the call-log provider, which rejects it
outright (`Invalid token LIMIT`, fixed in the previous commit) — so this is
prophylactic rather than a live bug.

It is worth doing anyway because the failure here would be worse than the call
log's. CallLogProvider always had a Kotlin `cap` guard, so its SQL clause was
redundant and the reads merely returned nothing. querySms had NO cap: callers
depend on the window for correctness, not just for size. If `content://sms`
ever tightened validation the way the call-log provider has:

  * loadThread pages the laptop's infinite scroll with LIMIT/OFFSET, so it
    would return whole threads per page and the paging would break;
  * readHistorySince would ship the entire message store in one batch instead
    of a self-paginating slice — and oversized history is exactly what desyncs
    the Noise cipher, per the note already on that function.

So the window moves into querySms: drop `skip` rows, then take `cap`. `cap` is
a required parameter rather than a defaulted one, so the compiler — not a
reviewer — guarantees no call site silently loses its bound. readSms keeps its
own existing guard; only its now-redundant SQL clause is gone.

Cost: `skip` becomes a cursor walk instead of a SQL OFFSET, so deep paging is
O(offset). Thread pages cap at 200, so that is a walk over a lazily-filled
cursor window rather than per-row I/O. Recorded in the KDoc so the tradeoff
isn't rediscovered later.

Verified on device: all six bulk-sync datasets still report "match", including
`sms` and `sms_ids`. That is a content-hash comparison, so an off-by-one in the
new windowing would have shown up as a mismatch and a resend rather than a
match. Not exercised: loadThread's paging, which needs the laptop's Messages
page and a scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two documents for work that was previously only legible from commit messages
and scattered code comments.

docs/communication/README.md — how a phone and laptop find each other,
authenticate and exchange data, as the code actually does it. Six diagrams:
role asymmetry, the advertisement payload, the phone's advertising state
machine, the channel stack and frame format, pairing, reconnect/steady state,
and the ownership handoff.

Written against the source rather than from memory, which corrected several
things worth having written down:

  * the trusted-runtime mDNS service is `_vortex._tcp.local.`; the
    `_vortex-pair._tcp.` name applies only during a pairing window;
  * "Audio Signal" is the general sealed app-data channel — notifications,
    clipboard, SMS, notes, file offers, handoff — not audio. The name is
    historical and misreads on first contact with the code;
  * `shared/proto/vortex.proto` is spec-level. The live BLE/LAN transport does
    NOT use `VortexMessage`; it uses the u8-typed Frame registry. Added as a
    callout because acting on the wrong one is an easy mistake to make;
  * unknown frame types are logged and ignored on both sides, which is the
    property that makes new frame types additive with no version gate.

docs/design/multi-peer.md — the design behind one phone ↔ many laptops and one
laptop ↔ many phones, with the alternatives that were rejected and why. Most
of it is now implemented; it stays because the reasoning is the part that does
not survive in the code:

  * why per-peer presence tokens beat one shared device key (revocation makes a
    forgotten peer's token uncomputable, not merely rejected);
  * why "seeking" must not become an advertising flag bit (is_well_formed would
    have every deployed peer reject it as malformed);
  * why a switch seeks BEFORE releasing, which is what removes the need for any
    reconnect-suppression window;
  * why Dark keeps advertising rather than going silent, pinned to the
    proximity confirmation-scan constants that make silence unsafe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing an 835 MB file killed the whole process. Captured on device:

    OutOfMemoryError: Failed to allocate a 876288016 byte allocation
      with 25165824 free bytes and 253MB until OOM,
      growth limit 268435456
      at ClipboardFileReader.read(ClipboardFileOut.kt:27)
      at ShareReceiverActivity.onCreate(ShareReceiverActivity.kt:81)
    ActivityManager: Killing 15355:io.github.zoir_dev.vortex (adj 50): crash

Three defects stacked:

  * `readBytes()` buffered the entire file BEFORE anything checked its size,
    so the 64 MB guard was unreachable for exactly the files it existed to
    stop. 876 MB cannot be allocated against a 256 MB growth limit.
  * `OutOfMemoryError` is an Error, not an Exception, so `catch (e: Exception)`
    did not catch it. It escaped onCreate and took the process down — with the
    BLE/LAN service and the notification listener. That is why the user saw a
    crash and no message: the app was dead before it could report anything.
  * Even the graceful path only wrote to logcat, so a rejected share looked
    identical to a broken one from the outside.

Now: the size is pre-flighted from OpenableColumns.SIZE before anything is
allocated; the read is bounded to cap+1 bytes so a provider that misreports or
omits the size still cannot blow the heap; OutOfMemoryError is caught narrowly
as a backstop for that case; and the toast names the real reason with real
numbers ("File is too big to send (835 MB; limit 64 MB)") instead of
"Couldn't read the shared file(s)", which is what made a deliberate limit look
like a malfunction.

This does NOT make large files transferable — the 64 MB cap and the
buffer-the-whole-file design are still there, and raising the constant would
only move the OOM. Ranged streaming is the actual fix; see the file-browsing
design doc, which needs the same primitive.

Verified on device: same 835 MB file now shows the size toast and the app
survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t of it

Sharing 150 files delivered about 20 and reported success for all of them.
Two silent losses compounded:

  * `clipboardFileBus` had 4 slots with DROP_OLDEST. The collector (read →
    stash → JSON → BLE notify) cannot drain that as fast as a share loop fills
    it, so the overflow was discarded. Worse, `tryEmit` returns TRUE on a drop,
    so the sender counted every file as sent — the toast promised ~130 files
    that never existed.
  * ClipboardBlobStore evicts past MAX_ENTRIES, and the laptop pulls one file
    at a time, so a queued file's bytes could be gone before its turn.

Capping the batch would make that honest but refuses work the user asked for,
which is worse UX than taking longer. So the whole list is accepted and paced:

  * ShareQueue holds URIs, NOT bytes, and reads each file on its turn. Reading
    150 files up front is what made an 835 MB share an OutOfMemoryError;
    memory is now flat whether the batch is 5 files or 500.
  * At most WINDOW files are in flight, measured against `pendingOffers` —
    offers the laptop has not collected — so pacing follows real delivery
    rather than a timer, and never exceeds what the blob store holds.
  * Progress is ONE updating notification with a real progress bar, replacing
    the per-file "File sent: X" toast. 150 toasts was its own bug.
  * Files too large or unreadable are counted and reported at the end instead
    of aborting the batch.

Deferring the read moved it out of the Activity (which finishes immediately)
into the service, and the share sheet's read grant does not reach that far on
its own — it is carried across via ClipData + FLAG_GRANT_READ_URI_PERMISSION.
Plain intent extras would have handed the service URIs it could not open.

The bus keeps a backstop: buffer sized to MAX_ENTRIES and overflow switched to
SUSPEND, so `tryEmit` now reports refusal instead of discarding, and the queue
retries rather than lying.

Verified on device: 65 files, all 65 delivered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A regression from `Active = silent` (bf43359). `linkedProvider` was wired to
`hasActiveConnection()`, which only means some central holds an ACL link — and
BlueZ owns the ACL, not the app, so it survives the laptop app being restarted,
killed or updated.

The result was a deadlock. The phone saw "connected", stayed silent, and became
unreachable; the laptop had no session and needed an advertisement to find the
phone; neither side broke the tie. Since file offers ride BLE only, they sat
retrying "BLE link down?" forever while LAN heartbeats kept working — so
everything else looked healthy. Observed live while testing a 65-file share:
the queue correctly held its files, but no offer could ever go out.

Now keyed on `hasAudioSignalSubscriber()`: a peer that has actually subscribed
to the notify channel, i.e. the path is genuinely deliverable. That is exactly
the condition under which advertising is pointless, and it comes apart from
mere ACL connectivity in precisely the case that broke.

Verified: the phone advertised WHILE ACL-connected (previously impossible), the
laptop found it, subscribed, and only then did the phone go silent.

Note this was reachable in normal use, not just from my restarts — any laptop
crash or app update leaves the same stale ACL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 65-file share produced dozens of Wi-Fi disconnect/reconnect notifications and
kept cutting transfers off mid-file. Four separate causes, found one at a time:

1. The OFFER was sent per file. `WifiDirect.start` is idempotent for the group
   but re-invokes its callback on every call (`if (isUp) onReady()`), and it
   runs once per file over 4 MB — so 65 files meant 65 offers, each making the
   laptop join the group and restore its Wi-Fi. Now coalesced with a minimum
   gap, cleared on teardown so a later batch is never starved.

2. The laptop restored Wi-Fi the instant its pull queue emptied. A paced sender
   empties it between windows, so every gap cost a full leave+rejoin. Now the
   group link is held across a 25 s idle grace — under the phone's 60 s GO
   teardown, so we let go before the group vanishes underneath us.

3. The watchdog force-restored 60 s after joining regardless of progress,
   cutting any batch that legitimately took longer (observed: file 26 of 65).
   It now polls for STALL instead of sleeping out a deadline.

4. That stall check then used `file_pull_active()`, which ANDs progress with
   "queue non-empty" — and a paced sender makes the queue oscillate, so a
   healthy transfer read as stalled and it fired anyway (file 21 of 65). It now
   measures time since a file last COMPLETED (`queue_progress_age`), and
   abstains entirely when nothing is queued, leaving that decision to the idle
   grace. Two mechanisms both deciding was the bug.

Which left the real question: why switch at all? The trigger was "some file is
over 4 MB", so a share went to Wi-Fi Direct even with both devices on the same
AP, where the router path already works. Wi-Fi Direct costs BOTH devices their
AP association — unavoidable with one radio each — so it is now skipped when
the peer is already reachable on our LAN.

That test is written to be portable, because the Windows port needs it:

  * the local address that reaches the peer comes from a connected UDP socket
    (`connect` sends nothing; it just makes the kernel do the route lookup so
    `local_addr` reports the source it would pick) — same on Windows, and a
    better question than enumerating interfaces and guessing;
  * netmasks come from `if-addrs`, which wraps getifaddrs / GetAdaptersAddresses
    and was already in the lockfile transitively.

`is_fast_lan_iface` isolates the one genuinely platform-specific part, since
if-addrs does not report interface TYPE. It is a denylist (bnep/ppp/wwan/rmnet/
tun/tap/p2p) on purpose: a misnamed fast link only costs a pointless P2P group,
whereas a missed Bluetooth PAN would silently route a large transfer over
Bluetooth. TODO left for the Windows port to use IfType properly.

Verified on device: a 65-file share now completes with ZERO network switches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Defence in depth, not the cure. A 65-file share arrived as 75 files with
duplicates, and the actual cause was the Wi-Fi Direct thrash (62e7a43): pulls
were cut off mid-transfer, leaving partially-written files that had already
been allocated a name in Downloads. Fixing the thrash is what stopped it.

The receiver was nevertheless not idempotent, and that is worth closing on its
own. The dedupe only covered offers still WAITING in the queue, so there is a
window between the laptop dequeuing an offer and the phone learning it was
served; a re-announce landing inside it passes the queued check, gets queued
again, and is pulled twice.

Completion now records the content token, and the dedupe also rejects anything
pulled within RECENT_PULL_TTL. Fixed on the receiver deliberately: the phone
cannot know about that window, so the side that owns the file has to be the one
that refuses the repeat.

The TTL is 60 s — long enough to outlast the announce/serve race, short enough
that deliberately re-sharing the same file is not mysteriously ignored. That
tension is real: tokens are content hashes, so "the same file" and "identical
bytes" are indistinguishable here.

Not verified as a fix in its own right — a 65-file share now arrives as exactly
65 files, but with the thrash gone the race it guards against no longer
reproduces on demand.

Still open: a failed pull leaves the partially-written file in Downloads. It
should write to a temp name and rename on completion, so an interrupted
transfer leaves nothing rather than a plausible-looking broken file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The KDE-Connect-style feature: open the phone's storage in Dolphin / Nautilus /
Explorer. Linux AND Windows from the start, since the Windows port branch means
every new feature needs both.

Records the decisions taken, and why:

  * one missing primitive underlies this AND large-file transfer — a ranged
    read. File managers issue them constantly; buffering whole files is what
    crashed the app on an 835 MB share. There is no offset-based read anywhere
    in the tree today, so both features start from the same standing start, and
    the 64 MB cap disappears as a side effect rather than as its own change;
  * the phone serves a dumb, narrow protocol; ALL caching, readahead and
    invalidation live in the daemon. Android is the worst place for that code —
    process death, Doze and low-memory kills make cache lifetime
    unpredictable, and every cache bug would need a phone rebuild to test;
  * the phone never serves the LAN. The daemon exposes the mount on loopback
    and proxies over the existing Noise session, so pairing IS the auth model —
    no second credential system, no TLS on the phone, nothing exposed on the
    network;
  * WebDAV over loopback first (one implementation, both OSes), with FUSE +
    ProjFS as the exit. ProjFS ships in Windows 10 1809+ with no third-party
    install, which is what beats WebDAV — whose Windows client defaults to a
    ~50 MB FileSizeLimitInBytes, i.e. trading a 64 MB cap for a 50 MB one;
  * SFTP+sshfs (what KDE Connect uses) is rejected on the Windows requirement
    alone: it needs WinFsp + SSHFS-Win, a third-party install per user;
  * writes and metadata-set are stubs that return an explicit "not supported"
    error, never silence — a stub that looks like a timeout hangs the file
    manager, which is the same class of bug as the silent share failures.

Sequencing puts the ranged-read primitive and reworking large-file transfer
onto it first, because both are worth doing whether or not the mount ever
ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vice

With two trusted laptops the card showed the wrong one, and its switch action
was invisible as a control. Observed after pairing a second laptop overnight:
arriving at work, the phone had reconnected to "Kapital" by itself and was
syncing over both LAN and BLE, while the card read "gaia / Disconnected" — a
laptop 40 km away.

Wrong peer. Two compounding causes, both "an arbitrary peer" dressed up as a
choice:

  * `HomeScreen` picked `peers.firstOrNull()`, which has no notion of which
    peer is active;
  * `EncryptedPrefsPeerStore.list()` reads `prefs.all`, a HashMap, so the
    sequence followed HASH ORDER. Every `list().firstOrNull()` in the phone
    codebase therefore meant "whichever peer hashed first".

The card now takes the peer with the freshest traffic, tie-broken by
`pairedAt`, so it follows whatever laptop is really on the other end and falls
back to most-recently-paired when nothing has been heard yet. `list()` is
sorted by `pairedAt` descending so the nondeterminism is gone at source, for
the other single-peer callers too.

Worth recording: restarting the app would have APPEARED to fix this, because a
fresh hash order might have landed on the right peer — it would have looked
like a transient glitch rather than a reproducible bug.

Invisible action. It sat in the card's bottom row between the cast and lock
glyphs: on screen and tappable (uiautomator confirmed clickable, bounds
[482,1152][650,1320]) but unreadable as something pressable. A two-facing-arrows
glyph sandwiched between two other icons reads as "swap those two", and the row
was crowded enough to wrap the battery percentage onto a second line. It now
renders beside the device icon via a new optional `afterIcon` slot on
`CardHeader`, using `PhonelinkOff` — "unlink", which is what leaving this
laptop for another one is. Icon and action are grouped in an inner Row so the
header's SpaceBetween keeps them left instead of spreading three items.

Verified on device: card went from "gaia / Disconnected" to
"Kapital / Connected / 100%"; action moved to [273,757][441,925]; battery back
to a single line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phone rendered a single laptop card, so with two trusted laptops the second
was invisible — no way to see it was still paired, and no way to choose it. This
is the per-peer view from design doc §D8.

An "Also paired" section lists every laptop except the one in the main card,
and tapping a row switches to THAT laptop.

Compact rows rather than a card each: PeerDeviceCard is 180 dp, so a card per
laptop would push the rest of the screen away for what is mostly "this one
exists and is not the one you are on". The row itself is the tap target — a
20 dp glyph is a poor one, and there is a single action per row — so there is no
per-row "Switch" label; the heading says it once.

Naming the destination is not just UI. `startSeeking` now takes an optional
target, and the presence provider advertises ONLY that peer's token instead of
cycling all remembered ones. So a targeted switch is found as fast as the
single-peer case and spends less time on air, which is the §D1 observation that
one advertisement suffices when the user picks where they are going. An
untargeted seek (the card header's unlink action) still multiplexes.

Each row reports when the laptop was last heard from, falling back to when it
was paired. `peerLastSeen` only covers peers seen during THIS app process — it
is not persisted — so a laptop paired yesterday reads as never-seen after a
restart. "not seen yet" would be true of the session and false to the user, who
remembers pairing it.

Verified on device with two real peers: main card "Kapital / Connected / 100%",
"Also paired" listing "gaia" with its last-seen line, and the targeted-seek
handler wired through. The row-label removal that followed is compile-verified
only — the phone locked before it could be re-captured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list stopped at "signal" while the code uses `sync` (mpsc/oneshot/Mutex/
Notify/OnceCell across ~14 modules), `net` (the LAN TCP transport), `io-util`
(the async read/write over it) and `process` (pactl / loginctl / xdg-user-dir).

It built anyway because bluer, secret-service and zbus each pull tokio in with
more features and Cargo unifies them — so the crate was relying on what its
D-Bus dependencies happened to enable. Nothing platform-specific about the
omission; it just wasn't visible while those dependencies were unconditional.
Moving them behind a target gate turns ~130 errors loose, starting with
`module 'sync' is private`.

Verified: `cargo check -p vortex-l3-daemon` and `cargo test --lib` unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… Windows

First half of the Windows port: a trait boundary between feature logic and the
OS, and enough gating that the platform-neutral core compiles for a second
target. Verify with

    cargo check -p vortex-l3-daemon --lib --target x86_64-pc-windows-gnu

`--lib` because `src/main.rs` is a Linux BLE CLI harness, and `-gnu` because an
MSVC cross-check wants `lib.exe` and dies in cc-rs before reaching our code.

`core::platform` holds the traits: UserPaths, Notifier, SessionControl,
Autostart, InputCapture, BleCentral/GattLink. Linux implementations delegate to
the existing modules, so nothing changes behaviour there. The first real
Windows implementations land here too — WinRT BLE central and the known-folder
paths (see below).

Two things turned out to be layering mistakes rather than porting problems, and
both were already biting on Linux:

  * `core::ble` mixed the WIRE PROTOCOL with the BlueZ transport. `frame.rs`
    has no BlueZ reference at all and `lan::tcp_client` already reassembles
    bulk-sync datasets by BLE frame type — the LAN path was reaching through a
    "BLE" module for protocol constants. `frame` is now unconditional and only
    `client`/`scanner`/`audio_signal` are Linux-gated;
  * `prologue_with_prs` — the IK prologue with the Pairwise Reconnect Secret
    mixed in — lived in the BLE reconnect module while `lan::tcp_client` called
    into it. It is normative wire material, so it moves to `crypto::noise`
    beside the prologue it extends, verbatim, and gains the two tests it never
    had (layout pinned to base‖PRS; a different PRS gives a different
    prologue). A mismatch here surfaces as an AEAD failure on msg1, not as
    anything readable, which is why it is worth pinning.

`fs_private` is now genuinely cross-platform rather than gated away, since it
is how the mirror caches are written. Windows has no mode to set: a file under
the user profile inherits an ACL that excludes other standard users but grants
Administrators and SYSTEM, and only while the path really is inside the
profile. That is weaker than 0600, so the module says so and carries a TODO
naming `SetNamedSecurityInfoW`. Identity and peer keys never go through it.

WinRT BLE central (`platform/windows/ble.rs`) implements scan, connect,
bonded, write, subscribe and disconnect. Three constraints shaped it:

  * `DataWriter`/`IBuffer` and the advertisement watcher are NOT agile — they
    hold a raw COM pointer that isn't Send — so the buffer is built in a scope
    that ends before the await, and the watcher (which must outlive the scan)
    runs on its own thread with only the address coming back. No
    `unsafe impl Send` anywhere: windows-rs marks the genuinely agile types,
    and asserting the rest would silence the next real violation;
  * `WriteValueWithResultAsync` takes no write option — the overload is
    `...AndOptionAsync`. The short form always writes WITH response, which
    would stall the unacknowledged frame path;
  * WinRT activation fails with CO_E_NOTINITIALIZED on a thread with no
    apartment, and the daemon's threads are plain tokio workers, so every entry
    point joins the MTA first. What that does not cover — a future suspending
    on one worker and resuming on another — is documented as the known risk,
    pointing at the `SECRET_RT` precedent in `core::storage`.

`WindowsPaths` uses `SHGetKnownFolderPath` for Downloads, AppData and
LocalAppData rather than `%USERPROFILE%`: all three can be redirected (OneDrive
moves Downloads by default), and writing received files where the user never
looks is the same bug the Linux side had with a hardcoded `~/Downloads` on a
French desktop. `KF_FLAG_DONT_VERIFY`, matching the XDG side — a
configured-but-missing folder is still the user's intent and the receive path
creates it.

Nothing in the WinRT code has been RUN. It type-checks against the Windows
metadata, which catches signatures and types and nothing about behaviour; BLE
cannot be exercised from Linux. The one part that is actually verified is
`PeerAddr::from_u48`/`to_u48`, kept in the seam with 3 tests because address
byte order has no error path — a mirrored address is a valid-looking address
that nothing answers on.

Also gated, with the reason on each cfg: the D-Bus / BlueZ / PulseAudio /
Secret Service modules. Two of those gates are load-bearing and want trait work
rather than a second copy — `pairing::{handshake, reconnect}` need
`&dyn GattLink` instead of a concrete `VortexClient`, and `ble::audio_signal`
has protocol-level frame dispatch trapped inside the BlueZ listener.

Verified: Windows lib check clean, no warnings; `cargo test -p vortex-l3-daemon
--lib` 152 passed; the Tauri app crate unchanged (`cargo check`, 38 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing the second implementation is what showed the traits were wrong, so this
is as much a reshaping as an addition.

BleCentral / GattLink gained what the real flows need and the first draft
missed: `read` (the §9.1.5 capability handshake reads before writing), `has`
(the audio-signal characteristic is absent on phone builds before P2.13 and
those peers must keep working), `peer` (so log lines can name the device), an
async `is_connected` (BlueZ answers over D-Bus, and a sync signature would have
forced Linux to cache a flag and answer with something stale), and
`scan_for_peer` returning an `AdvCandidate` rather than a bare address — the
phone rotates its address every few minutes, so the advertisement PAYLOAD is
what identifies a peer, and pairing needs the `pairable` flag while reconnect
needs the presence token.

That last one had left the WinRT scan quietly wrong: matching `ServiceUuids`
only says "a Vortex phone is nearby". It now walks the raw AD sections, and both
platforms run the same §5.2 filter through a new `ble::decode_service_data_128`.
Worth being a tested function rather than an inline comparison: the UUID in an
AD structure is little-endian, reversed from the printed form, and one test
feeds it big-endian to prove that does not match.

`LinuxBleCentral` wraps the existing `VortexClient`/scanner rather than
reimplementing them, so everything hard-won about connecting to a dual-mode
phone stays in one place. Its `disconnect` deliberately does NOT call
`Device::disconnect()`: on a dual-mode phone that tears down every bearer,
including A2DP/HFP if the phone is also paired as an audio device, so "close
this GATT link" would cut the user's music. BlueZ drops the LE link when the
handles go, which is what the pre-seam code relied on. There is also no
`platform::ble()` factory to match `paths()`/`notifier()`/`session()`: Linux
must take the process's ONE shared adapter (a session per use accumulated D-Bus
connections and hung the app after a few call cycles) while Windows needs no
handle at all, and a uniform factory would hide exactly that.

With both sides written, the callers moved over:

* `pairing::{handshake, reconnect}` take `&dyn GattLink` and are no longer
  gated. `ReconnectError::Client(ClientError)` became `Link(String)` because
  `ClientError` wraps `bluer::Error` and could never cross the seam. Call sites
  changed by one line each — `LinuxGattLink::from_client` BORROWS the client, so
  the connect logic stays put and `ble.rs` still uses `client.audio_signal`
  afterwards.
* `ble::audio_signal` — all nineteen frame types plus the nonce-resync
  recovery — likewise. Its one genuinely local dependency turned out to be two
  calls on the AUDIO_OP arm, now behind a narrow `AudioHandoff` trait; a
  platform with no audio backend passes `None`, drops AUDIO_OP, and keeps the
  other eighteen. One subtlety preserved explicitly: `write_audio_op` used
  bluer's bare `char.write()`, whose default is an unacknowledged Write Command,
  while everything else uses a Write Request for the long-write procedure and
  the nonce-lockstep ACK. That choice was implicit in a method name; it is now a
  `false` with the reasoning attached.

Windows also gets two real implementations:

* `SessionControl` — `LockWorkStation`, and `is_locked` via `WTSSessionInfoEx`
  rather than the `WTSRegisterSessionNotification` route the TODO named: event
  tracking cannot answer before the first transition, which is the wrong answer
  for a daemon that starts while the screen is already locked. `None` means
  "couldn't tell", never "unlocked" — proximity auto-lock must not act on a
  guess. The Windows 7 inverted-flags bug is deliberately NOT compensated for;
  Vortex targets 10+ and a blind correction would invert every supported
  version.
* `Notifier` — WinRT toasts. A click arrives as an `Activated` event on the
  ToastNotification object, so something must keep that object alive while the
  toast is up; toast objects are not agile either. Hence one thread owning the
  notifier and the live toasts with the async methods as a channel in front,
  the same shape as the advertisement watcher and as `SECRET_RT` on Linux.
  `replaces` maps to reusing a toast's tag (in-place update, which the transfer
  pill needs) and dismissal reasons map onto the freedesktop codes the existing
  consumers already interpret. NOTE for packaging: an unpackaged app has no
  AppUserModelID until a Start-menu shortcut carries one, and
  `CreateToastNotifierWithId` with an unregistered AUMID shows nothing at all —
  no error, no toast.

Testability is the point of all this, so:

* `FakeGattLink` is a GattLink with no radio, in the seam rather than a test
  module so the port can keep using it. A full XX pairing with dual approval now
  runs as a unit test with the test playing the phone (real snow responder),
  asserting the property that matters: BOTH sides derive the same SAS, which is
  what a man-in-the-middle breaks. Plus IK msg1's wire shape, a local reject
  still telling the peer before failing, and a foreign frame type being rejected
  by type rather than decrypted while the peer is still unauthenticated.
* The resync loop has a test that DROPS a frame on purpose and asserts the next
  one still arrives and the recovery was counted — built on a real IK transport
  pair, since a stub cipher cannot exercise a nonce sequence.
* The toast XML builder lives in `platform/toast_xml.rs`, compiled everywhere,
  because a toast body is frequently a MIRRORED PHONE NOTIFICATION. Unescaped, a
  `&` makes the document unparseable and the notification silently vanishes;
  worse, text that closes an element early can inject its own `<action>` into a
  prompt the user is about to trust. Two of its seven tests are injection
  attempts, one against a prompt that already has Accept/Decline buttons.

Kept as one commit: the pieces are coupled (the shared apartment helper, the
notifier accessor), so a split would leave a commit that does not build.

Verified: `cargo test -p vortex-l3-daemon --lib` 173 passed; Windows lib check
clean with no warnings; the Tauri app crate builds with 38 tests passing;
clippy unchanged on both crates. Nothing Windows-side has been RUN — it
type-checks against the WinRT/Win32 metadata and no more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three consumers subscribe to notification actions — the file-consent prompt
(`fc:`), the call banner (`call:`) and mirrored phone notifications (`act:`) —
and two subscribe to dismissals. On the freedesktop side that works because
signals are broadcast: each spawns its own watcher and filters by key prefix.

The Windows notifier stored the sink in a `OnceLock`, so only the FIRST
registrant ever received anything. Depending on module load order either the
consent prompt or the call banner would have had dead buttons, with nothing in
the log to say why — a click that does nothing looks like a UI bug, not a
missing subscription.

Fans out to every registered sender instead, pruning the ones whose receiver
has gone (a long-lived process would otherwise accumulate dead senders across
reconnects). Same contract as the bus on both platforms now.

Found by reading the call sites before the first Windows test run, not by
running it — the WinRT notifier still has never executed.

Verified: `cargo check -p vortex-l3-daemon --lib --target x86_64-pc-windows-gnu`
clean; Linux unaffected (this file is Windows-only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndows

The three remaining seam traits with no Windows implementation, plus two more
layering fixes that fell out of writing them.

**Credential Manager** (`storage::windows_credentials`) implements both
`IdentityStore` and `PeerStore`. Generic credentials, DPAPI-encrypted under the
user's profile — the same trust model as an unlocked Secret Service collection.
Target names are hierarchical (`Vortex/peer/<hex>`, plus `/counter` and
`/audio-*-nonce` children) so one wildcard enumerates peers, keyed on the
peer's static public key exactly as the Linux backend is.

`CRED_PERSIST_LOCAL_MACHINE`, deliberately not `ENTERPRISE`: enterprise
credentials roam with a domain profile, and a device identity key that follows
the user to another machine means the phone sees one static public key from two
devices — precisely what the pairing model assumes cannot happen.

The three audio-nonce methods are implemented rather than left to their trait
defaults. Those defaults answer `Ok(0)` for ever, which would hand out nonce 0
for every outbound audio-op frame and accept every inbound one. They are
unreachable on Windows today (no audio backend), but a default that is silently
wrong is a trap for whoever wires audio up later.

Two more codecs were trapped in the Secret Service backend, both hit on the way
in and both moved rather than copied:

* `decode_from_storage` — the identity record's decoder lived in the backend
  while `encode` lived on the type. Now `IdentityRecord::decode`, beside its
  counterpart: every backend reads the same 90 bytes, and a second copy of the
  offsets is a second place to get them wrong.
* `peers.rs` was Linux-gated only because `use secret_service::...` sat at the
  top, which held the platform-neutral `PeerStore` trait and `TrustedPeer`
  record hostage. The backend moved to `peers_secret_service.rs`.

`Platform` gains `Windows = 0x03`. Checked rather than assumed: that byte is
LOCAL — the phone's `Platform.fromByte` only parses the record it stored
itself, and what crosses the wire is the `class` string in AppState. So this is
not a protocol change and cannot make an existing phone reject us.

**InputCapture** (`platform::windows::input`) is the one subsystem that is
easier here: no pointer barrier, but `WH_MOUSE_LL` plus `ClipCursor` does the
same job on every desktop with no portal and no per-compositor behaviour. The
non-obvious part is that a clipped cursor cannot move, so absolute positions
stop changing and relative motion would die with them — each event is
differenced against the pin point and the cursor re-centred there, the same
trick a first-person game uses for mouse-look. Third appearance of the
dedicated-thread shape: low-level hooks fire on the installing thread and only
while it pumps messages.

`vk_to_evdev` is the verifiable half, with 7 tests, because
`InputEvent::Key` carries an evdev code — the phone already speaks them, and
inventing a third space would mean a translation table on the phone too. The
tests pin what a plausible-looking table gets wrong: evdev numbers keys by
physical row (`KEY_Q` = 16, so the alphabet is not contiguous), the digit row
starts at `KEY_1` = 2 with ZERO AT THE END, F11/F12 jump past the numpad block,
and the numpad must not collide with the digit row. Plus a sweep asserting no
two VKs map to one code and nothing maps to `KEY_RESERVED`.

**Autostart** uses `HKCU\...\Run`, over a Startup shortcut (needs `IShellLink`
COM for no gain) and Task Scheduler (buys elevation and delay Vortex does not
want). HKCU never HKLM: the identity key belongs to one profile, so starting it
for every account would have them fighting over a single pairing. Two failure
modes handled — `quoted_command` is tested because the default install path
contains a space and an unquoted value launches `C:\Program` silently at the
next logon, and `is_enabled` checks the value points at THIS binary so a stale
entry after a reinstall reads as off and flipping the toggle repairs it.

Verified: `cargo test -p vortex-l3-daemon --lib` 187 passed (12 new across the
credential naming, the keymap and the command quoting); Windows lib check clean;
the app crate builds. None of the Windows code has been RUN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…m seam

207 errors to 0. Lib and binary both compile for x86_64-pc-windows-gnu; Linux
is untouched (38 app tests, 187 daemon tests, clippy at its baseline).

**The manifest first**, since it was failing in a `glib-sys` build script
hunting for pkg-config before compiling a line of ours: bluer, x11rb,
gstreamer(-app), gtk, ashpd, reis and zbus behind a target gate. Also the tokio
features — `net` (the LAN fast-path probe) and `process` (9 files) were arriving
via bluer and zbus through feature unification, and gating without naming them
first would have buried the real errors under a repeat of the daemon's
~130-error cascade.

**Gated as whole modules**, because there is no partial version of "decode
H.264 into a GTK widget": mirror/cast/camera (GStreamer + GTK3 + the ScreenCast
portal) and the earbuds hand-off (PulseAudio + BlueZ). One correction to that —
`mirror_inject` is pure std plus `adb`, with no platform crate at all, and
Universal Control sends through it; gating it with the mirror was wrong.

**The 62-entry `generate_handler!` list stays single-sourced.**
`platform_unsupported` provides the gated commands and is aliased over the real
modules (`use platform_unsupported as earbuds`). Copying that list per platform
was the alternative, and 62 entries duplicated is a list that drifts. Two
judgement calls in it: `scan_bluetooth_devices` returns an empty list rather
than an error (the UI already knows how to draw "none found"), and the
smart-switch SETTING is accepted and remembered rather than refused, since it is
shared state the phone syncs by last-write-wins.

**`AudioServices`** replaces six Linux-only parameters on `try_lan_reconnect`
and `spawn_heartbeat` with one struct — populated on Linux, a zero-sized
stand-in elsewhere. The signature is then IDENTICAL on both platforms and every
place reaching for a handle must say `cfg`, which is the audit trail we want.

**Two utilities were trapped in gated modules**, the same pattern as
`ble::frame` and `prologue_with_prs` before them:

* presence bookkeeping (`touch_peer_contact`, `peer_contact_age_ms`) lived in
  `ble.rs` while its own comments said "over ANY transport" — the LAN heartbeat
  and the handoff path stamp it, the call pill and tray read it. Now
  `presence.rs`, which cleared six files at once;
* `TransportState` is re-exported from `crypto::noise` so the app can name what
  a handshake returns without its own `snow` dependency. Two copies of snow in
  one build are two incompatible types, and that surfaces as a baffling error at
  an API boundary rather than a version conflict.

**Notifications route through the seam.** Five files called
`core::notification_display` directly; they now go through
`platform::notifier()`, so the same call reaches libnotify or a WinRT toast and
the `fc:`/`call:`/`act:` keys come back the same way. `show_mirror` still forks:
a mirrored notification carries the phone app's cached logo and header name,
which are freedesktop hints with no toast equivalent (Windows takes both from
the AUMID's shortcut). Lock/unlock likewise moved onto `platform::session()`,
and the unlock arm checks `can_unlock()` FIRST so Windows says why instead of
surfacing an opaque API failure.

**BLE over the seam** (`ble_portable`): connect, IK, publish the five
laptop→phone writers, pump the event stream, back off and retry. Named
*portable* rather than *windows* because it talks only to `BleCentral` and
`GattLink`. It sits BESIDE the BlueZ loop rather than replacing it: that loop
carries adapter power-cycle self-heal, `remove_device` to force a re-resolve,
last-RPA learning and a dual-mode bearer dance — behaviour with no seam
counterpart and no meaning off Linux. Once the Windows side has run it will be
clearer which of those are BlueZ quirks and which are BLE ones.

**Pairing is shared, not duplicated.** `do_pair` split at the handshake:
`do_pair_over` — Noise XX, the SAS event, the 60 s dual-approval wait, the trust
save — is platform-neutral, because the code that decides whether two devices
trust each other forever should not exist twice. Windows adds only
`pair_by_scan`: scan for a *pairable* advert (20 s, longer than the reconnect
scan, since the user is watching), verify the capability version BEFORE the
handshake so a mismatch reports as one instead of an AEAD failure mid-XX, then
hand the link over. It pairs with the first pairable phone seen rather than from
a device list; the SAS comparison is what makes that safe.

Universal Control is the one gap left: `uc_start` returns
`no_capture_backend`. `WindowsInputCapture` exists, but `capture_loop` fuses
portal/libei acquisition with ~350 lines of platform-neutral gesture logic
across 34 transport-touching sites, so wiring it is the same split
`audio_signal` needed — narrow rind, big core — and worth doing deliberately.

Also here: a Windows `.ico` (16/24/32/48/64/128/256 — the sizes Explorer, the
taskbar, Alt-Tab and the installer each pick from), because `bundle.icon` had
three PNGs and an `.icns` and `tauri build` needs an `.ico`. And a crate-level
`allow(dead_code, ...)` for non-Linux: gating left ~110 Linux-only helpers
unreachable, and that noise would hide real warnings. It is commented as a
port-in-progress measure to REMOVE, or it will eventually hide genuinely dead
Windows code.

What a Windows build should do: pair, reconnect, notifications, clipboard, file
transfer, handoff, contacts/SMS/call-log, notes, tray, credential storage. What
it will not: mirror/cast/camera, earbuds/audio, UC capture, proximity
auto-lock. None of the Windows code has been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First real runs on Windows hardware. Every fault here was invisible from
Linux — the code type-checked and the features it needed were the ones no
compiler can see.

Build. `tauri`'s build script derives `dev = !custom-protocol`, so a bare
`cargo build` shipped a binary pointing at `devUrl` and the window showed
ERR_CONNECTION_REFUSED. Note that `--features tauri/custom-protocol` on the
command line is a SILENT no-op — that syntax only addresses workspace
members — so the feature is forwarded from a `[features]` table instead.

Logging. A GUI binary has no stderr, so a Windows run left no trace at all:
now a file in %LOCALAPPDATA%\Vortex, plus a panic hook, since a thread dying
on startup otherwise ends the log mid-sentence with no reason.

The UI could not reach pairing. `emit_peers` runs in `.setup()`, before the
webview registers its listeners, and Tauri does not replay events — so the
frontend's `peersLoaded` never flipped and its scan loop refused to start.
Linux never noticed because `UiCmd::RefreshState` re-emits peers on every
mount, which makes that command the repair for the lost first emit rather
than just a refresh. It was dropped off Linux, as was `UiCmd::Scan` itself,
so the radar had no rows and the Pair button no home.

Pairing then died on `timeout: peer approval`. Windows has no connect call
and no disconnect call: it opens the ACL for a GATT operation and closes it
once it decides nothing needs it, and holding the device, its service and a
subscribed characteristic does not count. Every handshake frame is a write
or a prompt reply, so the link survived all of it and then died in the first
idle gap — while the phone's user was comparing three emoji. A GattSession
with MaintainConnection is what tells the OS otherwise.

Pairing then succeeded and the UI never found out: the Windows arm emitted
`vortex:pairing_error`, which nothing subscribes to, and nothing at all on
success. The approve screen stayed up over a UI that was already synced.

Received files were auto-declined. An unpackaged app has no
AppUserModelID, and a toast sent under an unregistered one is not shown and
not an error — so the consent banner "succeeded", no one saw it, and the 45s
timeout declined. Registered by writing a Start-menu shortcut carrying the
AUMID; no installer or admin rights needed. Auto-accept, the way round this,
could not be enabled either: its path read $HOME, which Windows does not set.

The phone showed the laptop as "null" because `AppState::now_laptop` read
/proc directly, so every heartbeat reported no name and overwrote the one
the phone learned at pairing. Both now use one `platform::host_name`.

Also: `AdvCandidate` carries the advert's local name, so the radar can label
a row; the dbus-monitor capture and the earbuds heartbeat are Linux-gated
(they were retrying every 5s forever and burying the real fault); and the
unsupported-command warn names the command it dropped, which is what made
the dropped RefreshState findable.

Unverified: the AUMID shortcut is type-checked only, and hand-assembles a
PROPVARIANT because this `windows` version has no safe constructor. It logs
which way it went.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forgetting a peer was unreachable on Windows, which left a paired-forever
phone and no way back: the trust record lives in Credential Manager, so there
is no folder to delete either. The `forget_peer` command itself was always
registered and platform-neutral — it pushes `UiCmd::ForgetPeer` onto the worker
channel — but the dispatcher had no arm for it off Linux, so the click fell
into the catch-all and did nothing. Nothing about forgetting needs a radio: a
trust-store delete, a cache purge, and a best-effort LAN revoke, all of which
already work here. It was Linux-only purely because the module it lives in was,
so `cmd_pairing` is now compiled everywhere with only `scan` and `pair` gated
(those two hold a BlueZ adapter; the seam has `scan_for_ui` / `pair_by_scan`).

Then an audit of all 11 UiCmd variants against the non-Linux arms, and all 62
registered commands against their module's gating. The frontend has no platform
detection at all, so every button ships on Windows regardless of what is behind
it. Three actions were dead:

  * `start_screen_mirror` / `stop_screen_mirror` returned `Ok(())` and then had
    their command dropped by the catch-all — so the mirror button reported
    success and nothing happened. They now return the same "not available on
    this platform yet" as every other unsupported command. The comment claiming
    the UI paths that send these were gated too was simply wrong, and is what
    let them slip through.
  * The tray's "Switch earbuds" row sent `ToggleEarbuds` into the same void. A
    tray item is not a command — a click has no return value and nowhere to
    report an error — so an unsupported one can only do nothing. It is no longer
    built off Linux.

Everything else was already honest: earbuds, camera, proximity and laptop_cast
return the unsupported string through `platform_unsupported`, and the three
remaining earbuds variants cannot even be constructed off Linux now that their
senders and the 5 s heartbeat are gated.

Known and unchanged: Universal Control returns the raw token
`no_capture_backend`, which nothing in the UI maps to a readable message, and
the `--mirror` / `--mirror-stop` CLI test hooks still push commands that get
dropped here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Integration work from rebasing the Windows port onto the multi-peer branch.
Four places where the two met and neither was wrong on its own.

`forget_peer` and `forget_all` are no longer purely platform-neutral: they now
also drop the peer's BlueZ device object and any bond, which are BlueZ
concepts. Those two statements are gated; everything else in both handlers —
the trust delete, the per-peer cache removal, the arbiter release, the LAN
revoke — still works off Linux, which is what makes forgetting reachable there
at all.

`switch_peer` drives a BlueZ discovery to find the other trusted peers on air,
so it is Linux-only and the dispatcher off Linux has no arm for `SwitchPeer`.
`ActivatePeer` and `CancelSwitch` are a peer-store read and an arbiter flip, so
they stay available — a Windows laptop can still hand the session to a peer it
already knows, just not scan for one.

The additive-frame channel became per-peer (`([u8; 32], u8, Vec<u8>)`) so a
consumer knows whose statement a frame is rather than inferring it from
whoever is active. The seam's `BleSinks.raw` still declared the two-element
form, and it was wired straight to notes — it now enters at the peer-handoff
dispatcher, the same point the BlueZ loop enters, so handoff frames are taken
in front and the rest forwarded on.

`peer_cache`'s `chmod 0700` is Unix-only. The non-Unix arm is a no-op rather
than a fake: the cache lives under `%LOCALAPPDATA%`, inside the user profile,
whose ACL already grants only the user and administrators — pretending to set
a mode would read as a guarantee this function is not the one providing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The installed Linux app kept no record of anything. `install_linux.sh` starts
it from a `.desktop` autostart entry, whose stdout and stderr go to /dev/null —
confirmed on a live process via /proc/<pid>/fd. The doc comment claimed the
journal picked stderr up, which is only true under a systemd user unit, and
that is not how this installs.

The cost was paid in diagnosis. Every investigation began by asking someone to
kill the running app and relaunch it by hand with `RUST_LOG` set, which throws
away the run that misbehaved: a phone→laptop transfer failed, the app was
relaunched, it worked, and the state was simply gone.

So the rotating file log Windows already had now runs on every platform:
`~/.cache/vortex/vortex.log`, previous run kept as `vortex.log.1` — which is
the part that matters, since the interesting run is usually the one before the
one you thought to look at. Output is teed, so a terminal or journal run reads
exactly as before; stderr writes are best-effort so a /dev/null console cannot
cost a line in the file.

Also: the BlueZ persistent loop announces itself, and says once when it is
idling for want of a trusted peer. Both that idle and a loop failing to connect
used to look identical in the log — silence. The portable loop already said it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to f49636f, which moved `linkedProvider` off `hasActiveConnection()`
and onto `hasAudioSignalSubscriber()`. That removed the stale-ACL wedge but
opened a strictly worse one: `onConnectionStateChange(DISCONNECTED)` prunes
`connectedAddrs`, `deviceMtu`, the prepared-write buffers and the orchestrator
state — but never the three subscriber sets. A CCCD subscription only ever
disappears on an explicit 0x0000 write or in `stop()`, and a central whose link
just dropped never gets to write anything.

So every clean disconnect now leaves a phantom AUDIO_SIGNAL subscriber, and the
presence loop suspends advertising *permanently*: the phone believes a session
is live, goes off air, and cannot be found by the very laptop it is waiting for.
Nothing breaks the tie but an app restart. Before f49636f `connectedAddrs` was
pruned here, so the old code self-healed from a clean drop — the regression
traded a rare wedge for one that fires on every disconnect.

Observed live this morning: the laptop app restarted at 20:08, and the phone
advertised nothing for the following ten hours. The laptop scanned every five
minutes and found nothing, so laptop→phone clipboard sat pending and expired at
its 300s TTL, while LAN heartbeats kept syncing and both ends showed
"connected". Force-stopping the phone app (which calls `stop()`) restored it:
advertising resumed within 20ms, and the laptop connected, subscribed and
registered its BLE audio writer 3.5s later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sharing a link from the phone re-opened it in the laptop's browser every
~12s and never stopped. Caught live: 67 zombie `xdg-open` children under the
Vortex UI, one every 12.4s across the preceding 831s, with a fresh one at
age 0. Nothing the user could do on the phone stopped it — copying other
text doesn't touch the handoff bus at all, and only the accessibility read
(which needs that service enabled) ever writes the clearing empty url.

An explicit Share is a one-shot COMMAND, but it was being carried as STATE:

  * `forwardHandoff` stashes the event in `VortexService.currentHandoff` so a
    dead BLE link still gets it over LAN — and then never retracts it.
  * every AppState snapshot republishes it, on both the LAN heartbeat
    (lan.rs) and the BLE STATE frame (lan_state.rs).
  * the laptop's `open_now` branch called `open_url` unconditionally. Both
    dispatch sites claimed "consumer dedups by URL"; it never did.

Fixed at both ends, because either alone leaves a hole. `HandoffEvent` gains
an `id` identifying one Share request, and the consumer opens a given id
exactly once. Keyed on the id and not the URL, so deliberately re-sharing the
same page still opens it. An empty id (the live-read path, or a phone build
predating the field) falls back to deduping by URL: those cannot express
"again", and stopping the loop matters more. This also closes a latent
double-open — the BLE frame and the AppState carry both landing opened two
tabs. The phone additionally expires an `openNow` carry after 45s, long
enough for a BLE-down laptop to collect it off a heartbeat, and retracts only
its own event so a newer page isn't clobbered.

`open_url` also moves to `tokio::process`: a `std` Child dropped without
`wait()` stays a zombie for the parent's whole life, which is where the 67
came from. The notification-action opener already did it this way.

Verified end to end on the debug build. One share opened at 08:37:22; the
heartbeat re-delivered the same request 12.2s later and it was suppressed —
exactly the cadence that produced the zombies. A second share of the same URL
opened normally. Zero `xdg-open` children throughout.

Known limit: the consumer's memory is in-process, so a laptop restart inside
the 45s carry window opens the page once more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
X-Ryl669 and others added 23 commits September 12, 2026 18:40
The back button was there all along — an arrow in the header, wired to the same
action as the gesture — but it rendered UNDERNEATH the status bar, landing
behind the clock. Invisible enough to read as missing, and hard to hit: a
synthetic tap on it was swallowed outright, which is how this was first noticed
while driving the screen over adb.

The cause is not this screen. targetSdk 36 makes edge-to-edge mandatory, and
nothing in the app applies window insets, so every screen draws under the system
bars. The home screen's own header overlaps the clock the same way. Fixed here
because this is where it was reported; the rest of the app has the same problem
and the same one-line remedy, which is worth doing deliberately rather than as a
drive-by that shifts every screen's layout at once.

Padding goes after the background so the status bar still sits on our colour
instead of a bare gap.

Back behaviour is unchanged and was already correct: gesture and button run the
same lambda, walking up one folder and leaving the screen only from the top
level. The comments now say so, since having two entry points to one action is
exactly the kind of thing that drifts.

Not verified on the device — the phone dropped off adb before I could re-run it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same defect as the file browser, on the screen the user sees first. targetSdk 36
makes edge-to-edge mandatory and nothing here opted in, so the header drew UNDER
the status bar: the Notes / Laptop files / Settings icons sat in the same band as
the clock, where the system takes the touch.

The failure mode is nastier than a cosmetic overlap. The icons render perfectly
and simply do not respond, so it reads as a dead button rather than a
mispositioned one — the report was "it's hard to click the folder icon", not
"the header is too high". It also caught me out while driving the screen over
adb: my taps on that row did nothing and I put it down to bad coordinates.

Background before padding, so the status bar still sits on our colour rather
than a bare strip.

Verified on the device: the header now clears the clock, and a tap at the folder
icon's own centre opens the browser — the exact tap that was being swallowed.

Notes and Settings still have this; they carry only a back arrow in that band,
so it bites less, and the same one-line change fixes them when wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filesystem traffic from the phone ran entirely over BLE, at ~40 KiB/s. It now
runs over the LAN session for everything after the opening request: 18.4 MB
downloaded in 46 s (~409 KiB/s), byte-identical, an order of magnitude faster
and a rate BLE cannot physically reach.

The phone cannot open a LAN session — the laptop runs no listener, which is why
this direction was BLE-only. It does not need one. The session the LAPTOP opens
to deliver its reply is a plain bidirectional socket, and the laptop's
dispatcher serves an FS_REQ arriving on it whichever side sent it. So the first
request of a browse goes over BLE, the laptop's own reply brings the session up
as a side effect, and the phone sends everything after that down it — including
every ranged read of a download. The laptop needed no changes at all.

The sender binds to the connection that has actually carried an FS frame rather
than to whichever is newest: the laptop also opens short-lived heartbeat
sessions, and a request sent down one of those would die with it. Cleared on
teardown under the same CAS discipline as the audio session writer, so a
connection on its way out cannot strip a newer one of its writer.

Not as fast as the laptop→phone direction (931 KiB/s), because this loop is
strictly sequential — each 48 KiB read waits a full round trip. Pipelining is
the obvious next gain and needs no protocol change: requests already carry ids
and replies may arrive in any order.

Verified on the device end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both download loops issued one read and waited a full round trip before sending
the next, so throughput was bounded by latency rather than by the link. Each now
keeps READ_WINDOW (4) reads outstanding.

Measured on the device, same files as before:

  * laptop pulling from the phone: 151 MB, 73 s -> 17.7 s (2.07 -> 8.1 MB/s)
  * phone pulling from the laptop: 18.4 MB, 46 s -> 8 s (0.4 -> 2.3 MB/s)

Both byte-identical by md5.

Replies are consumed in ISSUE order, which is also offset order, so the file is
still written front to back and progress only moves forward. Out-of-order
arrival is already legal — ids exist for exactly that — this simply declines to
care. Each in-flight read carries the offset it asked for rather than trusting a
running counter: a short read would otherwise shift every later chunk silently.

Only when the size is known. Without one there is nothing to size a window
against and speculative reads past the end would be waste on a link this feature
exists to stop wasting, so that path stays sequential and follows EOF.

The window is deliberately small. The gain is hiding the round trip, not the
disk — the peer serves reads under a single lock either way — and on a BLE
fallback each 48 KiB reply is ~96 paced notify fragments, so a large window
would flood a link that cannot absorb it.

Concurrency was safe to add: the Rust server takes the handle-table lock across
seek+read, so overlapping reads on one handle serialise rather than interleave.
Checked before writing the client, not after.

Fixes a leak the concurrency would have exposed: the phone's `roundTrip` removed
its in-flight id on timeout and on send failure, but not on CANCELLATION — and
cancelling siblings is exactly what happens when one read of a batch fails. It
now drops the id in a `finally`.

205 daemon + 39 app tests pass; both targets check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The throughput figures in §7 and §8b were from the sequential loops. Both are
now the measured pipelined ones, and the readahead bullet says what is actually
done (4 reads in flight) versus what is not (reading ahead of the request,
which is the part a mount will need).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design doc §8 step 6, Linux half — and ahead of steps 3-5, because on this
OS it supersedes the WebDAV gateway rather than competing with it: GVFS and
KIO mount `davs://` inside the file manager's own process, so `cp`, `mpv` and
anything not built on KIO cannot see the files. A FUSE mount is a path, so
everything can.

`--fs-mount` / `--fs-umount` put it at `$XDG_RUNTIME_DIR/vortex/phone`,
read-only. Not automatic: mounting costs a kernel session, and a mount
pointing at a phone that is not here is worse than no mount.

The load-bearing decision is that nothing blocks the FUSE session thread.
Each operation is handed to the async runtime and its reply object (which
fuser makes `Send` for exactly this) is answered when the phone answers.
Serving inline would cost a full round trip per operation in series, and a
file manager opening a folder issues dozens at once. It is also what makes
the kernel's own readahead work for us: a sequential reader triggers several
`read` calls at once, and they overlap on the wire instead of queueing.

Three of §7's items fall out of it:

* metadata cache — attributes and entries carry a 5 s TTL, so a repeat stat
  never reaches this process, and a listing seeds the attribute cache for
  every entry, which is what makes the lookup+getattr storm after a readdir
  free. Both caches sweep expired entries past a threshold: inodes are never
  recycled, so unswept they would hold every listing for the life of a
  process that runs for days.
* readahead — the kernel's, per above; ours is still open.
* concurrency cap — a semaphore of 8 over every request, so a thumbnailer
  cannot queue megabytes of image data ahead of the next listing, or starve
  the BLE session carrying everything else.

`fs_link::send` now reports failure instead of dropping the frame, so a
request that reaches neither transport fails at once with EHOSTDOWN rather
than waiting out the 20 s timeout. Twenty seconds per operation on a phone
that is not there is indistinguishable from a hung file manager (§6).

Child addresses are opaque — a SAF document URI, not a path — so `lookup`
resolves a name through the parent's listing rather than joining it onto the
parent's address, and inode numbers are interned rather than derived and
never recycled: a file manager holds them across a refresh.

`fuser` with default features off: `libfuse` would need libfuse3 headers and
pkg-config at build time, which every packaging target would then carry.
Without it, fuser mounts through the kernel and falls back to `fusermount3`,
already present anywhere FUSE works.

The mount is detached on the tray's Quit, because a FUSE mount outlives its
server process and answers ENOTCONN afterwards — `df` errors and every file
manager shows a broken entry.

Tested: 11 unit tests against a fake peer (pagination, inode stability, a
listing answering the lookups after it, reassembly of a 100 KiB read across
three protocol reads, offsets, errno mapping), plus a real kernel mount over
that fake peer driven by ordinary `std::fs` calls — read_dir, a 100 KiB file
byte-identical through the page cache, a stat, and a write refused. That one
needs /dev/fuse so it is `#[ignore]`d; run it with
`cargo test --lib fs_mount -- --ignored`.

Not verified: the Windows build. `cargo check --target x86_64-pc-windows-msvc`
fails here in a dependency's build script for want of `lib.exe`, before
reaching our code. Every reference to the module is `cfg(target_os =
"linux")` and the crate dependency is in the Linux target section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…corpse

Both found by mounting a phone for real.

Every path walk asked us `access`, every `close` asked `flush`, and Dolphin
and `cp -a` ask `getxattr`/`listxattr`. Unimplemented, each answers ENOSYS,
which the kernel copes with but fuser logs as "[Not Implemented]" — so an
ordinary `ls` wrote warnings into the app's log and spent a round trip
through the session thread to answer a question with a local answer.
`flush` is nothing to do on a read-only mount, there are no xattrs and cannot
be (the protocol carries a name, a kind, a size and an mtime), and
`default_permissions` hands the access check to the kernel, which has the
mode bits we reported and stops sending `access` altogether. Warnings to
zero, and a 52 MB read from 8.6 s to 7.4 s.

The stale-mount recovery ran too late to work. A mount whose server process
was SIGKILLed — a crash, or install_linux.sh restarting the app — stays in
the mount table with nothing behind it, answering ENOTCONN to every syscall.
That includes the `stat` inside `create_dir_all`, which therefore fails with
EEXIST: the directory is there, it just cannot be looked at. So mounting
failed with "cannot create /run/user/1000/vortex/phone: File exists" before
reaching the `fusermount3 -quz` that exists precisely to clear this. Clearing
first, then creating.

Live results are in the design doc: a 52 MB video md5-identical at 7.1 MB/s
and readable by ffprobe, a 481 MB APK at 7.3 MB/s with the phone's heap
flat, a 3.4 GB zip listed and read correctly at offset 3.4e9, and `touch`
refused by the kernel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beside "Connected", so it sits with the state it depends on: the mount is
served over the live session, and the button is only there while the card
says the phone is reachable.

One click does both halves — `open_phone_files` mounts on demand and hands
the path to `xdg-open`. Asking the user to mount first would be a step that
exists only because the code is in two pieces, and `mount` is idempotent, so
a second click reveals the same window rather than remounting under it.

Mounting is the part that can fail: a phone can go between the card last
saying Connected and the click. So the failure is shown on the button —
tinted for a few seconds with the reason in its tooltip — rather than
swallowed like the ring button's, where a lost heartbeat genuinely does not
matter. `tokio::process` for the spawn, not `std`, for the reason
`handoff::open_url` gives: a `std` Child dropped without `wait()` is a
zombie for the life of a process that runs for days.

`vx-mini`, a 22px sibling of `vx-ring`: the 36px ring button would tower over
the 13px line of text it belongs to.

Windows gets the command as an `UNSUPPORTED` stub through
`platform_unsupported`, like camera and earbuds — the frontend is one bundle
on every platform, so the command has to exist there or the button fails with
"command not found" instead of something a user can read. It has nothing to
open until ProjFS lands.

Tooltip translated in all three locales, in `peers` beside `switch_tip`
rather than in a new section for one string.

Verified: `vue-tsc -b` and the Rust build clean, and `xdg-open` on the mount
opens Dolphin at the phone's storage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Right edge of the row rather than hugging the "Connected" text, and `vx-ring`
at its full 36px with an 18px icon, so it lines up under the ring and switch
buttons instead of reading as a third size on the same card.

`vx-mini` is gone — with the sizes equal there was nothing left in it — and
its failure tint moves onto `vx-ring--bad`, a sibling of `vx-ring--on`.
No pulse on that one: it is reporting, not working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design doc §8 step 6's other half, so the feature is on both OSes. Windows
skips WebDAV for the reason the doc already predicted: the WebClient
redirector caps a file at ~50 MB, and escaping a 64 MB cap into a 50 MB one
would be absurd. ProjFS ships in Windows 10 1809+ with no third-party
install, though it is an optional feature that is off by default on client
SKUs — the mount error says how to turn it on rather than failing obscurely.

Adding a second adapter first meant splitting the first one. `fs_vfs` is now
everything between a mount and the wire — caches, the path walk, pipelined
ranged reads, the concurrency cap — with `fs_fuse` and `fs_projfs` holding
only translation, and `fs_mount` the facade the rest of the app calls. So the
interesting bugs are written and tested once, and the path walk in particular
is tested on Linux precisely because it is the hardest part of the Windows
adapter and the part least able to be tested there.

The threading models are opposites and that drives the difference. FUSE hands
one request at a time on one thread, so the Linux adapter must never block.
ProjFS runs its own pool and is built for providers that block one of its
threads, so the Windows callbacks are plainly synchronous — safe only because
the pool is sized at twice MAX_INFLIGHT, leaving the shared semaphore as what
limits concurrency rather than the pool. If that stops holding, the escape
hatch is ERROR_IO_PENDING + PrjCompleteCommand, which costs an owned copy of
every callback parameter and is why it is not the starting point.

Read-only takes enforcing rather than declaring: ProjFS has no `ro` mount
option, so every placeholder carries FILE_ATTRIBUTE_READONLY (advisory, greys
Explorer's commands out) and the notification callback vetoes PRE_DELETE,
PRE_RENAME, PRE_SET_HARDLINK and FILE_PRE_CONVERT_TO_FULL, which is the half
that actually enforces it.

Hydration is a free content cache and a staleness problem: ProjFS writes
fetched bytes into the real directory and serves later reads from disk
without asking us. The projection is cleared at each mount so a session
starts from the phone's current truth; within a session a changed file still
shows the old content. Fixing that properly means a ContentID from size and
mtime plus PrjUpdateFileIfNeeded — the companion to step 3.

Three things worth a reviewer's eye, all found by reading rather than running:
* the stop thread must move the whole `Instance`, not its fields — Rust 2021
  captures disjointly, which would capture two raw pointers and bypass the
  `unsafe impl Send` that vouches for them;
* the notification root is an empty string, not null, and getting that wrong
  fails OPEN, as a writable projection;
* callbacks can fire before PrjGetVirtualizationInstanceInfo returns, so the
  write alignment starts at 64 KiB rather than 1 — a chunk rounded up to that
  is also a whole multiple of 512 and 4096, so a hydration landing in the
  window is legal whatever the volume wants.

Verified on Windows: nothing. There is no Windows machine in this loop.
`cargo check --all-targets --target x86_64-pc-windows-gnu` is clean, which
type-checks every callback signature, struct layout and constant against the
real Win32 metadata and says nothing about behaviour. The ProjFS protocol
itself — enumeration restart and buffer-full handling, the write-alignment
rule, whether the veto covers every path to a write — is written from the
documented contract and is what a first run should be expected to shake out.

Verified on Linux, because this refactored working code: 57 unit tests, the
real-kernel mount test, and the device again — the same 52 MB video and the
same 64 KiB at offset 3,396,354,250 of a 3.4 GB zip, both md5-identical to
the earlier runs, plus listings, a subdirectory and a refused write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A whole Windows binary cross-builds from Linux with mingw-w64, which is a
better check than `cargo check`: the result imports all eleven `Prj*` entry
points from projectedfslib.dll, so the FFI is wired and not merely
type-correct. Recipe recorded, including that `WebView2Loader.dll` has to
ship beside the exe.

Building it surfaced a shipping bug worth fixing before release, now an open
question: those imports are STATIC, so on a machine where the ProjFS optional
feature has never been enabled and the DLL is absent, Windows refuses to
start the whole app — a user who never wanted to browse their phone's files
would lose notifications, clipboard and calls with it. Reaching ProjFS
through LoadLibrary/GetProcAddress is the fix, at the cost of the
compiler-checked signatures the `windows` crate gives us today. Delay-loading
alone is not enough: its failure is a structured exception, so it needs a
failure hook to become an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…f $HOME

Two independent bugs behind the multi-peering weirdness on Windows, both
found in the log from the first real run.

1. The portable BLE loop never told the arbiter anything.

`ble_portable` READ the arbiter (`preferred_peer`, to pick who to dial) but
never wrote to it — no `note_connected`, no `claim` — where the BlueZ loop
does both immediately after IK proves the peer's identity. So on Windows no
peer was ever active until a restart happened to claim one via worker.rs's
single-trusted-peer path. That is exactly the reported shape: pair, and the
new laptop never takes ownership from the phone's previous one; restart and
pick it by hand, and it works.

It also silently disabled every per-peer cache, because `peer_cache::peer_dir`
keys on the ACTIVE peer and returns None when there is none.

2. `$HOME` is a Unix variable, and Windows does not set it.

`peer_cache::cache_root` built every per-peer path from it, so on Windows the
whole cache was None: SMS, contacts and call-log history could be neither read
nor written. The consequence is visible in the log as a re-sync storm — the
bulk-sync hash is computed over an empty id list, never matches, and the phone
re-sends its entire history every 12-second heartbeat. 42 syncs in 8.5
minutes, ~1 MB of SMS history each, where the Linux log answers "match" 66
times in 71.

Same bug, same file-scope, in `clipboard` (history never persisted), `notes`
(empty after every restart), `icon_cache` (no mirrored app icon ever cached)
and `voice_settings`. All now go through the platform seam, which resolves to
the same `~/.cache/vortex` on Linux and to `%LOCALAPPDATA%\Vortex\Cache` on
Windows. `voice_settings` keeps its exact Linux path because that file is a
bridge read by the voice scripts outside this process — the pattern
`file_consent` already established when it hit this.

Verified on Linux that nothing moved: XDG_CACHE_HOME unset here so the seam
returns the same directory, the existing peer cache is untouched, and the
bulk-sync gate still answers "match" after the first post-restart sync. 57 +
205 tests pass; both targets check clean.

NOT fixed: the crash right after pairing. The log ends abruptly with no panic
line despite a panic hook that would have logged one, and the writer is
unbuffered, so the process died without unwinding — an access violation, a
stack overflow or an abort. That needs the faulting module and exception code
from Event Viewer, or a WER dump; it cannot be read out of this log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ite-on-white tray icon

The crash is identified. Event Viewer gives exception code 0xc0000374 —
STATUS_HEAP_CORRUPTION — faulting in ntdll, 30 ms after the log's last line.
Heap corruption, not a panic, which is why the panic hook logged nothing.

`register_aumid_shortcut` built a PROPVARIANT by hand and pointed its
VT_LPWSTR at a Rust `Vec<u16>`. A PROPVARIANT is an OWNING value by COM
convention: whoever holds one may call PropVariantClear, which for VT_LPWSTR
is CoTaskMemFree(pwszVal). So the shell was handed a Rust heap block to free
with the COM allocator, and the Vec then freed the same block again on the way
out. The old comment argued this was safe because the struct has no Drop —
true, and beside the point: the hazard is the callee's clear, not ours.

Three things pin it to this function. It creates the shortcut exactly once per
machine and skips on every later run, which is the observed "crashed once,
fine afterwards" shape. Its success line is the last new thing in the log
before the fault. And the toast-permission prompt appeared on the SECOND run,
confirming run 1 reached shortcut creation and run 2 did not.

Fixed by allocating the string with CoTaskMemAlloc and clearing the variant
ourselves — one allocator throughout, one free, on both the success and
failure paths.

Also: the tray icon is invisible on Windows because it is pure white (mean RGB
254,254,254), shipped that way deliberately for Linux, where the GNOME/Ubuntu
top bar is dark even in light mode and SNI hosts do not recolor. Windows 11's
taskbar is light by default and does not recolor either, so the entry was
present and clickable with nothing drawn in it. Windows now gets the
full-colour 64px brand icon, which is the convention there anyway; Linux is
untouched.

Verified: 57 + 205 tests, both targets check clean, and the Linux app restarts
on the new tray path. The Windows fixes are reasoned from the crash dump and
the API contract — neither has run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The laptop has always kept "a link exists" apart from "this peer owns the
session" (design doc §D4). The phone did not: `activePeerPub` was set by
whichever laptop completed BLE-IK last, unconditionally.

That is worse than it sounds, because the presence provider deliberately
advertises every remembered peer EXCEPT the active one. So a second laptop in
range is actively INVITED to connect, and on arrival it took ownership. With
two laptops up, ownership ping-ponged: pick one in the phone's UI and the
other reconnected seconds later and took it straight back — exactly the
reported "connects for a few sec then switches back to Gaia".

Ownership now moves only when there is nothing to displace, when it is the
same laptop reconnecting, when the user tapped this one (a targeted seek),
when an untargeted seek picks the first answer, or when the owner has really
gone. Everything else may hold a link and get no ownership with it.

"Really gone" needs a grace period, not a disconnect event. Surrendering
ownership the moment the link dropped would hand the session to whichever
other laptop reconnected first, and a two-second flap is enough — likely on
Windows, whose BLE link has already been seen to drop mid-handshake. So a
dropped owner keeps the session for OWNERSHIP_GRACE_MS (20 s) and only then
becomes displaceable, which is the walk-away case. Tapping the device in the
UI bypasses the wait entirely: an explicit choice should not queue behind a
timer.

One coupling worth naming: the presence provider now suppresses the owner's
token only while it is actually LINKED. Keeping ownership across a drop
without that change would have meant never advertising to the laptop we still
consider the owner — it could never have found us again.

Also, minor: the Windows app called itself a "Linux laptop" on its own card.
One frontend bundle ships to every platform, so it cannot know at build time;
`ipc::host_platform` tells it.

Verified: Kotlin compiles, Android unit tests pass, both Rust targets check
clean, 57 + 205 tests. On the device, the phone's service came back up with
the new APK, re-linked with the Linux laptop and took ownership normally —
the single-laptop path is unregressed. The two-laptop case cannot be tested
from here; it needs both machines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he dance was

The previous commit put the connected-vs-active rule in the BLE reconnect
listener. A live capture from the phone showed the flapping was pure LAN:
75 seconds of logcat with both laptops up contained not one BLE line, so that
guard never ran.

The actual mechanism is three lines deep and entirely on the phone:

* `handlePeerAppState` writes `latestPeerState` — a SINGLE slot — from
  whichever laptop's heartbeat arrives, with no reference to ownership;
* each laptop heartbeats about every twelve seconds;
* the home screen picks the laptop to display by FRESHEST traffic
  (`peerLastSeen`), a heuristic for "which one is at my desk" that inverts the
  moment two are live.

So the card alternated between them on a ~12 s cadence, exactly as reported.
Nothing to do with BLE, and nothing the BLE-side rule could have reached.

Ownership is now one decision — `considerOwnership` — and every transport
defers to it. It moves only when nothing holds it, when the holder is the one
calling, when the user picked this laptop in the UI, or when the holder has
been silent for OWNERSHIP_GRACE_MS. A non-owner may hold a link, sync files
and browse, and gets no ownership with it.

Three supporting changes fall out:

* Liveness is a last-contact timestamp, not a disconnect event. The two
  transports fail differently — BLE raises a disconnect, a LAN session is torn
  down and rebuilt every heartbeat by design — so "the socket closed" means
  nothing there. Last contact means the same on both.
* The presence loop suppresses a peer's token based on a live GATT link
  (`GattServer.linkedPeerPubs`), not on ownership. They are no longer the same
  thing: a laptop can own the session over Wi-Fi with no BLE link at all, and
  suppressing its token then would leave it unable to find us on the one
  transport still working.
* The post-IK "refresh the UI's last-seen" nudge is owner-only. It would
  otherwise put a non-owner straight back at the top of the freshest-traffic
  ordering — the same dance through another door — and it re-emits the OWNER's
  snapshot, so attributing it to another laptop drew that card with someone
  else's battery.

Revoking trust stays ungated: a laptop dropping us is honoured whoever it is.

Verified on the device with both laptops live: 95 s of logcat shows exactly
one ownership change, and it is the user's own tap (`chosen=true`), where the
same window before the fix flipped repeatedly. Confirmed by the user. Kotlin
compiles, Android unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adaptive cadence asks "is the BLE link down?" and answers 12 s if so,
240 s if not — BLE already carries liveness, state pushes and the call signal,
so with it up the LAN tick only has to keep the cached-IP fast path warm.

Off Linux that question was a bare `true`: BLE is always down. So a Windows
laptop never reached the 240 s branch and paid a fresh TCP connect plus a full
Noise IK every twelve seconds, waking the phone's Wi-Fi each time. Measured
from the phone with both laptops live: nine handshakes in 95 s from Windows
against two from Linux.

The reason it was hard-coded is real — Linux answers from its BLE-audio
session map, and that map does not exist off Linux because earbuds hand-off
does not. So the portable loop now keeps its own flag, set where the link
comes up and cleared once after `connect_and_run` returns rather than at each
exit, so no future escape route can leave it stuck on.

Stuck-on is the dangerous direction, which is why the second half matters more
than the first: the 240 s sleep is only safe because the BLE loop wakes the
heartbeat the moment its link drops. The BlueZ loop has always done that; the
portable one never needed to, because its cadence never relaxed. It does now —
without it this change would turn a waste into a real regression, with the
phone looking offline for up to four minutes after a BLE drop.

Also pairs `note_disconnected` with the `note_connected` added earlier, so the
arbiter's connected set stops growing stale on this side.

Verified: both targets check clean, 57 tests, and the Linux app reinstalled
and relinked — its cadence is unchanged, being on the other side of the cfg.
The Windows half needs the next build to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
targetSdk 36 makes edge-to-edge mandatory, so the app draws behind the status
bar whether it asks to or not. The settings header sat in the same band as the
clock, where the system consumes the touch — the back arrow rendered fine and
simply did not respond, which reads as a broken button rather than a
mispositioned one. Same fix and same reasoning as the home and laptop-files
screens.

Background before padding, so the status bar still sits on our colour rather
than a bare strip.

That completes the sweep: Home, Laptop files and Settings each build on a bare
Column and now carry `systemBarsPadding()`; Notes needs nothing because it is
built on Material3's Scaffold + TopAppBar, which applies the inset itself. I
had twice offered to "fix Notes too" on the assumption it shared the defect —
it does not, and checking was overdue.

Verified on the device by the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A regression from the previous commit, and my fault. Relaxing the LAN
heartbeat to 4 minutes while BLE is up rests on "BLE carries liveness" — true
of the BlueZ loop, which has always pushed AppState every 12 s, and false of
the portable one, which pushed nothing at all. So the only thing telling the
phone this laptop existed was the LAN tick I had just slowed by 20x.

The phone greys a laptop out after LAPTOP_STALE_MS (30 s) without contact, so
it showed "disconnected" while being perfectly connected — and file browsing
over the same link kept working, because that path never touches AppState.
Exactly the shape reported.

The portable loop now beats every 12 s for the life of the link, carrying the
subset of state this platform can produce: lock state through the seam, the
cast offer, the camera request, the ring sequence and now-playing. Earbuds are
left out because the hand-off is Linux-only. A successful write also refreshes
presence and peer-contact, which are the liveness signals other subsystems
gate on.

I had guessed the wrong failure mode when I shipped the cadence change: the
README warned about the drop nudge, and the hole was the steady-state beat.
Worth remembering that "BLE carries liveness" was an assumption inherited from
a comment written about the other loop.

Verified: both targets check clean, 57 tests. The phone-side evidence that
found it — 75 s of logcat from the affected phone showing no traffic from the
Windows laptop at all, while the Linux one hammered it with correctly-rejected
handshakes — is what a 12 s beat should now fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Add phone" card is rendered `v-if="!primaryPeer"` — only while nothing is
paired. Once the first phone was linked it was replaced by that phone's card,
and pairing became unreachable: the sole route to a second phone was to forget
the one you had. Which is what the user had to do.

A Plus button now sits in the phone card's header, beside Ring and Switch.
Switch is the precedent — it is the other action there about phones that are
not this one — and Plus goes last so it stays the rightmost control: the two
before it are conditional (Ring needs the phone online, Switch needs a second
peer), and a button that shifts position as they appear is harder to find
twice.

It reuses `openPairPhoneModal`, so both platforms get it from one change:
`UiCmd::Scan` reaches `cmd_pairing::scan` on Linux and
`ble_portable::scan_for_ui` on Windows, and `UiCmd::Pair` reaches
`cmd_pairing::pair` or `pair_by_scan`. Nothing was missing underneath — the
gap was only in the UI. Checked before adding it that a second `save` appends
rather than replaces, so pairing a new phone cannot silently drop the old one.

No paired-devices list exists anywhere else in the laptop UI (`peers.title`
and `peers.forget_all` are defined in the locales and unused), so the card
header is the only place this could go.

Tooltip in all three locales. Frontend typechecks, both Rust targets check
clean, and the Linux app is reinstalled and running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the Plus button I had put in the phone card's header. That card
already carries ring, switch and files; a fourth icon there was crowding, and
more to the point it was not the shape the phone uses. Android's home screen
shows the active laptop, then an "Also paired" list, then a card to add one —
so the laptop now shows the same three things about phones, in the same order
and with the same wording where it transfers.

Two tiles, both full width under the phone and earbuds cards:

* "Also paired" — every other remembered phone as a compact clickable row with
  its name and when it was last heard from, spinner while a switch is in
  flight. Compact rows rather than a card each, for the reason the Android
  version gives: a full card per phone would push the rest of the page away
  for what is mostly "this one exists". The label never claims reachability —
  these are by definition the phones we are not the active peer of, so it says
  "seen 5 min ago", or "paired 2 d ago" for one that has not checked in this
  session.
* "Pair another phone" — hint plus a Pair button. Only rendered once a phone
  exists, because with none paired the "Add phone" card above already IS this.

Two details worth noting. The row icon is its own class rather than `vx-icon`
plus size utilities: Vue scoped styles compile to `.vx-icon[data-v-hash]`,
which out-specifies a plain `.h-[34px]`, so the override would have been
silently ignored — right size in the markup, wrong size on screen. And
`nowTick` is now exported from `useHome` rather than re-created here, so the
row labels age off the same clock as the online dot; two tickers would drift
and update at different moments.

Strings in all three locales, and the `pair.add_another` key from the previous
commit is removed rather than left dangling.

Frontend typechecks, both Rust targets check clean, locales verified in sync
(27 keys each), and the Linux app is reinstalled and running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A plus reads as "click me", and that tile is not clickable — its action is the
Pair button on the right — so clicking the icon did nothing.

The two "Add …" cards above keep their plus, and the difference is the point:
each of those is a single button, so the whole card responds and the plus is
honest. This tile has a hint line and a separate control, matching the
Android card it mirrors, so it gets the phone icon the rest of the page uses
for phones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Integration fallout from rebasing onto origin/main, which had moved a long way:
new Linux-only subsystems, a rewritten tray, and continued work on the very
file-transfer path this branch replaces.

WIRE. Upstream took frame type 0x4F for PHONE_FILES while PEER_HANDOFF had it
here. Two meanings for one type byte is a protocol that cannot be read, so
PEER_HANDOFF moves to 0x54 — it is the one that has never shipped outside
these two repositories, and it now sits just past this branch's FS block.

TRAY. Upstream replaced Tauri's tray with ksni, which speaks
StatusNotifierItem over D-Bus and cannot exist on Windows. Both
implementations expose the same two functions, so `tray.rs` is now a facade
over `tray_ksni` (Linux) and `tray_tauri` (elsewhere). The Tauri one stashes
its AppHandle at setup so its signature matches ksni's, which takes none.

WINDOWS GATES. Upstream added hogp (HID over GATT), bt_hid (a BlueZ profile
over zbus), audio_route, x11_focus and SIGTERM handling — all Linux-bound, all
newly in the way of a Windows build. Gated at the module where the whole thing
is Linux, at the statement where a cross-platform function has a BlueZ branch,
and stubbed where a caller needs an answer either way.

APIs THIS BRANCH HAD ALREADY REPLACED, with upstream callers grown since:
`ClipboardFileReader.read` returns a typed Outcome here (it distinguishes "too
large" from "unreadable", which the share sheet reports), so upstream's
callers get `readOrNull`. `ClipboardOutgoingFile` carries a URI, not bytes, so
upstream's MediaStore auto-send is ported onto ShareGrants — a captured video
is routinely hundreds of megabytes and stashing one in the blob store is the
allocation this branch exists to remove. Its MAX_FILE_BYTES guard goes with
it: the heap reason for a cap is gone, and the laptop streams on demand.

DUPLICATED FEATURES. Upstream and this branch each added shared-folder
picking. One row survives — upstream's, which is localised — but the branch's
launcher, because it registers the tree with FsRoots (what the protocol
actually serves) as well as persisting the permission, and upstream's reader
sees the same persisted grants either way. `Advertiser.startPresenceLoop`
keeps `linkedProvider` over upstream's `isConnected`: the latter is merely
ACL-connected, and BlueZ keeps the ACL open past the laptop app's death.

Verified: both Rust targets check clean, 78 + 225 tests, Kotlin compiles,
Android unit tests pass, frontend builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream's `applog` resolves its directory from `XDG_STATE_HOME`, else
`$HOME/.local/state`, else the temp directory. Neither variable is set on
Windows, so the log fell all the way through to `%TEMP%\vortex` — where a disk
cleanup is entitled to delete exactly the evidence a first run on an untested
platform exists to leave. It is also not where I told the user to look.

Linux keeps XDG state, which is the right home for a log and better than the
cache directory it used before. Everywhere else goes through the platform
seam, which answers `%LOCALAPPDATA%\Vortex` — beside the app's other state,
and the path the Windows notes have been quoting all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@X-Ryl669

Copy link
Copy Markdown
Contributor Author

Forgot to add, the windows port doesn't include an installer (I know Windows people expect those usually. It's a portable application). I've posted it here if you want to try it.

A ProjFS callback occupies a pool thread for as long as it runs, and every
Explorer action on the projection is a callback — so the protocol's 20 s reply
timeout was also the length of time Explorer appears hung when the phone stops
answering. A live run showed exactly that: a phone that went quiet mid
transfer left the folder frozen, and the log ends with two requests expiring
at the full twenty seconds, one after the other.

Metadata operations — enumeration, placeholder info, name queries, open — are
now bounded at 5 s and answer ERROR_TIMEOUT. That is still far longer than a
listing takes on a working link (11 ms over Wi-Fi, ~2.5 s over BLE), so it
only fires when something is genuinely wrong, and then a message in a moment
beats a frozen window for twenty seconds.

Hydration is deliberately NOT held to it: copying a large file over a slow
link legitimately takes minutes, and ProjFS is built to render that as a slow
copy rather than a hang. The distinction is the point — it is opening a folder
that must never feel dead, not finishing a copy.

Abandoning a request early is safe: `fs_link` keys its in-flight entry by
request id and drops it when the late reply lands or when the session ends.

This addresses the freeze, NOT the stall that triggered it. Why the phone
stopped answering after ~16 MB of a 59 MB download is still open — its logcat
had rolled over by the time I looked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reconnecting after the phone restarted took minutes, and the cause is a gap I
opened when the LAN heartbeat learned to relax to 4 minutes while BLE is up.

That relaxation trusts `LINK_UP`, which is cleared when `connect_and_run`
returns — and it only returns when the LISTENER returns. The listener cannot
notice a phone that has gone: it is parked waiting for a notification that
will never arrive, and WinRT can take minutes to surface the disconnect. So
`LINK_UP` stayed true, the LAN heartbeat kept sleeping on the strength of a
BLE link that no longer existed, and nothing reconnected until one or the
other finally timed out. Before the relaxation this was invisible, because LAN
ticked every 12 s regardless and covered for it.

The beat already knows. It writes every 12 s and gives up after six
consecutive failures, which is proof the link is gone — it just had nowhere to
report it, so it returned and left the session standing. It now races the
listener: whichever ends first ends the session, which clears `LINK_UP`,
wakes the LAN heartbeat and lets the loop reconnect.

A failing beat also retries at 2 s rather than at the beat interval, so a
verdict takes ~12 s instead of ~72 s. A healthy link wants a heartbeat; a
failing one wants an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counterpart of the Nautilus extension and the Dolphin ServiceMenu that
install_linux.sh writes: a classic shell verb under HKEY_CURRENT_USER, on
files (`*`) and on folders (`Directory`, which the Linux side shares too by
zipping them on the way out).

Registered by the app on every start rather than by an installer. Same
reasoning as the toast AUMID shortcut: someone who unzipped a standalone .exe
should get a working app, and this needs no admin rights and no packaging.
Rewritten each run rather than only when absent, because the command carries
this exe's full path — Explorer will not go looking for a binary that moved,
and the Dolphin ServiceMenu is regenerated each install for the same reason.

Two limits, both stated in the module and neither fixable with a registry
write. Windows 11 files classic verbs under "Show more options"; its
top-level menu only takes entries from an IExplorerCommand in a signed MSIX
package. And a multiple selection launches the verb once per file, capped at
15 — each launch forwards one path to the running instance, so the files do
arrive, as several offers rather than one batch, which the phone's share queue
already copes with.

Failure is logged and swallowed: a missing context-menu entry is a diminished
app, not a broken one, and every other way of sharing still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The webview runs with WEBKIT_DISABLE_DMABUF_RENDERER=1 on purpose — some GPU
stacks render a blank WebKitGTK window without it, see src-tauri/src/main.rs —
and that leaves it with no compositing at all. Layer promotion does nothing
(`will-change` measured to the noise floor), so every frame of every animation
repaints a good part of the window rather than the damaged rectangle, and
whatever sits in that area is paid for again at the frame rate.

vortex_logo.png was 512x512. The sidebar draws it at 30px, and no other use
is larger than 56px, so each repaint re-sampled a quarter of a megapixel down
to nine hundred pixels. That one image was the single largest cost on the
devices screen: with the connection dot pulsing, blanking just this <img> took
the web process from 68% of a core to 6%, and neither the sidebar's
backdrop-blur nor the logo's drop-shadow filter accounted for any of it.

Shipped at 128px, which is still more than twice every size it is drawn at.

Measured against the installed binary on the devices screen, not in a harness:
an offscreen WebKitGTK window silently stops animating, which makes every
reading from one meaningless.

perf(ui): pulse the connection dot without repainting the card

The devices screen animates exactly one thing — the 8px dot next to
"Connected" — and that dot was costing most of a core for as long as the
window was open, on a screen whose entire message is "everything in sync".
It is what the 100% WebKitWebProcess was.

`box-shadow` is a paint property, and with no compositor to hand the frame to
(see the previous commit, and the WEBKIT_DISABLE_DMABUF_RENDERER note in
main.rs) animating one re-rasterises the dot and everything under it, sixty
times a second. Scaling and fading a copy of the dot instead is the same ring
for a fraction of the work: with the logo already right-sized, ~19% of a core
becomes ~16%, and `steps(33, end)` — which caps the halo at 15 updates a
second rather than the display's 60, invisible on a soft fade — takes it to
~7%. `will-change` was tried on both and does nothing here; there is no
compositing to opt into.

The dot itself is now drawn by a masked pseudo-element rather than a
background colour clipped by `border-radius`, which fixes a rendering bug the
halo rewrite exposed. At eight CSS pixels the dot is eleven device pixels
across on a fractional display scale, and a clipped circle that small
rasterises to a different silhouette depending on the sub-pixel offset it
lands on — from the identical rule, the "This device" dot came out round and
the phone's came out a squircle, with a filled halo laid over it squaring it
off further. A radial mask is antialiased the same way wherever it falls. It
has to be a mask and not a gradient: a gradient fading to `transparent` fades
through black and leaves a visible dark rim at this size. The colour therefore
rides on `currentColor` rather than bg-*, and the glow moved to `drop-shadow`,
which follows the masked circle where a box-shadow would trace the square
border box and then be masked away.

Verified on the installed binary: the halo renders frame-for-frame as before,
both dots are round, and the web process sits at 6-8% of a core where it was
at 70%.

fix(ui): size the main window so the phone card stops wrapping

The default 760x880 predates the browse button on the "Connected" row. With
it there, the phone card no longer had the width for its own labels: "Android
phone" and "Use phone as webcam" each wrapped to two lines, which pushed the
content past the bottom of the window and left the page scrolling on first
launch.

920x860 fits all of it on one line — the pairing hint too — and is shorter
than before rather than taller, because the two recovered lines more than pay
for the extra width. Checked against the rendered window, with the browse
button present.

fix(android): only hold the screen on while waiting on another device

MainActivity.onCreate set three window flags, with a comment saying what they
were: "Dev-only: keep the screen on so the lab tester can read the generated
identity. Production removes this." It never did. The phone could not sleep
for as long as Vortex was in front, and FLAG_DISMISS_KEYGUARD quietly waived
a non-secure lock screen every time the activity came up — including from the
foreground-service notification, the one place the app launches itself.
FLAG_TURN_SCREEN_ON came along for the ride, and both keyguard APIs have been
superseded by setShowWhenLocked / requestDismissKeyguard anyway.

Nothing depended on them. The phone-to-laptop mirror holds its own
SCREEN_DIM_WAKE_LOCK inside ScreenMirrorService, where it has to be, since
capture continues with the activity gone; LaptopMirrorActivity sets its own
FLAG_KEEP_SCREEN_ON while you watch the laptop; RingActivity wakes the screen
with setShowWhenLocked/setTurnScreenOn.

What did deserve the flag is the pairing flow, where you read the screen
without touching it and a display timeout in the middle of a handshake takes
the radio work down with it. So KEEP_SCREEN_ON is now scoped in VortexRoot to
the three bounded waits that need it — the pairing window, the SAS
comparison, the switch-laptop seek — and released by the DisposableEffect, so
it cannot outlive the window closing or the activity going away.

Verified on the phone: the live window on an idle home screen is down to
LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED
DRAWS_SYSTEM_BAR_BACKGROUNDS, and the display now times out with the app in
the foreground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@X-Ryl669

X-Ryl669 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

The last commit is fixing a (required) performance issue that's burning both laptop and phone's battery uselessly.
On the laptop side, the "pulsing" dot when connected was made by downsampling a 1200px picture of the dot to a 11px rendering and this was done for every frame of the animation, causing 100% CPU burn on a core when the UI is visible.
On the Android's side, the application request a wake lock when in foreground which prevent the screen to turn off for no reason (also burning battery). I've let the scanning/pair activity take the wake lock since it would be disturbing to unlock to check if the scan succeeded.

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