Conversation
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>
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>
|
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>
33216d4 to
ee6bc01
Compare
|
The last commit is fixing a (required) performance issue that's burning both laptop and phone's battery uselessly. |
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-gnucargo 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).