Two causes. The connect step had no timeout, so an unreachable device was
retried indefinitely instead of falling through to hold-for-later; it now
gives up after 15s and holds the offer as designed.
The picker also waited on the whole exchange, which includes a person on the
other device deciding — up to two minutes. It now closes on tap and reports
the outcome as a message, and a decline or an unanswered offer is shown as
information rather than an error, since the offer did arrive.
Another way to deliver an invitation the user already created, alongside the
QR code, rather than a second share of the same files: the ticket handed over
is the stored one and the transfer id is unchanged.
Only an active share can be offered. A stopped one no longer serves its
content, so handing out its ticket would promise nothing.
An unreachable device is a delay, not a failure: the share stays here and
the ticket waits in held_offers (schema 8 -> 9) until that device comes and
collects it. No server and no push, per the design.
Polling needs no grant proof. iroh has already authenticated the remote
endpoint key, and a device is only handed offers addressed to precisely that
endpoint, so a stranger polling learns nothing. Offers are consumed on
delivery, so polling twice does not re-deliver, and cancelling the transfer
withdraws the waiting ticket.
Polling is rate limited per device: it tells every contact the app was
opened, so it must never become a presence beacon.
Adds SubmitOffer to the contacts ALPN: the sender creates an ordinary share
and pushes the ticket over an authenticated connection, replacing the QR
code without changing the transfer itself.
Only the receiving user is prompted. The sender pre-authorises the target
endpoint before offering, and the approval service now honours an existing
access session, so the handshake the receiver runs next does not ask the
sender to approve a transfer they initiated. An unsolicited ticket receive
still prompts as before.
The ticket leaves the core only when the user accepts; declining yields
nothing. Offer-created shares are never public, one prompt per device is
pending at a time, a decline starts a cooldown, and forgetting a device
clears any prompt it left on screen.
New /vnidrop/offer/1 ALPN carrying grant delivery and revocation, with a
per-connection challenge so a captured proof cannot be replayed onto another
connection. Unlike the transfer handshake, this serves nobody without a
grant, so an unpaired device cannot raise a prompt on the far side.
A delivered grant is never stored on arrival: it waits for the local user's
consent, so an unsolicited grant cannot create a contact. Forgetting a
contact revokes locally first and notifies the peer best effort. A blocked
endpoint is refused indistinguishably from any other refusal.
Adds the UniFFI surface for listing, pairing, forgetting, blocking, labels,
and grant lifetime.
Schema 7 -> 8 adds contacts, grants_issued, grants_held, and
blocked_endpoints. Kept in their own module so repository.rs does not grow
further; the tables migrate with the rest of the schema through the shared
pool.
Revocation tombstones rather than deletes, so a returning peer is answered
Revoked instead of Unknown and can drop its dead entry. Blocking revokes any
outstanding grant, and unblocking does not hand access back.
Grants are the capability a device issues so a known peer may reach it. The
issuer is the only party that can validate one, which is what makes consent
and revocation enforceable without the peer's cooperation.
Pure module: proof construction and constant-time verification bound to the
challenge and both endpoint ids, idle expiry renewed on use, and secrets
redacted in Debug output.
Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
Tighten approve-endpoint to active shares with TTL sessions, reject non-file
FDs, lower default ticket/approval/size caps, show endpoint IDs and Public-mode
warnings, harden Android receive path checks, and run cargo-audit in CI.
Close high-severity findings from the security review: default-deny blob
gets for unmapped hashes, map collection members for ACL, fail closed on
unknown access modes, require vnd1 tickets only, redact tickets from events,
exclude Android app-data backups, and tighten secret-file creation.
Make agent instructions imperative and command-first, add nested
crates/vnidrop and shared guides, and keep compose-skill as the UI
source of truth with VniDrop-specific overrides.
Break the ~1.7k-line runtime orchestration unit into facade, share,
receive, lifecycle, and provider modules so future send/receive work
stays reviewable. Behavior and UniFFI surface are unchanged.
Signal transfer cancel synchronously before any runtime await, drive
API calls via Handle::block_on so concurrent cancel cannot deadlock a
receive, and rewrite the gated output-sink cancel test to avoid the
slow multi-megabyte write loop that starved CI.
Attribute provider transfer progress to each remote endpoint so the
receivers panel and catalog can show per-peer send bars for multi-file
collections while a share is active.
Make the transfer loop feel live: parse real core progress payloads, show
progress on send/receive surfaces, allow cancel/stop, retry failed receives,
and fix delivery-phase events so receiver completion updates the UI.
Default Android receive destination is now the shared system Downloads
collection (like desktop), using MediaStore on API 29+ instead of
app-private storage. Legacy app-private defaults are promoted back to
public Downloads so existing installs pick up the fix.
Android emulated storage rejects hard-link commit even when canWrite()
reports success. Fall back to exclusive rename for publish, probe real
writes during folder validation, and label the default app downloads dir
clearly so it is not confused with shared system Downloads.