22 keys carried the English text in every locale. Copied English does not
look unfinished — it looks shipped: the key is present and non-empty, so
`validate` passes and it reaches users as a French or Russian build that
silently speaks English. 21 came in with the saved-devices work; one was
added alongside the send-cancellation fix by copying its neighbours.
Translations follow the vocabulary already established in the file —
appareil enregistré, dispositivo guardado, Kopplung, Mémoriser/Ricorda/
Zapamiętaj, and European Portuguese ficheiros/guardado.
Only format_separated_pair and format_separated_triple still read the same
in every locale. They are pure punctuation ("{first} · {second}") with no
words to translate.
Documents the rule in the README, including why it is worse than a missing
key and the trap that spread it here: copying a neighbour's shape without
checking the neighbour was translated.
Creating a targeted transfer contacts the peer and only returns once the
offer is answered or its timeouts expire — connection_timeout plus
offer_wait_timeout, so minutes against a device that never answers. The
composer disabled every control for that whole window, including Close,
leaving no way out. Worse, the core records the row before it reaches out
and leaves it in `failed` when the peer never replies, so giving up still
produced a failure notification and a history entry for a send the user
had already called off.
Cancelling now reaches the core while that create is still running. The
`created` lifecycle event carries the transfer id and is emitted before
the peer is contacted, so the id is known in time; the cancel goes out
through the interrupt lane, which exists precisely to reach a core busy
inside another call. The transfer is then deleted, and its id is filtered
out of the published list so a refresh racing the delete cannot leak it
into history or into a notification. If the id has not arrived yet, the
result carries it and the same cleanup runs on return. Picked sources are
released only once the call lands, because the import owns them until
then, and a generation counter keeps a late result from disturbing a
newer send.
Close and Cancel were also the same action under two labels. There is now
one control: the sheet's cancellation item, reading Cancel while a send is
waiting and Close otherwise, never disabled.
Two further fixes from device testing:
- Receive and Resume were gated on transfer state alone, so an approved
outgoing transfer offered the sender a Receive button — an invitation to
download the files it was uploading. Both pull into a local folder and
are now receiver-only.
- Renamed the deprecated `laptopcomputerAndIphone` symbol to
`macbookAndIphone`; the deployment targets are well past where it was
introduced, so no availability guard is needed.
Adds a typed `targetedTransferId` accessor on CoreEventModel. This is a
narrow, deliberate exception to the wake-up-only event rule in
DESIGN-DEVICE-HISTORY.md §13: it takes the subject id and never state,
and it exists because no query can answer while the create holds the
serial lane — which is exactly when the user wants to cancel.
Known gap: direction is inferred by comparing endpoint ids, because the
binding does not expose the row's role. After an identity reset, rows
predating it match neither endpoint, so past sends read as incoming from
the device's own retired identity. Fixing that needs `role` on the core's
TargetedTransfer.
Adds the native SwiftUI Saved Devices experience on top of the production
saved-device core, as a top-level destination in the iOS tab bar and the
macOS sidebar.
Core seam:
- App-facing saved-device domain models mirroring core/SavedDeviceModels.kt,
with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views
never hand-roll state checks.
- 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping.
cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial
lane: each must reach the core while a targeted receive is blocking it.
- Payload-free pairingChanged/targetedTransferChanged signals, dispatched
before the numeric-transferId guard since saved-device events identify
their subject by peer endpoint or a string transfer id.
Experience:
- Screen lists saved devices and outstanding consent requests only; the
global targeted-transfer history stays out, reachable per device.
- Details as a sheet with detents on compact layouts and a native inspector
on macOS, owning Send, label, forget/block and that device's transfers.
- Label editing is transactional: the draft and editor survive a failed
write, conflicting actions are refused while saving, and the editor closes
only after the core confirms.
- Pairing and targeted-offer consent hosted at the app root, answerable from
any tab and suppressed while a transfer approval is up. Dismissing a
pairing prompt suppresses locally without consuming the single-use
eligibility; dismissing an offer declines it, since an unanswered offer
holds a slot in the core's bounded per-sender queue.
- Targeted send reuses the invitation composer's affordances with file,
folder, rename, replace and cleanup parity. Picker copies are released on
replace/remove/clear/cancel and after a successful create, but kept after a
failure so retry does not require re-picking.
- Notifications for pairing requests and offers (withdrawn once answered) and
for terminal targeted transfers. Wording follows direction: on the sending
device the peer finished receiving, not us.
Localization:
- Widens 52 saved-device keys from kmp-only to both platforms.
- Five keys carried a literal %1$s with no declared args, which Compose
renders positionally but the Apple generator emits as a plain constant,
leaking the placeholder into the UI. They now use named args; Compose
output is byte-identical.
- Adds targeted_offer_title/body. Reusing the invitation approval copy stated
the roles backwards, announcing the sender as the receiver.
Also surfaces core startup failures: the startup overlay is drawn above the
snackbar host, so a failed initialize() was indistinguishable from an app
that never finished loading. AppModel now keeps the reason, logs it, and the
overlay shows it with a retry, plus the technical detail in DEBUG builds.
Send and receive between two devices is verified only partially; a missing
endpoint-identity credential currently blocks startup on the test device.
Add the VniDrop-specific Compose architecture skill, unify invitation and targeted transfer drafts, and promote Saved devices to an adaptive first-class destination.
- map the new VnidropError cases (DeviceUnavailable, OfferTimeout,
RelayPolicyIncompatible, ProtocolIncompatible) to existing catalog keys
- drop the stale .map(\.share) now that sharePickedFiles returns Share
- remove the duplicate .transfersChanged pattern in the signal switch
- replace the deprecated String(cString:) sysctl decode
- close the CoreGateway protocol declaration
Run Secret Service IO on spawn_blocking so Linux zbus cannot nest Tokio
runtimes during init, and wait for core initialize before experimental
saved-device coordinators refresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
Use flock for profile locks, hash secret record filenames under NAME_MAX,
and dismiss the starting overlay after the first init attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
Stop returning grant strings across UniFFI; approve yields typed outcomes and
pull/resume use transfer id plus path or ReceiveOutputSink. Document the
pairing/targeted event catalog and cover Android MediaStore-style sink contracts.
Co-authored-by: Cursor <cursoragent@cursor.com>
Peel relationships, eligibility, and secrets off the shared pool into
AppDataStores adapters, split pairing service/protocol, and move the
invitation Repository into its own module so open_all owns schemas.
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce persistence::open_all so domain stores (invitation, targeted,
blocked) are constructed once. Extract TargetedTransferStore and own it on
CoreInner; soft-close raw pool access for new callers.
Co-authored-by: Cursor <cursoragent@cursor.com>
Prove the DPAPI-backed Windows bridge can drive the full public saved-device
and targeted-transfer contract via an injectable API fake on non-Windows hosts
and real DPAPI under cfg(windows), without product UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
Prove Keystore-backed Android secret storage can drive the full public
saved-device and targeted-transfer contract without product UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
Platform contract harnesses need monotonic event revisions for at-least-once
dedup and a typed rename API that survives listing.
Co-authored-by: Cursor <cursoragent@cursor.com>
Preserve approved transfer auth and state across restart, resume without
re-approval, and keep cancel/delete from leaving usable orphan authorization.
Co-authored-by: Cursor <cursoragent@cursor.com>
Targeted offers and pairing now validate relay-policy compatibility and
reject protocol downgrades with typed unavailable/timeout/incompatibility
errors, without falling back to ordinary shares.
Co-authored-by: Cursor <cursoragent@cursor.com>
Cancel targeted protocol shares synchronously before await, and treat block
lookup errors as denied so store failures cannot admit blocked peers.
Co-authored-by: Cursor <cursoragent@cursor.com>
Combine ticket 09 lifecycle APIs with ticket 10 targeted-transfer protocol
and wire forget/block to real cancel_targeted_transfers_for_peer.
Co-authored-by: Cursor <cursoragent@cursor.com>
Give saved-device owners immediate local control over grants and identity-wide
denies, with minimal tombstones for replay rejection and a ticket-10 hook for
targeted-transfer cancellation.
Co-authored-by: Cursor <cursoragent@cursor.com>
Establish PendingOutgoing/PendingIncoming relationships over a token-bound
pairing protocol, exchange directional grants with challenge-response proofs
and a final ack before Saved, and merge simultaneous initiations without a
second prompt.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add the experimental eligibility control plane so either endpoint of a
fully completed authenticated invitation transfer can start one single-use
pairing attempt within 24 hours, with secrets held in custody and invalid
requests rejected silently.
Co-authored-by: Cursor <cursoragent@cursor.com>