Compare commits
30 Commits
d52ac52cea
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| c852a68f28 | |||
| 225ff9ad22 | |||
| 677fc3c6d5 | |||
| 3441280599 | |||
| d8587851a3 | |||
| cba51ad504 | |||
| 0f70663263 | |||
| 94a8ba2103 | |||
| 268fbf161d | |||
| dc23e87c56 | |||
| 1369df4578 | |||
| 256ff89423 | |||
| 3c89c34c6e | |||
| 178def0629 | |||
| 5f5f7e0515 | |||
| e041fbeda2 | |||
| 7aa99304b2 | |||
| 9fbcf653e8 | |||
| 4cfee786fc | |||
| 0388422318 | |||
| 7afc7d0892 | |||
|
|
e8c3eadfc8 | ||
| 877083a3ed | |||
| 7cb2270d56 | |||
| eb8498168d | |||
| ac6e837560 | |||
| 3e18378610 | |||
| b68d338097 | |||
| b8a002a2ad | |||
| 232fb125d3 |
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(swift test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
Cargo.lock
generated
@@ -5227,6 +5227,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"futures-lite",
|
||||
"getrandom 0.3.4",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"iroh-relay",
|
||||
|
||||
487
DESIGN-DEVICE-HISTORY.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# Design — Device history and direct offers
|
||||
|
||||
Status: **implemented** in the Rust core and the SwiftUI app. The KMP/Compose
|
||||
app has not been built yet; the UniFFI surface is additive, so `shared/` still
|
||||
compiles untouched and the Compose string resources are already generated.
|
||||
|
||||
Where the build deviates from what was specified here, the section says so.
|
||||
|
||||
Lets a user send to a device they have already transferred with, without
|
||||
creating and sharing a new invitation. Both sides opt in to being remembered,
|
||||
and either side can end the relationship later and have that actually take
|
||||
effect on the other device.
|
||||
|
||||
Local network discovery was considered and deliberately dropped. See
|
||||
[Appendix A](#appendix-a--deferred-local-network-discovery).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Send to a previously used device with no new invitation, QR code, or NFC tap.
|
||||
- Let each side independently decide whether to be remembered after a transfer.
|
||||
- Let either side revoke that relationship unilaterally, with real effect.
|
||||
- Keep the receiving side's confirmation mandatory for every transfer that
|
||||
arrives this way.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No automatic acceptance of transfers, under any configuration.
|
||||
- No server-side store-and-forward and no push infrastructure. An offer to an
|
||||
unreachable device is held **on the sender's own device** or fails; nothing is
|
||||
uploaded anywhere. See §11 for what this means in practice.
|
||||
- No presence or "who is online" indicator. Knowing it requires probing, and
|
||||
probing tells every contact when you opened your list. Reachability is
|
||||
resolved lazily, at send time.
|
||||
- No change to the invitation (QR / NFC / `.vnd`) flow, which remains how a
|
||||
first contact is made and how an unpaired device is reached.
|
||||
|
||||
### Relationship to the existing flow
|
||||
|
||||
First contact is unchanged: an invitation, a transfer, a receiver confirmation.
|
||||
This feature only removes the invitation step from the *second* and subsequent
|
||||
transfers between the same two devices.
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat model
|
||||
|
||||
Assume an attacker who can run a modified VniDrop client, choose any display
|
||||
name, and reach the target over the network.
|
||||
|
||||
| Property | Mechanism |
|
||||
|---|---|
|
||||
| A stranger cannot send an unsolicited transfer prompt | The offer protocol requires a valid grant (§3) |
|
||||
| A stranger cannot impersonate a known device | Identity is the iroh endpoint key; display names are untrusted data |
|
||||
| Being remembered requires consent from the remembered party | Grants are minted by the party being remembered (§3.4) |
|
||||
| A user can end a relationship unilaterally | A grant is validated only by its issuer (§3.3) |
|
||||
| A revoked peer cannot quietly regain access | Revocation is local and immediate; no cooperation required |
|
||||
|
||||
Explicit non-property: we cannot erase data from a device we do not control. A
|
||||
revoked peer's app may still hold a name string on disk. What is guaranteed is
|
||||
that the entry stops **functioning** — see §3.3.
|
||||
|
||||
The app broadcasts nothing and advertises nothing. There is no passive network
|
||||
surface introduced by this feature at all.
|
||||
|
||||
---
|
||||
|
||||
## 3. Grants: the core primitive
|
||||
|
||||
A history entry is **not** "I remember this device's endpoint ID". It is "this
|
||||
device issued me a capability to reach it". This is what makes both consent and
|
||||
revocation real rather than promised, and it is the reason a grant-based design
|
||||
is worth the modest extra complexity over storing a public key.
|
||||
|
||||
### 3.1 Shape
|
||||
|
||||
A grant is directional. If Alice wants Bob to be able to reach her, *Alice*
|
||||
mints the grant and gives it to Bob:
|
||||
|
||||
- `grant_id` — 128-bit random, opaque.
|
||||
- `grant_secret` — 256-bit random.
|
||||
- Bound to Bob's endpoint ID at issue time.
|
||||
- `expires_at` — an **idle** expiry, renewed on use (§3.5).
|
||||
|
||||
Alice keeps `(grant_id, grant_secret, bob_endpoint_id, expires_at, revoked_at)`
|
||||
in her **issued** table. Bob keeps `(grant_id, grant_secret, alice_endpoint_id,
|
||||
display_name, …)` in his **held** table, which is what his history UI lists.
|
||||
|
||||
A mutual relationship is two independent grants. Either side can revoke its own
|
||||
without affecting the other direction, which is the correct semantics: "you may
|
||||
no longer reach me" is separable from "I may no longer reach you".
|
||||
|
||||
### 3.2 Proving a grant
|
||||
|
||||
iroh already provides a mutually authenticated, encrypted QUIC connection, so
|
||||
both endpoint IDs are known and trustworthy at the transport layer. On top of
|
||||
that, challenge–response proves possession of the grant without ever
|
||||
transmitting it:
|
||||
|
||||
1. Alice (the accepting side) sends a 32-byte random `challenge`.
|
||||
2. Bob replies with `grant_id` and
|
||||
`HMAC(grant_secret, "vnidrop-grant-v1" ‖ challenge ‖ alice_endpoint_id ‖ bob_endpoint_id)`.
|
||||
3. Alice looks up `grant_id`, checks it is neither revoked nor expired, checks
|
||||
that the connection's remote endpoint ID equals the endpoint the grant was
|
||||
issued to, and verifies the HMAC in constant time.
|
||||
|
||||
Binding to the issued-to endpoint means Bob cannot lend his grant to a third
|
||||
party. Binding to the challenge means a captured proof cannot be replayed.
|
||||
|
||||
### 3.3 Revocation
|
||||
|
||||
Alice deletes (or tombstones) the grant in her issued table. That is the whole
|
||||
mechanism, and it is sufficient: hers is the **only** device that can validate
|
||||
it. Bob's next attempt presents an unknown `grant_id`, is refused, and his
|
||||
client deletes the dead entry.
|
||||
|
||||
The refusal is **explicit**: ordinary revocation returns a distinct `Revoked`
|
||||
status so Bob's client can remove the entry immediately and tell him the device
|
||||
is no longer available. Silence would leave a zombie entry, and Bob can infer
|
||||
what happened regardless, so the deniability is not worth the worse behavior.
|
||||
|
||||
The hard block list is the exception: a blocked endpoint receives a response
|
||||
indistinguishable from an expired or unknown grant, so blocking cannot be
|
||||
detected by probing.
|
||||
|
||||
Additionally, when Alice revokes while Bob is reachable, she sends a best-effort
|
||||
`RevokeGrant { grant_id }` so his entry disappears promptly rather than at his
|
||||
next attempt. Best-effort only — correctness never depends on it arriving.
|
||||
|
||||
Two things revocation deliberately is **not**:
|
||||
|
||||
- **Not retroactive.** Files already sent stay sent. UI copy must say so.
|
||||
- **Not a block.** Bob can still reach Alice with a QR invitation like any
|
||||
stranger. A separate hard block list refuses a given endpoint ID at the offer
|
||||
and handshake layers.
|
||||
|
||||
### 3.4 Consent to be remembered
|
||||
|
||||
After a completed transfer, each side is asked independently whether to remember
|
||||
the other. If Alice declines, no grant is minted, so Bob has nothing functional
|
||||
to store and his UI must not offer to save the device. Bob cannot override
|
||||
Alice's choice, because the useful half of the entry is hers to issue.
|
||||
|
||||
The prompt is per-transfer and must be dismissible without a choice, defaulting
|
||||
to "no". A user who never engages with it is never added to anyone's history.
|
||||
|
||||
### 3.5 Grant lifetime
|
||||
|
||||
Grants expire on **idleness, not age**. Each successful offer renews the
|
||||
issuer's `expires_at`, so a relationship in regular use never lapses, while one
|
||||
that is forgotten cleans itself up.
|
||||
|
||||
Default idle lifetime: **90 days**, configurable per device in settings
|
||||
(30 / 90 / 365 days / never) and applied at issue time. Changing the setting
|
||||
affects newly minted grants; existing ones keep the lifetime they were issued
|
||||
with until renewed.
|
||||
|
||||
Renewal is issuer-side only and needs no protocol message: Alice extends the
|
||||
grant when she validates a proof from Bob. An expired grant behaves exactly like
|
||||
a revoked one from Bob's side, except that the UI explains it as inactivity and
|
||||
offers to pair again rather than presenting it as a deliberate removal.
|
||||
|
||||
This bounds the blast radius of a pairing the user has forgotten about, and it
|
||||
softens the reinstall problem in §5.1: dead entries pointing at a regenerated
|
||||
`iroh.secret` eventually disappear on their own.
|
||||
|
||||
---
|
||||
|
||||
## 4. The offer protocol
|
||||
|
||||
Today the protocol is strictly receiver-pull: the sender never initiates. An
|
||||
offer inverts only the *delivery of the ticket*, not the transfer itself.
|
||||
|
||||
New ALPN: `/vnidrop/offer/1`.
|
||||
|
||||
1. Sender picks a contact from history.
|
||||
2. Sender creates the share exactly as today (`share_files`). The share is
|
||||
`ApprovalRequired`; an offer-created share may **never** be `Public`
|
||||
(invariant, enforced in `access_policy`).
|
||||
3. Sender pre-authorizes the target endpoint for that `transfer_id` via the
|
||||
existing `AccessPolicy::approve_endpoint_until`, so the sender is not later
|
||||
prompted to approve a transfer they themselves initiated.
|
||||
4. Sender dials the target's offer ALPN, completes the grant challenge–response
|
||||
(§3.2), and sends
|
||||
`Offer { ticket, sender_display_name, file_count, total_bytes }`.
|
||||
5. **The receiver is prompted.** This is the mandatory confirmation and it has
|
||||
no bypass.
|
||||
6. On accept, the receiver calls the existing `receive(ticket, output_dir,
|
||||
receiver_name)` — completely unchanged. It dials the sender's existing
|
||||
`/vnidrop/handshake/2`, where the pre-authorization from step 3 is already in
|
||||
place, so exactly one human is prompted for the whole flow.
|
||||
7. On decline, the sender receives `Declined` and stops the share.
|
||||
|
||||
The ticket must satisfy the receiver's relay profile, so the existing
|
||||
`ticket_matches_relay_profile` check applies unchanged: a contact on a
|
||||
strict-custom profile will refuse an offer whose ticket advertises public
|
||||
relays, and the UI must explain that rather than failing opaquely.
|
||||
|
||||
### 4.1 Identity display
|
||||
|
||||
Display names are attacker-chosen data — the existing handshake already treats
|
||||
`receiver_name` that way, and the same rule applies here. The endpoint ID is the
|
||||
only real identity. Therefore:
|
||||
|
||||
- A contact's local label is set by the local user and is **never** silently
|
||||
overwritten by a name the remote later claims. A changed remote name is shown
|
||||
as a distinct, dismissible signal.
|
||||
- A short fingerprint derived from the endpoint ID is available in the contact
|
||||
detail view, for out-of-band verification.
|
||||
|
||||
---
|
||||
|
||||
## 5. Address resolution and reachability
|
||||
|
||||
A contact stores an endpoint ID, but iroh needs an address to dial. Without
|
||||
local discovery, resolution depends on the relay profile:
|
||||
|
||||
| Relay mode | Resolution |
|
||||
|---|---|
|
||||
| `Automatic` | Public discovery resolves the endpoint ID anywhere |
|
||||
| `StrictCustom` / `CustomWithDirectFallback` | Reachable through the configured relay, whose URL is stable |
|
||||
| `LocalOnly` | Only while the cached direct address is still valid |
|
||||
|
||||
`presets::Minimal` deliberately leaves address lookup empty for the restricted
|
||||
modes (see the comment at `runtime/mod.rs:154`), so those modes cannot fall back
|
||||
to public resolution — by design.
|
||||
|
||||
**Mitigation: cache the peer's last-known `EndpointAddr` on the contact and
|
||||
refresh it after every successful connection.** The repository already persists
|
||||
sender addresses this way for receive rows —
|
||||
`encode_persisted_sender_address` / `parse_persisted_sender_address` in
|
||||
`ticket.rs:72` — so this reuses an established pattern rather than inventing
|
||||
one.
|
||||
|
||||
This covers relay modes fully, and covers `LocalOnly` for as long as the peer's
|
||||
address is unchanged. When it is not, the send fails and the user falls back to
|
||||
a QR invitation: no regression against today's behavior, but the UI must say so
|
||||
plainly rather than presenting an opaque failure. Local-only users in particular
|
||||
should be told that contacts depend on a cached address.
|
||||
|
||||
Reachability is never polled in the background. It is determined when the user
|
||||
actually sends — and, for incoming offers, when the app next comes to the
|
||||
foreground (§11).
|
||||
|
||||
### 5.1 Identity lifetime
|
||||
|
||||
Reinstalling the app regenerates `iroh.secret`, so every grant referencing the
|
||||
old endpoint dies. The UI needs an explicit "this device is no longer
|
||||
recognized, pair again" state rather than a silent failure.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model
|
||||
|
||||
New tables in the existing SQLite repository, with a schema migration:
|
||||
|
||||
| Table | Columns (sketch) |
|
||||
|---|---|
|
||||
| `contacts` | `id`, `endpoint_id` (unique), `local_label`, `remote_display_name`, `last_known_addr`, `created_at`, `last_transfer_at` |
|
||||
| `grants_issued` | `grant_id`, `grant_secret`, `issued_to_endpoint_id`, `created_at`, `expires_at` (idle, renewed on use), `revoked_at` |
|
||||
| `grants_held` | `grant_id`, `grant_secret`, `peer_endpoint_id`, `created_at`, `expires_at` (advisory copy) |
|
||||
| `blocked_endpoints` | `endpoint_id`, `created_at` |
|
||||
|
||||
`grant_secret` is **key material**. It follows the same rule as tickets: never
|
||||
in events, never in logs, never in bug reports, never in a UniFFI return value.
|
||||
The existing "tickets are capabilities" discipline extends verbatim.
|
||||
|
||||
A contact list is itself a privacy artifact — it names the people someone
|
||||
exchanges files with. It must be deletable per-entry and wholesale, and the
|
||||
wholesale delete must be reachable from the same place as the existing
|
||||
transfer-history and cache clearing actions.
|
||||
|
||||
Deleting a contact deletes both directions' grants for that peer and, for the
|
||||
issued side, triggers the best-effort revoke message.
|
||||
|
||||
---
|
||||
|
||||
## 7. Abuse and resource limits
|
||||
|
||||
Extend `CoreLimits` rather than inventing a parallel mechanism:
|
||||
|
||||
- `max_contacts`.
|
||||
- `max_pending_offers`, mirroring the existing `max_pending_approvals`.
|
||||
- Per-endpoint offer rate limiting, with a cooldown after repeated declines.
|
||||
- Blocked endpoints are refused at the offer ALPN before any user-visible
|
||||
prompt.
|
||||
|
||||
Because an offer already requires a valid grant, the spam surface is limited to
|
||||
devices the user deliberately chose to be reachable by, and the remedy — revoke
|
||||
— is one tap.
|
||||
|
||||
---
|
||||
|
||||
## 8. Surfaces to build
|
||||
|
||||
- **Rust core:** offer ALPN and handler, grant minting/proof/revocation,
|
||||
contacts and grants repository with migration, address caching, new limits,
|
||||
block list.
|
||||
- **UniFFI:** additive API — list/rename/delete contacts, send-to-contact,
|
||||
revoke, block/unblock, respond to an incoming offer, plus the corresponding
|
||||
events. Additive changes do not break existing Kotlin or Swift call sites, but
|
||||
both must be updated to use them.
|
||||
- **Compose (`shared/`)** and **SwiftUI (`apple/`)**: a contacts list and detail
|
||||
view, the post-transfer "remember this device?" prompt, the incoming-offer
|
||||
confirmation, a send-to-contact entry point in the send flow, and settings for
|
||||
the feature toggle, the grant idle lifetime (30 / 90 / 365 days / never,
|
||||
default 90), and blocked devices.
|
||||
- **Localization:** all new strings go in `localization/strings.json` and are
|
||||
generated; the platform catalogs are never hand-edited.
|
||||
|
||||
No new OS permissions, entitlements, or platform bridges are required.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- **Grant crypto:** fixed vectors for the HMAC proof; expiry, revocation,
|
||||
wrong-endpoint binding, and replay rejection.
|
||||
- **Grant lifetime:** a successful proof renews `expires_at`; an idle grant
|
||||
lapses at the configured boundary; a renewed grant survives past its original
|
||||
expiry. Assert the revoked and blocked responses are distinguishable from each
|
||||
other and that blocked is indistinguishable from expired/unknown.
|
||||
- **Offer protocol:** two in-process nodes using the existing
|
||||
`crates/vnidrop/tests/support` harness — accept, decline, revoked grant,
|
||||
expired grant, blocked endpoint, relay-profile mismatch, and the invariant
|
||||
that an offer-created share is never `Public`.
|
||||
- **Pre-authorization:** assert the sender is prompted exactly zero times and
|
||||
the receiver exactly once, for a full offer → accept → transfer round trip.
|
||||
- **Consent:** assert that declining to be remembered leaves the peer with no
|
||||
usable grant, and that a subsequent offer from that peer is refused.
|
||||
- **Address caching:** a contact whose cached address is stale falls back
|
||||
cleanly and reports an actionable error, rather than hanging.
|
||||
- **Persistence:** grants and contacts survive a core shutdown and reopen of the
|
||||
same data dir, following the existing recovery-test pattern.
|
||||
- **Sender-held offers (§11):** an offer to an unreachable contact is retained,
|
||||
is cancellable, is collected on the receiver's next pull, and is not
|
||||
double-delivered if the receiver pulls twice.
|
||||
- Per `AGENTS.md`, any bug found gets a regression test at the lowest layer.
|
||||
|
||||
---
|
||||
|
||||
## 10. Settled decisions
|
||||
|
||||
Both previously open questions are decided and specified above; recorded here
|
||||
with their rationale so the reasoning is not lost.
|
||||
|
||||
1. **Revocation is reported explicitly** (§3.3). A revoked peer's client
|
||||
receives a distinct status and removes the dead entry immediately. The
|
||||
alternative — silence — leaves a zombie entry, and the revocation is
|
||||
inferable from the failure anyway, so the deniability is illusory.
|
||||
Indistinguishable silence is reserved for the hard block list, where
|
||||
undetectability is the point.
|
||||
2. **Grants expire on idleness, renewed on use, defaulting to 90 days** (§3.5),
|
||||
configurable to 30 / 90 / 365 days or never. Relationships in regular use
|
||||
never lapse; forgotten ones clean themselves up, which bounds the blast
|
||||
radius of a stale pairing and quietly disposes of entries orphaned by a
|
||||
reinstall.
|
||||
|
||||
---
|
||||
|
||||
## 11. Delivery when the recipient is not running
|
||||
|
||||
An offer is a live connection to a running app. This section states plainly what
|
||||
that costs and how far it is mitigated.
|
||||
|
||||
### 11.1 The constraint
|
||||
|
||||
Notifying the user is not the problem — `LocalNotificationService` and the
|
||||
existing `ApprovalCoordinator` already turn an incoming approval request into a
|
||||
user-visible prompt, and an incoming offer reuses that path unchanged.
|
||||
|
||||
*Receiving* the request is the problem. `BackgroundActivityController` holds an
|
||||
iOS background assertion only while there is active work and releases it as soon
|
||||
as that drains, so a suspended app has no listening socket: the sender's dial
|
||||
fails and there is nothing to notify about.
|
||||
|
||||
Waking a suspended iOS app from the network requires a remote push through APNs,
|
||||
which means a server holding device tokens and observing who contacts whom. That
|
||||
is infrastructure plus a metadata leak, both of which contradict the product's
|
||||
no-cloud posture. **APNs is out of scope.** (This is also why AirDrop can do it
|
||||
and a third-party app cannot: AirDrop is an OS daemon, not an app.)
|
||||
|
||||
### 11.2 Sender-held offers with a foreground pull
|
||||
|
||||
When the target is unreachable, the sender holds the offer **locally** — the
|
||||
share stays on the sender's disk exactly as today, with no copy anywhere else —
|
||||
and the receiver collects it when its app next comes to the foreground, raising
|
||||
a local notification at that point.
|
||||
|
||||
Resulting coverage:
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| Phone → always-on desktop | Immediate; the desktop is listening |
|
||||
| Desktop → phone, app closed | Delivered on the phone's next launch |
|
||||
| Phone → phone, both apps closed | **Not supported** |
|
||||
|
||||
Desktop platforms are unaffected by any of this and are always reachable while
|
||||
the app runs.
|
||||
|
||||
### 11.3 The presence cost of pulling
|
||||
|
||||
Dialing contacts on launch tells them when the app was opened and reveals the
|
||||
device's address to them — precisely the leak §1 avoids by refusing background
|
||||
presence polling. The pull is therefore bounded rather than automatic:
|
||||
|
||||
- It is **off by default**, behind a single setting whose own footer states the
|
||||
cost, plus an explicit "Check now" action that works regardless.
|
||||
- It never runs in the background, only on an actual foreground transition.
|
||||
- It is rate-limited per contact (5 minutes), so repeated app switching does not
|
||||
turn into a presence beacon.
|
||||
|
||||
**Deviation from the original draft, as built.** This specified a *per-contact*
|
||||
opt-in. What shipped is one global toggle, which is coarser: enabling it polls
|
||||
every contact rather than a chosen few. Per-contact control needs a schema
|
||||
column and a control on each device's detail screen, and the global switch with
|
||||
an honest footer covers the same threat — the user still decides whether their
|
||||
app-open times are revealed at all. Worth revisiting if anyone keeps contacts
|
||||
they would rather not signal to.
|
||||
|
||||
### 11.4 What the sender sees
|
||||
|
||||
A held offer is listed on the sender's device with its target, and withdrawing
|
||||
it is cancelling the transfer — stopping the share deletes the waiting ticket,
|
||||
so a cancelled transfer can never be collected afterwards.
|
||||
|
||||
### 11.5 Scope statement for the UI
|
||||
|
||||
Mobile-to-mobile transfer with both apps closed is not supported and must not be
|
||||
implied. The contact list distinguishes "reachable now" from "will be delivered
|
||||
when they next open VniDrop", and an offer awaiting pickup is visible and
|
||||
cancellable on the sender's side.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Deferred: local network discovery
|
||||
|
||||
An earlier draft specified AirDrop-style discovery: three visibility tiers
|
||||
(invisible / paired-only / a time-boxed pairing window), private per-grant mDNS
|
||||
beacons using rotating per-epoch AEAD entries so only grant holders could
|
||||
recognize a device, and a short-authentication-string pairing flow. It was
|
||||
dropped, because once first contact requires a completed transfer anyway,
|
||||
discovery adds far less than it costs.
|
||||
|
||||
**What it would have added:** camera-free pairing (QR pairing already works),
|
||||
live presence (which requires probing, and probing leaks when a user opens their
|
||||
contact list), and address resolution on a network with no public discovery —
|
||||
the only substantive one, and largely handled by the address caching in §5.
|
||||
|
||||
**What dropping it avoids:**
|
||||
|
||||
- The `com.apple.developer.networking.multicast` entitlement risk. iroh's
|
||||
local-network discovery uses raw multicast sockets rather than Bonjour, and
|
||||
that entitlement requires a special request to Apple that is frequently
|
||||
refused. This was the single largest threat to shipping.
|
||||
- Local network permission prompts on iOS/macOS, an Android multicast lock and
|
||||
`NEARBY_WIFI_DEVICES`, a Windows firewall prompt, and avahi coexistence on UDP
|
||||
5353.
|
||||
- A per-platform discovery bridge, including a native `NWBrowser`/`NWListener`
|
||||
implementation in Swift.
|
||||
- Beacon crypto, epoch/clock-skew handling, and a hard cap of roughly 24–28
|
||||
advertised contacts imposed by the mDNS packet budget.
|
||||
- A contradiction with the README's promise that the restricted relay modes
|
||||
never use "public discovery".
|
||||
- Visibility-tier settings, which are difficult to explain and easy to
|
||||
misconfigure.
|
||||
|
||||
It also *improves* the privacy posture: the app broadcasts nothing at all, which
|
||||
is a stronger and far more explainable claim than any beacon scheme, including
|
||||
in an App Store review.
|
||||
|
||||
**Network-trust detection was rejected separately and stays rejected.** Deciding
|
||||
what to expose based on whether a network looks "public" is unreliable — macOS
|
||||
has no such concept, Android needs `ACCESS_FINE_LOCATION` to read an SSID, and
|
||||
iOS cannot identify the current network at all without
|
||||
`com.apple.developer.networking.wifi-info` plus location permission. It is also
|
||||
spoofable, since an attacker can clone an SSID and choose a gateway MAC.
|
||||
|
||||
**If it is ever revisited**, the beacon scheme was deliberately keyed off grants,
|
||||
so it layers onto the tables in §6 with no change to the offer protocol or the
|
||||
data model. Nothing in this design forecloses it. One unrelated cleanup noted
|
||||
along the way: `apple/VniDrop/Resources/Info.plist:78` declares
|
||||
`NSBonjourServices` with a single empty-string entry, which is meaningless and
|
||||
should be removed or given a real service type.
|
||||
10
Makefile
@@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk
|
||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||
.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
||||
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
||||
|
||||
@@ -71,8 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings.
|
||||
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
||||
|
||||
check-release: ## Validate coordinated release scripts and workflow YAML.
|
||||
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
||||
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
||||
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
|
||||
cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh
|
||||
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
|
||||
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
|
||||
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
||||
@@ -135,7 +136,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
|
||||
apple-version-config: ## Generate derived Store and Direct Apple build settings.
|
||||
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
|
||||
|
||||
apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project.
|
||||
apple-app-config: ## Generate AppConfig.swift from the shared app.properties.
|
||||
cd $(ROOT) && apple/scripts/generate-appconfig.sh
|
||||
|
||||
apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project.
|
||||
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||
|
||||
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||
|
||||
10
README.md
@@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**.
|
||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||
Windows, and Linux
|
||||
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||
excluded
|
||||
- Optional user-submitted bug reports with transfer contents, invitations, and
|
||||
file paths excluded
|
||||
|
||||
## Privacy by design
|
||||
|
||||
- **No hosted transfer copy.** VniDrop does not upload file contents to its
|
||||
diagnostics service or a VniDrop storage bucket.
|
||||
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
|
||||
service or a VniDrop storage bucket.
|
||||
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
||||
end to end, including when a relay is needed.
|
||||
- **Local control.** Transfer history and sharing state stay on the device.
|
||||
- **Sensitive invitations.** An invitation can grant access, so it is
|
||||
deliberately excluded from product logs and diagnostics.
|
||||
deliberately excluded from product logs and bug reports.
|
||||
- **Explicit access.** Approval is required by default, and stopping a share
|
||||
removes access immediately.
|
||||
|
||||
|
||||
4
app.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
# Public, app-wide configuration shared by every platform (Apple + KMP).
|
||||
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
|
||||
# Injected into the apps at build time — never hardcode these values in app code.
|
||||
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/
|
||||
@@ -1,43 +0,0 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// Core/UI Swift sources built as a library so the shared logic can be typechecked
|
||||
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
|
||||
// Xcode project links the same sources plus the app entry point.
|
||||
let package = Package(
|
||||
name: "VniDropApp",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
.library(name: "VniDropApp", targets: ["VniDropApp"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "VnidropCore"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "VniDropApp",
|
||||
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
|
||||
path: "VniDrop",
|
||||
// The @main entry belongs to the Xcode app target only; excluding it
|
||||
// keeps this library free of a conflicting `_main` symbol for tests.
|
||||
exclude: ["Resources", "App/VniDropApp.swift"],
|
||||
// The Rust core (iroh network stack) links these system libraries. The
|
||||
// Xcode app target must add the same frameworks under "Link Binary With
|
||||
// Libraries" (SystemConfiguration, Security, libresolv).
|
||||
linkerSettings: [
|
||||
.linkedFramework("SystemConfiguration"),
|
||||
.linkedFramework("Security"),
|
||||
.linkedLibrary("resolv"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "VniDropAppTests",
|
||||
dependencies: ["VniDropApp"],
|
||||
path: "Tests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -18,9 +18,8 @@ apple/
|
||||
UI/Theme|Components|Navigation|Feedback|Shell/
|
||||
Platform/ # pickers, QR, NFC, share/export, per-OS file services
|
||||
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
|
||||
Tests/ # XCTest (ported progress-derivation assertions)
|
||||
Package.swift # builds VniDrop/ as a library for CLI build/test
|
||||
project.yml # XcodeGen spec for the iOS/macOS app target
|
||||
Tests/ # XCTest bundle (VniDropTests target)
|
||||
project.yml # XcodeGen spec for the iOS/macOS app and test targets
|
||||
```
|
||||
|
||||
## Build & run
|
||||
@@ -72,19 +71,22 @@ opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
|
||||
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
||||
the development team.
|
||||
|
||||
## Command-line typecheck & tests
|
||||
## Typecheck & tests
|
||||
|
||||
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
||||
so the shared logic can be checked and unit-tested without Xcode:
|
||||
The Xcode project is the only build definition: it owns the UI, its package
|
||||
dependencies, and the `VniDropTests` bundle (module `VniDrop`, which is what the
|
||||
tests import). Everything runs through `xcodebuild`:
|
||||
|
||||
```bash
|
||||
cd apple
|
||||
swift build # macOS
|
||||
swift test # runs Tests/ (ported progress-derivation assertions)
|
||||
# iOS typecheck:
|
||||
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
make check-apple # iOS simulator unit tests
|
||||
make build-apple-macos # unsigned macOS build (typecheck)
|
||||
```
|
||||
|
||||
There is deliberately no SwiftPM manifest for the app. A second build definition
|
||||
would duplicate the target's package dependencies, and the previous one had
|
||||
already drifted out of sync with `project.yml` badly enough that neither
|
||||
`swift build` nor `swift test` worked.
|
||||
|
||||
## Generated / ignored artifacts
|
||||
|
||||
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
|
||||
@@ -106,15 +108,14 @@ Rust crate itself is never changed.
|
||||
## System frameworks
|
||||
|
||||
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
||||
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
|
||||
`project.yml` (for the app target).
|
||||
`libresolv`. These are declared in `project.yml` for the app target.
|
||||
|
||||
## Parity & scope
|
||||
|
||||
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
||||
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
||||
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
||||
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
|
||||
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
|
||||
hides the diagnostics toggle when not compiled in.
|
||||
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
|
||||
a real transport lands in a later phase. There is no telemetry or crash
|
||||
auto-reporting.
|
||||
```
|
||||
|
||||
39
apple/Tests/AppConfigTests.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
|
||||
/// exposes the expected, well-formed values to the app.
|
||||
final class AppConfigTests: XCTestCase {
|
||||
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
|
||||
let url = AppConfig.privacyPolicyURL
|
||||
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
|
||||
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
|
||||
}
|
||||
|
||||
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
|
||||
// Cross-check the generated constant against the single source of truth so a
|
||||
// broken generator (or drift) is caught, not just a hardcoded copy.
|
||||
let expected = try Self.privacyURLFromAppProperties()
|
||||
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
|
||||
}
|
||||
|
||||
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
|
||||
/// from this source file's location to the repository root.
|
||||
private static func privacyURLFromAppProperties() throws -> String {
|
||||
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
for _ in 0..<8 {
|
||||
let candidate = dir.appendingPathComponent("app.properties")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let contents = try String(contentsOf: candidate, encoding: .utf8)
|
||||
for line in contents.split(whereSeparator: \.isNewline) {
|
||||
if line.hasPrefix("PRIVACY_POLICY_URL=") {
|
||||
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
|
||||
}
|
||||
}
|
||||
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
|
||||
}
|
||||
dir.deleteLastPathComponent()
|
||||
}
|
||||
throw XCTSkip("app.properties not found from \(#filePath)")
|
||||
}
|
||||
}
|
||||
640
apple/Tests/ContactsModelTests.swift
Normal file
@@ -0,0 +1,640 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
@MainActor
|
||||
final class ContactsModelTests: XCTestCase {
|
||||
private func makeModel(
|
||||
_ gateway: FakeCoreGateway
|
||||
) -> (ContactsModel, AppPreferencesRepository) {
|
||||
let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(
|
||||
kind: .fileSystemPath,
|
||||
value: "/tmp",
|
||||
displayName: "Downloads"
|
||||
),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
return (model, preferences)
|
||||
}
|
||||
|
||||
private func contact(
|
||||
_ endpointId: String,
|
||||
label: String? = nil,
|
||||
remoteName: String? = nil,
|
||||
canSend: Bool = true
|
||||
) -> DeviceContact {
|
||||
DeviceContact(
|
||||
endpointId: endpointId,
|
||||
localLabel: label,
|
||||
remoteDisplayName: remoteName,
|
||||
lastTransferAt: nil,
|
||||
createdAt: 0,
|
||||
canSend: canSend
|
||||
)
|
||||
}
|
||||
|
||||
private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel {
|
||||
IncomingOfferModel(
|
||||
offerId: offerId,
|
||||
fromEndpointId: endpointId,
|
||||
senderDisplayName: "Peer",
|
||||
transferName: "photos",
|
||||
fileCount: 2,
|
||||
totalBytes: 1_024,
|
||||
receivedAt: 0
|
||||
)
|
||||
}
|
||||
|
||||
func testRefreshLoadsContactsBlocksAndPrompts() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("a"), contact("b")])
|
||||
gateway.blockedResult = .success(["blocked-one"])
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)]
|
||||
gateway.offers = [offer("offer-1")]
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.contacts.count, 2)
|
||||
XCTAssertEqual(model.state.blocked, ["blocked-one"])
|
||||
XCTAssertEqual(model.state.currentPairing?.endpointId, "c")
|
||||
XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1")
|
||||
XCTAssertFalse(model.state.isLoading)
|
||||
}
|
||||
|
||||
/// Accepting an offer is the only path that yields a ticket; the caller needs
|
||||
/// it to run the receive with its own destination.
|
||||
func testAcceptingAnOfferReturnsTheTicket() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offers = [offer("offer-1")]
|
||||
gateway.offerTicket = "vnd1:abc"
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true)
|
||||
|
||||
XCTAssertEqual(ticket, "vnd1:abc")
|
||||
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
|
||||
}
|
||||
|
||||
func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offers = [offer("offer-1")]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false)
|
||||
|
||||
XCTAssertNil(ticket, "a declined offer must not hand over a capability")
|
||||
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||
}
|
||||
|
||||
/// Declining to be remembered must leave nothing behind for the peer.
|
||||
func testDecliningPairingClearsThePromptWithoutAddingAContact() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
await model.respondToPairing(endpointId: "peer", accepted: false)
|
||||
|
||||
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||
XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false])
|
||||
}
|
||||
|
||||
func testAcceptingPairingAddsTheContact() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")])
|
||||
|
||||
await model.respondToPairing(endpointId: "peer", accepted: true)
|
||||
|
||||
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||
XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"])
|
||||
}
|
||||
|
||||
func testForgettingClearsTheSelectionAndReloads() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer")])
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
model.select("peer")
|
||||
|
||||
gateway.contactsResult = .success([])
|
||||
await model.forget(endpointId: "peer")
|
||||
|
||||
XCTAssertEqual(gateway.forgottenContacts, ["peer"])
|
||||
XCTAssertNil(model.state.selectedEndpointId)
|
||||
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||
}
|
||||
|
||||
func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer")])
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
model.select("peer")
|
||||
|
||||
gateway.contactsResult = .success([])
|
||||
gateway.blockedResult = .success(["peer"])
|
||||
await model.block(endpointId: "peer")
|
||||
|
||||
XCTAssertEqual(gateway.blockedContactIds, ["peer"])
|
||||
XCTAssertNil(model.state.selectedEndpointId)
|
||||
XCTAssertEqual(model.state.blocked, ["peer"])
|
||||
}
|
||||
|
||||
/// An empty label clears the override rather than storing whitespace, so the
|
||||
/// row falls back to the name the device reports.
|
||||
func testBlankLabelClearsTheLocalName() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.setLabel(endpointId: "peer", label: " ")
|
||||
|
||||
XCTAssertEqual(gateway.contactLabels.count, 1)
|
||||
XCTAssertNil(gateway.contactLabels[0].label)
|
||||
}
|
||||
|
||||
func testLabelIsTrimmedBeforeStoring() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.setLabel(endpointId: "peer", label: " Work Mac ")
|
||||
|
||||
XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac")
|
||||
}
|
||||
|
||||
/// The core holds the lifetime in memory only, so the stored preference is
|
||||
/// the durable copy and both have to move together.
|
||||
func testGrantLifetimeIsPersistedAndPushedToTheCore() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway)
|
||||
|
||||
model.setGrantLifetime(.days365)
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.grantLifetime, .days365)
|
||||
XCTAssertEqual(preferences.preferences.grantLifetime, .days365)
|
||||
XCTAssertEqual(gateway.grantLifetimes.last, .days365)
|
||||
}
|
||||
|
||||
func testDefaultGrantLifetimeIsNinetyDays() {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
XCTAssertEqual(model.state.grantLifetime, .days90)
|
||||
}
|
||||
|
||||
/// The local label wins over whatever the peer calls itself.
|
||||
func testDisplayNamePrefersTheLocalLabel() {
|
||||
let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil")
|
||||
|
||||
XCTAssertEqual(subject.displayName, "Work Mac")
|
||||
}
|
||||
|
||||
func testDisplayNameFallsBackToTheReportedName() {
|
||||
let subject = contact("peer", remoteName: "Laptop")
|
||||
|
||||
XCTAssertEqual(subject.displayName, "Laptop")
|
||||
}
|
||||
|
||||
/// Files picked for a device go out as an offer, never as an invitation
|
||||
/// anyone holding the ticket could use.
|
||||
func testSendingToAContactUsesTheContactDestination() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
XCTAssertTrue(model.pendingFilePick)
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertEqual(files.shareDestinations, [.contact(endpointId: "peer")])
|
||||
XCTAssertEqual(gateway.sentToContacts, ["peer"])
|
||||
}
|
||||
|
||||
/// A pick that arrives with no target must not be sent anywhere.
|
||||
func testPickedFilesWithoutATargetAreIgnored() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertTrue(files.shareDestinations.isEmpty)
|
||||
XCTAssertTrue(gateway.sentToContacts.isEmpty)
|
||||
}
|
||||
|
||||
/// Polling is opt-in: it tells every contact the app was opened.
|
||||
func testForegroundCheckIsSkippedUnlessEnabled() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.checkForOffersOnForeground()
|
||||
|
||||
XCTAssertEqual(gateway.pollCount, 0)
|
||||
}
|
||||
|
||||
func testForegroundCheckRunsOnceEnabled() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway)
|
||||
|
||||
model.setCheckForOffersOnOpen(true)
|
||||
await model.checkForOffersOnForeground()
|
||||
|
||||
XCTAssertEqual(gateway.pollCount, 1)
|
||||
XCTAssertTrue(preferences.preferences.checkForOffersOnOpen)
|
||||
}
|
||||
|
||||
/// The explicit "check now" ignores the setting: the user just asked.
|
||||
func testExplicitCheckRunsEvenWhenTheSettingIsOff() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pollResult = .success(2)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let collected = await model.collectWaitingOffers()
|
||||
|
||||
XCTAssertEqual(collected, 2)
|
||||
XCTAssertEqual(gateway.pollCount, 1)
|
||||
}
|
||||
|
||||
/// A transfer that could not be delivered is reported as waiting, not as a
|
||||
/// success nobody has received.
|
||||
func testAnUndeliveredSendIsReportedAsWaiting() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-held-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: false
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testHeldOffersAreLoadedForDisplay() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.heldOffersResult = .success([
|
||||
HeldOfferModel(
|
||||
offerId: "held-1",
|
||||
endpointId: "peer",
|
||||
transferId: 1,
|
||||
transferName: "doc",
|
||||
fileCount: 1,
|
||||
totalBytes: 2,
|
||||
createdAt: 0
|
||||
)
|
||||
])
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
|
||||
}
|
||||
|
||||
/// Offering an existing transfer reuses it rather than creating another.
|
||||
func testOfferingAnExistingTransferReportsAcceptance() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertTrue(delivered)
|
||||
XCTAssertEqual(gateway.offeredTransfers.map(\.transferId), [7])
|
||||
XCTAssertEqual(gateway.offeredTransfers.map(\.endpointId), ["peer"])
|
||||
}
|
||||
|
||||
/// An offer to a closed device is reported as waiting, not accepted.
|
||||
func testOfferingToAClosedDeviceReportsItAsWaiting() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: false
|
||||
)
|
||||
)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
}
|
||||
|
||||
/// A refusal by the person on the other device is information, not an error.
|
||||
func testADeclinedOfferIsReportedWithoutAnErrorTone() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .failure(
|
||||
InvitationError.raw("permission error: device did not accept the transfer: receiver-declined")
|
||||
)
|
||||
let defaults = UserDefaults(suiteName: "contacts-declined-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post-transfer suggestions
|
||||
|
||||
@MainActor
|
||||
final class PairingSuggestionTests: XCTestCase {
|
||||
private func makeModel(
|
||||
_ gateway: FakeCoreGateway,
|
||||
defaults: UserDefaults
|
||||
) -> (ContactsModel, AppPreferencesRepository) {
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(
|
||||
kind: .fileSystemPath,
|
||||
value: "/tmp",
|
||||
displayName: "Downloads"
|
||||
),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
return (model, preferences)
|
||||
}
|
||||
|
||||
private func newDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
|
||||
}
|
||||
|
||||
private func completedReceive(from peerId: String?) -> Transfer {
|
||||
Transfer(
|
||||
localId: "local-1",
|
||||
transferId: 1,
|
||||
direction: .receive,
|
||||
status: .done,
|
||||
peerId: peerId,
|
||||
transferName: "photos",
|
||||
contentHash: nil,
|
||||
fileCount: 1,
|
||||
totalSize: 10,
|
||||
ticket: nil,
|
||||
accessPolicy: .requireApproval,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
)
|
||||
}
|
||||
|
||||
private func state(with transfers: [Transfer]) -> CoreState {
|
||||
var core = CoreState()
|
||||
core.isInitialized = true
|
||||
core.transfers = transfers
|
||||
return core
|
||||
}
|
||||
|
||||
func testCompletedReceiveSuggestsItsSender() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
|
||||
}
|
||||
|
||||
/// A transfer that never recorded a peer cannot be turned into a suggestion.
|
||||
func testReceiveWithoutAPeerIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: nil)]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testAlreadyRememberedDeviceIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([
|
||||
DeviceContact(
|
||||
endpointId: "sender-endpoint",
|
||||
localLabel: nil,
|
||||
remoteDisplayName: nil,
|
||||
lastTransferAt: nil,
|
||||
createdAt: 0,
|
||||
canSend: true
|
||||
)
|
||||
])
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testBlockedDeviceIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.blockedResult = .success(["sender-endpoint"])
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
/// Declining has to stick, or every later transfer with the same device
|
||||
/// re-asks the question the user already answered.
|
||||
func testDecliningIsRememberedAcrossLaterTransfers() async {
|
||||
let defaults = newDefaults()
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||
await model.refresh()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||
|
||||
model.declineSuggestion(suggestion!)
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
|
||||
// A second transfer with the same device must stay silent.
|
||||
gateway.setState(CoreState())
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||
|
||||
await model.acceptSuggestion(suggestion!)
|
||||
|
||||
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
|
||||
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
/// Pairing deliberately after declining should work, so the decline is
|
||||
/// cleared rather than blocking the device forever.
|
||||
func testAcceptingClearsAnEarlierDecline() async {
|
||||
let defaults = newDefaults()
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||
let suggestion = PairingSuggestion(
|
||||
endpointId: "sender-endpoint",
|
||||
displayName: nil,
|
||||
transferName: nil
|
||||
)
|
||||
model.declineSuggestion(suggestion)
|
||||
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
|
||||
await model.acceptSuggestion(suggestion)
|
||||
|
||||
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
}
|
||||
|
||||
func testTheSameDeviceIsOnlySuggestedOnce() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.suggestions.count, 1)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,98 @@ final class FakeCoreGateway: CoreGateway {
|
||||
return responseResult
|
||||
}
|
||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||
|
||||
// MARK: Device history
|
||||
|
||||
var contactsResult: Result<[DeviceContact], Error> = .success([])
|
||||
var pairings: [PendingPairingModel] = []
|
||||
var offers: [IncomingOfferModel] = []
|
||||
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||
/// Ticket handed back when an offer is accepted; nil models a declined one.
|
||||
var offerTicket: String? = "vnd1:offered"
|
||||
var sendToContactResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||
var heldOffersResult: Result<[HeldOfferModel], Error> = .success([])
|
||||
var pollResult: Result<UInt64, Error> = .success(0)
|
||||
private(set) var pollCount = 0
|
||||
var forgetContactResult: Result<Void, Error> = .success(())
|
||||
var blockedResult: Result<[String], Error> = .success([])
|
||||
|
||||
private(set) var allowedDevices: [(endpointId: String, displayName: String?)] = []
|
||||
private(set) var pairingResponses: [(endpointId: String, accepted: Bool)] = []
|
||||
private(set) var offerResponses: [(offerId: String, accepted: Bool)] = []
|
||||
private(set) var forgottenContacts: [String] = []
|
||||
private(set) var forgetAllCount = 0
|
||||
private(set) var blockedContactIds: [String] = []
|
||||
private(set) var unblockedContactIds: [String] = []
|
||||
private(set) var contactLabels: [(endpointId: String, label: String?)] = []
|
||||
private(set) var grantLifetimes: [GrantLifetimeOption] = []
|
||||
private(set) var sentToContacts: [String] = []
|
||||
|
||||
func contacts() async -> Result<[DeviceContact], Error> { contactsResult }
|
||||
func pendingPairings() async -> [PendingPairingModel] { pairings }
|
||||
func pendingOffers() async -> [IncomingOfferModel] { offers }
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||
allowedDevices.append((endpointId, displayName))
|
||||
return .success(())
|
||||
}
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
pairingResponses.append((endpointId, accepted))
|
||||
if case .success = respondToPairingResult {
|
||||
pairings.removeAll { $0.endpointId == endpointId }
|
||||
}
|
||||
return respondToPairingResult
|
||||
}
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
offerResponses.append((offerId, accepted))
|
||||
offers.removeAll { $0.offerId == offerId }
|
||||
return accepted ? offerTicket : nil
|
||||
}
|
||||
func sendToContact(
|
||||
endpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
sentToContacts.append(endpointId)
|
||||
return sendToContactResult
|
||||
}
|
||||
private(set) var offeredTransfers: [(transferId: UInt64, endpointId: String)] = []
|
||||
var offerTransferResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
offeredTransfers.append((transferId, endpointId))
|
||||
return offerTransferResult
|
||||
}
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||
pollCount += 1
|
||||
return pollResult
|
||||
}
|
||||
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||
forgottenContacts.append(endpointId)
|
||||
return forgetContactResult
|
||||
}
|
||||
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||
forgetAllCount += 1
|
||||
return .success(0)
|
||||
}
|
||||
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
blockedContactIds.append(endpointId)
|
||||
return .success(())
|
||||
}
|
||||
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
unblockedContactIds.append(endpointId)
|
||||
return .success(())
|
||||
}
|
||||
func blockedContacts() async -> Result<[String], Error> { blockedResult }
|
||||
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
contactLabels.append((endpointId, label))
|
||||
return .success(())
|
||||
}
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { grantLifetimes.append(lifetime) }
|
||||
}
|
||||
|
||||
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
||||
@@ -92,8 +184,21 @@ final class FakeFileSystemService: FileSystemService {
|
||||
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
||||
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
|
||||
private(set) var shareDestinations: [ShareDestination] = []
|
||||
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> {
|
||||
shareDestinations.append(destination)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
|
||||
preferences: preferences,
|
||||
notifications: LocalNotificationService(),
|
||||
messages: UiMessageController(),
|
||||
bugReports: NoopBugReportService(),
|
||||
diagnosticsIncluded: false
|
||||
bugReports: NoopBugReportService()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ final class AppGraph: ObservableObject {
|
||||
let preferencesRepository: AppPreferencesRepository
|
||||
let filePreviewRepository: FilePreviewRepository
|
||||
let approvalCoordinator: ApprovalCoordinator
|
||||
let contactsModel: ContactsModel
|
||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||
let backgroundActivity: BackgroundActivityController
|
||||
|
||||
@@ -24,10 +25,15 @@ final class AppGraph: ObservableObject {
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: dependencies.environment.defaultUsername,
|
||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode: .system,
|
||||
diagnosticsEnabled: false
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
self.contactsModel = ContactsModel(
|
||||
repository: coreRepository,
|
||||
messages: messages,
|
||||
preferences: preferencesRepository,
|
||||
fileSystemService: dependencies.fileSystemService
|
||||
)
|
||||
self.approvalCoordinator = ApprovalCoordinator(
|
||||
repository: coreRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
|
||||
@@ -60,6 +60,11 @@ struct RootView: View {
|
||||
approvals: graph.approvalCoordinator,
|
||||
sendModel: sendModel
|
||||
)
|
||||
ContactPromptLayer(
|
||||
contacts: graph.contactsModel,
|
||||
receiveModel: receiveModel,
|
||||
approvals: graph.approvalCoordinator
|
||||
)
|
||||
// Top-most so the toast is never covered by the approval overlay's
|
||||
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||
SnackbarHost(controller: graph.messages)
|
||||
@@ -88,6 +93,9 @@ struct RootView: View {
|
||||
// unfocused/occluded (common on macOS) live events may not have
|
||||
// rendered, leaving progress/status stale.
|
||||
Task { _ = await graph.coreRepository.refresh() }
|
||||
// Opt-in and foreground-only: collecting transfers held for this
|
||||
// device also tells every contact that the app was opened.
|
||||
Task { await graph.contactsModel.checkForOffersOnForeground() }
|
||||
case .background:
|
||||
graph.visibility.setForeground(false)
|
||||
// Hold the process open for iOS's grace window so an active
|
||||
@@ -165,9 +173,10 @@ struct RootView: View {
|
||||
@ViewBuilder
|
||||
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||
switch destination {
|
||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||
case .settings:
|
||||
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,3 +292,57 @@ import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
|
||||
///
|
||||
/// Separate from the approval layer because the two never compete: an approval
|
||||
/// belongs to a transfer this device is sending, and these belong to a device
|
||||
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||
/// never answering two modals at once.
|
||||
private struct ContactPromptLayer: View {
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let receiveModel: ReceiveModel
|
||||
@ObservedObject var approvals: ApprovalCoordinator
|
||||
|
||||
@State private var showPrompt = false
|
||||
|
||||
var body: some View {
|
||||
ContactPromptHost(
|
||||
isPresented: $showPrompt,
|
||||
state: contacts.state,
|
||||
onPairingResponse: { endpointId, accepted in
|
||||
Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) }
|
||||
},
|
||||
onOfferResponse: { offerId, accepted in
|
||||
Task {
|
||||
// The ticket is released only on acceptance; the receive then
|
||||
// runs through the ordinary path so the platform picks the
|
||||
// destination.
|
||||
if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) {
|
||||
receiveModel.receiveOffered(ticket: ticket)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuggestionResponse: { suggestion, accepted in
|
||||
if accepted {
|
||||
Task { await contacts.acceptSuggestion(suggestion) }
|
||||
} else {
|
||||
contacts.declineSuggestion(suggestion)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onChange(of: promptKey) { _, key in
|
||||
showPrompt = key != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One identity for "is there something to answer", so an offer replacing a
|
||||
/// pairing prompt re-presents rather than silently swapping content.
|
||||
private var promptKey: String? {
|
||||
guard approvals.state.current == nil else { return nil }
|
||||
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
||||
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
||||
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,16 +120,22 @@ struct AppPreferences: Equatable {
|
||||
var username: String
|
||||
var receiveFolder: ReceiveFolder
|
||||
var themeMode: ThemeMode
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
var relayConfiguration: RelayConfiguration
|
||||
/// Idle lifetime applied to grants this device issues from now on.
|
||||
var grantLifetime: GrantLifetimeOption
|
||||
/// Devices the user declined to remember. Persisted so a repeat transfer
|
||||
/// with the same device does not re-ask forever.
|
||||
var declinedPairingSuggestions: Set<String>
|
||||
/// Whether opening the app asks remembered devices for waiting transfers.
|
||||
/// Off by default: it reveals app-open times to every contact.
|
||||
var checkForOffersOnOpen: Bool
|
||||
}
|
||||
|
||||
struct AppPreferencesDefaults {
|
||||
let username: String
|
||||
let receiveFolder: ReceiveFolder
|
||||
let themeMode: ThemeMode
|
||||
var diagnosticsEnabled: Bool = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -145,9 +151,11 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let receiveFolderValue = "receive_folder_value"
|
||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||
static let themeMode = "theme_mode"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
static let grantLifetime = "grant_lifetime"
|
||||
static let declinedPairingSuggestions = "declined_pairing_suggestions"
|
||||
static let checkForOffersOnOpen = "check_for_offers_on_open"
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||
@@ -160,15 +168,19 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
|
||||
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
|
||||
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
|
||||
return AppPreferences(
|
||||
username: username,
|
||||
receiveFolder: folder,
|
||||
themeMode: themeMode,
|
||||
diagnosticsEnabled: diagnostics,
|
||||
diagnosticsInstallId: installId,
|
||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||
relayConfiguration: resolveRelayConfiguration(defaults),
|
||||
grantLifetime: grantLifetime,
|
||||
declinedPairingSuggestions: declined,
|
||||
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,13 +226,34 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
setReceiveFolder(fallback.receiveFolder)
|
||||
}
|
||||
|
||||
func setThemeMode(_ mode: ThemeMode) {
|
||||
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||
func declinePairingSuggestion(_ endpointId: String) {
|
||||
var declined = preferences.declinedPairingSuggestions
|
||||
declined.insert(endpointId)
|
||||
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||
/// Clears the decline so the device can be suggested again, used when the
|
||||
/// user pairs with it deliberately.
|
||||
func clearDeclinedPairingSuggestion(_ endpointId: String) {
|
||||
var declined = preferences.declinedPairingSuggestions
|
||||
guard declined.remove(endpointId) != nil else { return }
|
||||
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setThemeMode(_ mode: ThemeMode) {
|
||||
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
@@ -47,4 +47,42 @@ protocol CoreGateway: AnyObject {
|
||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
||||
func refresh() async -> Result<Void, Error>
|
||||
|
||||
// MARK: Device history
|
||||
|
||||
func contacts() async -> Result<[DeviceContact], Error>
|
||||
func pendingPairings() async -> [PendingPairingModel]
|
||||
func pendingOffers() async -> [IncomingOfferModel]
|
||||
/// Hand a device a revocable capability to reach this one.
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error>
|
||||
/// Accept or decline a device's offer to be remembered.
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error>
|
||||
/// Answer an incoming offer. Returns the ticket on acceptance, which the
|
||||
/// caller passes to `receive` with a platform-appropriate destination.
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String?
|
||||
func sendToContact(
|
||||
endpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
/// Offer an existing share to a remembered device, alongside its QR code.
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error>
|
||||
/// Ask remembered devices whether they hold anything for this one.
|
||||
///
|
||||
/// Only ever called from a foreground transition or an explicit user action:
|
||||
/// it reveals to every contact that this device is awake.
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error>
|
||||
func forgetContact(endpointId: String) async -> Result<Void, Error>
|
||||
func forgetAllContacts() async -> Result<UInt64, Error>
|
||||
func blockContact(endpointId: String) async -> Result<Void, Error>
|
||||
func unblockContact(endpointId: String) async -> Result<Void, Error>
|
||||
func blockedContacts() async -> Result<[String], Error>
|
||||
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error>
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ enum ShareAccessPolicy: Equatable, Sendable {
|
||||
case anyoneWithTransfer
|
||||
}
|
||||
|
||||
/// Where a picked selection is going.
|
||||
///
|
||||
/// A contact destination deliberately carries no access policy: the core forces
|
||||
/// approval-required for offers, so exposing the choice here would imply a
|
||||
/// setting that does not exist.
|
||||
enum ShareDestination: Equatable, Sendable {
|
||||
case invitation(accessPolicy: ShareAccessPolicy)
|
||||
case contact(endpointId: String)
|
||||
}
|
||||
|
||||
enum TransferDirection: Equatable, Sendable {
|
||||
case send
|
||||
case receive
|
||||
@@ -168,6 +178,10 @@ enum CoreSignal: Equatable, Sendable {
|
||||
case receiverHistoryChanged(transferId: UInt64)
|
||||
/// Transfer status/history changed enough to re-read the durable snapshot.
|
||||
case transfersChanged(transferId: UInt64)
|
||||
/// Device history changed: a contact was added, forgotten, or blocked.
|
||||
case contactsChanged
|
||||
/// An incoming offer arrived or was answered.
|
||||
case offersChanged
|
||||
}
|
||||
|
||||
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
||||
@@ -186,3 +200,112 @@ extension TransferStatus {
|
||||
self == .done || self == .failed || self == .cancelled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device history
|
||||
|
||||
/// A device the user has chosen to remember.
|
||||
///
|
||||
/// `localLabel` is the user's own name for the device and is authoritative for
|
||||
/// display; `remoteDisplayName` is whatever the device last called itself and is
|
||||
/// untrusted. The endpoint id is the only real identity.
|
||||
struct DeviceContact: Equatable, Identifiable, Sendable {
|
||||
let endpointId: String
|
||||
let localLabel: String?
|
||||
let remoteDisplayName: String?
|
||||
let lastTransferAt: Int64?
|
||||
let createdAt: Int64
|
||||
/// Whether a live grant is held. False once the peer revoked, the grant
|
||||
/// lapsed, or the peer reinstalled and lost its identity.
|
||||
let canSend: Bool
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
/// Name to show, preferring the local label the peer cannot influence.
|
||||
var displayName: String {
|
||||
if let localLabel, !localLabel.isEmpty { return localLabel }
|
||||
if let remoteDisplayName, !remoteDisplayName.isEmpty { return remoteDisplayName }
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
|
||||
/// Short prefix of the endpoint id, for telling apart devices claiming the
|
||||
/// same name.
|
||||
var shortFingerprint: String { String(endpointId.prefix(8)) }
|
||||
}
|
||||
|
||||
/// A device offering to be remembered, awaiting this user's decision.
|
||||
struct PendingPairingModel: Equatable, Identifiable, Sendable {
|
||||
let endpointId: String
|
||||
let displayName: String?
|
||||
let receivedAt: Int64
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
var resolvedName: String {
|
||||
guard let displayName, !displayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// A transfer a remembered device is offering. Carries no ticket: that is a
|
||||
/// capability and the core releases it only once the user accepts.
|
||||
struct IncomingOfferModel: Equatable, Identifiable, Sendable {
|
||||
let offerId: String
|
||||
let fromEndpointId: String
|
||||
let senderDisplayName: String?
|
||||
let transferName: String
|
||||
let fileCount: UInt64
|
||||
let totalBytes: UInt64
|
||||
let receivedAt: Int64
|
||||
|
||||
var id: String { offerId }
|
||||
|
||||
var resolvedSenderName: String {
|
||||
guard let senderDisplayName, !senderDisplayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return senderDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
/// A transfer waiting for its target device to come back online.
|
||||
struct HeldOfferModel: Equatable, Identifiable, Sendable {
|
||||
let offerId: String
|
||||
let endpointId: String
|
||||
let transferId: UInt64
|
||||
let transferName: String
|
||||
let fileCount: UInt64
|
||||
let totalBytes: UInt64
|
||||
let createdAt: Int64
|
||||
|
||||
var id: String { offerId }
|
||||
}
|
||||
|
||||
/// Outcome of sending straight to a remembered device.
|
||||
struct ContactSendOutcome: Equatable, Sendable {
|
||||
let share: Share
|
||||
/// False when the device was not running: the transfer is held locally and
|
||||
/// collected the next time that device opens the app.
|
||||
let delivered: Bool
|
||||
}
|
||||
|
||||
/// How long a remembered device stays reachable while unused. The countdown
|
||||
/// restarts on every transfer.
|
||||
enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable {
|
||||
case days30
|
||||
case days90
|
||||
case days365
|
||||
case never
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var days: Int? {
|
||||
switch self {
|
||||
case .days30: return 30
|
||||
case .days90: return 90
|
||||
case .days365: return 365
|
||||
case .never: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,122 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device history
|
||||
|
||||
func contacts() async -> Result<[DeviceContact], Error> {
|
||||
await runCore {
|
||||
try self.requireCore().listContacts().map { $0.toModel() }
|
||||
}
|
||||
}
|
||||
|
||||
func pendingPairings() async -> [PendingPairingModel] {
|
||||
let result = await runCore { try self.requireCore().listPendingPairings().map { $0.toModel() } }
|
||||
return (try? result.get()) ?? []
|
||||
}
|
||||
|
||||
func pendingOffers() async -> [IncomingOfferModel] {
|
||||
let result = await runCore { try self.requireCore().listPendingOffers().map { $0.toModel() } }
|
||||
return (try? result.get()) ?? []
|
||||
}
|
||||
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().allowDeviceToReachMe(endpointId: endpointId, displayName: displayName)
|
||||
}
|
||||
}
|
||||
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().respondToPairing(endpointId: endpointId, accepted: accepted)
|
||||
}
|
||||
}
|
||||
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
let result = await runCore {
|
||||
try self.requireCore().respondToOffer(offerId: offerId, accepted: accepted)
|
||||
}
|
||||
return (try? result.get()) ?? nil
|
||||
}
|
||||
|
||||
func sendToContact(
|
||||
endpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
return await runCore {
|
||||
// The access mode is forced to approval-required by the core for
|
||||
// offers; passing it here only keeps the metadata well-formed.
|
||||
let result = try self.requireCore().sendToContact(
|
||||
endpointId: endpointId,
|
||||
sources: sources,
|
||||
metadata: ShareMetadataInput(
|
||||
transferId: Self.nextTransferId(),
|
||||
transferName: transferName.isEmpty ? nil : transferName,
|
||||
senderName: senderName.isEmpty ? nil : senderName,
|
||||
accessMode: .approvalRequired
|
||||
)
|
||||
)
|
||||
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
await runCore {
|
||||
let result = try self.requireCore().offerTransferToContact(
|
||||
transferId: transferId, endpointId: endpointId
|
||||
)
|
||||
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error> {
|
||||
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||
await runCore { try self.requireCore().pollContactsForOffers() }
|
||||
}
|
||||
|
||||
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().forgetContact(endpointId: endpointId) }
|
||||
}
|
||||
|
||||
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||
await runCore { try self.requireCore().forgetAllContacts() }
|
||||
}
|
||||
|
||||
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().blockContact(endpointId: endpointId) }
|
||||
}
|
||||
|
||||
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().unblockContact(endpointId: endpointId) }
|
||||
}
|
||||
|
||||
func blockedContacts() async -> Result<[String], Error> {
|
||||
await runCore { try self.requireCore().listBlockedContacts() }
|
||||
}
|
||||
|
||||
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().setContactLabel(endpointId: endpointId, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async {
|
||||
_ = await runCore { try self.requireCore().setGrantLifetime(lifetime: lifetime.toNative()) }
|
||||
}
|
||||
|
||||
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||
|
||||
private func handle(event: CoreEvent) {
|
||||
@@ -302,6 +418,14 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
||||
state.events = events
|
||||
|
||||
// Contacts and offers are endpoint-scoped: they carry no transfer id, so
|
||||
// they are dispatched before the transfer-scoped handling below.
|
||||
switch model.phase {
|
||||
case "contacts": signalsSubject.send(.contactsChanged)
|
||||
case "offer": signalsSubject.send(.offersChanged)
|
||||
default: break
|
||||
}
|
||||
|
||||
guard let transferId = model.transferId else { return }
|
||||
switch model.phase {
|
||||
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||
@@ -519,3 +643,61 @@ private extension ReceiverRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContactSummary {
|
||||
func toModel() -> DeviceContact {
|
||||
DeviceContact(
|
||||
endpointId: endpointId,
|
||||
localLabel: localLabel,
|
||||
remoteDisplayName: remoteDisplayName,
|
||||
lastTransferAt: lastTransferAt,
|
||||
createdAt: createdAt,
|
||||
canSend: canSend
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PendingPairing {
|
||||
func toModel() -> PendingPairingModel {
|
||||
PendingPairingModel(endpointId: endpointId, displayName: displayName, receivedAt: receivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
extension IncomingOffer {
|
||||
func toModel() -> IncomingOfferModel {
|
||||
IncomingOfferModel(
|
||||
offerId: offerId,
|
||||
fromEndpointId: fromEndpointId,
|
||||
senderDisplayName: senderDisplayName,
|
||||
transferName: transferName,
|
||||
fileCount: fileCount,
|
||||
totalBytes: totalBytes,
|
||||
receivedAt: receivedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension HeldOfferSummary {
|
||||
func toModel() -> HeldOfferModel {
|
||||
HeldOfferModel(
|
||||
offerId: offerId,
|
||||
endpointId: endpointId,
|
||||
transferId: transferId,
|
||||
transferName: transferName,
|
||||
fileCount: fileCount,
|
||||
totalBytes: totalBytes,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension GrantLifetimeOption {
|
||||
func toNative() -> GrantLifetimeSetting {
|
||||
switch self {
|
||||
case .days30: return .days30
|
||||
case .days90: return .days90
|
||||
case .days365: return .days365
|
||||
case .never: return .never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +33,16 @@ protocol FileSystemService {
|
||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
||||
/// Releases only app-owned picker copies; never deletes original user sources.
|
||||
func discardPickedFiles(_ files: [PickedShareFile]) async
|
||||
/// Imports a picked selection, either as an invitation or straight to a
|
||||
/// remembered device. One entry point so the platform's security-scoped
|
||||
/// access handling covers both.
|
||||
func sharePickedFiles(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error>
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
}
|
||||
|
||||
extension FileSystemService {
|
||||
|
||||
185
apple/VniDrop/Features/Contacts/ContactPrompts.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Consent prompts for device history, presented as sheets like the receiver
|
||||
/// approval modal.
|
||||
///
|
||||
/// Both are dismissable by answering only. An incoming offer in particular must
|
||||
/// not be acceptable by accident, and a swipe-away would leave the sender
|
||||
/// waiting on a decision that never comes.
|
||||
struct ContactPromptHost: View {
|
||||
/// Driven by the host so a prompt is never presented while another sheet is
|
||||
/// still animating out — macOS silently drops the second one.
|
||||
@Binding var isPresented: Bool
|
||||
let state: ContactsState
|
||||
let onPairingResponse: (String, Bool) -> Void
|
||||
let onOfferResponse: (String, Bool) -> Void
|
||||
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.sheet(isPresented: $isPresented) {
|
||||
// Ordered by who is waiting: a sender is blocked on an offer, a
|
||||
// pairing request keeps until its consent window lapses, and a
|
||||
// post-transfer suggestion has nobody waiting at all.
|
||||
if let offer = state.currentOffer {
|
||||
OfferSheet(
|
||||
offer: offer,
|
||||
busy: state.busyOfferIds.contains(offer.offerId),
|
||||
onRespond: onOfferResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
} else if let pairing = state.currentPairing {
|
||||
PairingSheet(
|
||||
pairing: pairing,
|
||||
busy: state.busyEndpoints.contains(pairing.endpointId),
|
||||
onRespond: onPairingResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
} else if let suggestion = state.currentSuggestion {
|
||||
// Lowest priority: nobody is waiting on this answer, it just
|
||||
// follows a transfer that already finished.
|
||||
SuggestionSheet(
|
||||
suggestion: suggestion,
|
||||
busy: state.busyEndpoints.contains(suggestion.endpointId),
|
||||
onRespond: onSuggestionResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactPromptDetents: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
content.presentationDetents([.medium])
|
||||
#else
|
||||
content.frame(minWidth: 420, minHeight: 300)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// "A remembered device wants to send you files."
|
||||
private struct OfferSheet: View {
|
||||
let offer: IncomingOfferModel
|
||||
let busy: Bool
|
||||
let onRespond: (String, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .trayAndArrowDownFill)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Offer.title))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Offer.body(device: offer.resolvedSenderName, transferName: offer.transferName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L10n.Transfer.fileCount(count: Int(offer.fileCount)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(offer.offerId, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Offer.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(offer.offerId, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Offer.accept)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/// "This device offered to let you reach it. Remember it?"
|
||||
private struct PairingSheet: View {
|
||||
let pairing: PendingPairingModel
|
||||
let busy: Bool
|
||||
let onRespond: (String, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Pairing.requestTitle))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Pairing.requestBody(device: pairing.resolvedName))
|
||||
.multilineTextAlignment(.center)
|
||||
// Names are peer-supplied; the endpoint id is what actually identifies
|
||||
// the device.
|
||||
Text(L10n.Approval.endpointId(deviceId: pairing.endpointId))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(pairing.endpointId, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(pairing.endpointId, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.accept)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/// "You just transferred with this device. Let it reach you next time?"
|
||||
private struct SuggestionSheet: View {
|
||||
let suggestion: PairingSuggestion
|
||||
let busy: Bool
|
||||
let onRespond: (PairingSuggestion, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .clockArrowCirclepath)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Pairing.allowTitle))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(String(localized: L10n.Pairing.allowBody))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(suggestion, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(suggestion, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
451
apple/VniDrop/Features/Contacts/ContactsModel.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A device worth remembering after a completed transfer.
|
||||
///
|
||||
/// Only a suggestion: nothing is issued until the user agrees, because being
|
||||
/// reachable is a standing permission and a transfer is a one-off.
|
||||
struct PairingSuggestion: Equatable, Identifiable {
|
||||
let endpointId: String
|
||||
let displayName: String?
|
||||
let transferName: String?
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
var resolvedName: String {
|
||||
guard let displayName, !displayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsState: Equatable {
|
||||
var contacts: [DeviceContact] = []
|
||||
var blocked: [String] = []
|
||||
var pendingPairings: [PendingPairingModel] = []
|
||||
var pendingOffers: [IncomingOfferModel] = []
|
||||
var grantLifetime: GrantLifetimeOption = .days90
|
||||
var isLoading = false
|
||||
/// Endpoints with an in-flight decision, so a row can disable itself without
|
||||
/// blocking the rest of the list.
|
||||
var busyEndpoints: Set<String> = []
|
||||
var busyOfferIds: Set<String> = []
|
||||
var suggestions: [PairingSuggestion] = []
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
var heldOffers: [HeldOfferModel] = []
|
||||
var checkForOffersOnOpen = false
|
||||
var isCheckingForOffers = false
|
||||
var selectedEndpointId: String?
|
||||
|
||||
var selected: DeviceContact? {
|
||||
guard let selectedEndpointId else { return nil }
|
||||
return contacts.first { $0.endpointId == selectedEndpointId }
|
||||
}
|
||||
|
||||
/// One prompt at a time: pairing consent is a modal decision and stacking
|
||||
/// sheets on top of each other reads as a loop of dialogs.
|
||||
var currentPairing: PendingPairingModel? { pendingPairings.first }
|
||||
var currentOffer: IncomingOfferModel? { pendingOffers.first }
|
||||
var currentSuggestion: PairingSuggestion? { suggestions.first }
|
||||
}
|
||||
|
||||
/// Drives the device-history surfaces: the list, its detail, and the two
|
||||
/// consent prompts. Ported in the MVVM shape used by the other feature models.
|
||||
@MainActor
|
||||
final class ContactsModel: ObservableObject {
|
||||
@Published private(set) var state = ContactsState()
|
||||
|
||||
/// Set when the detail screen asks for a file picker; the platform picker
|
||||
/// modifier observes it, mirroring `SendModel`.
|
||||
@Published var pendingFilePick = false
|
||||
/// Device the picked files are destined for.
|
||||
@Published private(set) var sendTarget: String?
|
||||
|
||||
private let repository: CoreGateway
|
||||
private let messages: UiMessageController
|
||||
private let preferences: AppPreferencesRepository
|
||||
private let fileSystemService: FileSystemService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
messages: UiMessageController,
|
||||
preferences: AppPreferencesRepository,
|
||||
fileSystemService: FileSystemService
|
||||
) {
|
||||
self.repository = repository
|
||||
self.messages = messages
|
||||
self.preferences = preferences
|
||||
self.fileSystemService = fileSystemService
|
||||
state.grantLifetime = preferences.preferences.grantLifetime
|
||||
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
switch signal {
|
||||
case .contactsChanged:
|
||||
Task { await self.refresh() }
|
||||
case .offersChanged:
|
||||
Task { await self.refreshOffers() }
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
// A completed delivery names the device that received from us.
|
||||
Task { await self.considerSendPeers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] core in
|
||||
guard let self, core.isInitialized else { return }
|
||||
self.considerReceivePeers(core.transfers)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.map(\.isInitialized)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] isInitialized in
|
||||
guard let self, isInitialized else { return }
|
||||
// The core owns the lifetime; push the stored preference on start
|
||||
// so a restart does not silently fall back to the default.
|
||||
Task {
|
||||
await self.repository.setGrantLifetime(self.state.grantLifetime)
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func refresh() async {
|
||||
state.isLoading = true
|
||||
defer { state.isLoading = false }
|
||||
|
||||
switch await repository.contacts() {
|
||||
case .success(let contacts):
|
||||
state.contacts = contacts
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
if case .success(let blocked) = await repository.blockedContacts() {
|
||||
state.blocked = blocked
|
||||
}
|
||||
if case .success(let held) = await repository.heldOffers() {
|
||||
state.heldOffers = held
|
||||
}
|
||||
state.pendingPairings = await repository.pendingPairings()
|
||||
await refreshOffers()
|
||||
}
|
||||
|
||||
func refreshOffers() async {
|
||||
state.pendingOffers = await repository.pendingOffers()
|
||||
}
|
||||
|
||||
// MARK: - Post-transfer suggestions
|
||||
|
||||
/// A completed receive names its sender, so that device becomes a candidate.
|
||||
private func considerReceivePeers(_ transfers: [Transfer]) {
|
||||
let candidates = transfers
|
||||
.filter { $0.direction == .receive && $0.status == .done }
|
||||
.compactMap { transfer -> PairingSuggestion? in
|
||||
guard let peerId = transfer.peerId else { return nil }
|
||||
return PairingSuggestion(
|
||||
endpointId: peerId,
|
||||
displayName: nil,
|
||||
transferName: transfer.transferName
|
||||
)
|
||||
}
|
||||
add(suggestions: candidates)
|
||||
}
|
||||
|
||||
/// A completed delivery names the device we sent to.
|
||||
private func considerSendPeers(transferId: UInt64) async {
|
||||
guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
|
||||
return
|
||||
}
|
||||
let candidates = requests
|
||||
.filter { $0.status == .completed }
|
||||
.map { request in
|
||||
PairingSuggestion(
|
||||
endpointId: request.remoteEndpointId,
|
||||
displayName: request.receiverName ?? request.receiverDeviceName,
|
||||
transferName: request.transferName
|
||||
)
|
||||
}
|
||||
add(suggestions: candidates)
|
||||
}
|
||||
|
||||
/// Filters candidates down to devices actually worth asking about.
|
||||
private func add(suggestions candidates: [PairingSuggestion]) {
|
||||
let known = Set(state.contacts.map(\.endpointId))
|
||||
let blocked = Set(state.blocked)
|
||||
let declined = preferences.preferences.declinedPairingSuggestions
|
||||
let pending = Set(state.suggestions.map(\.endpointId))
|
||||
|
||||
let fresh = candidates.filter { candidate in
|
||||
!known.contains(candidate.endpointId)
|
||||
&& !blocked.contains(candidate.endpointId)
|
||||
&& !declined.contains(candidate.endpointId)
|
||||
&& !pending.contains(candidate.endpointId)
|
||||
}
|
||||
guard !fresh.isEmpty else { return }
|
||||
state.suggestions.append(contentsOf: fresh)
|
||||
}
|
||||
|
||||
/// Agree to be reachable by a suggested device.
|
||||
func acceptSuggestion(_ suggestion: PairingSuggestion) async {
|
||||
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||
preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
|
||||
await allowDeviceToReachMe(
|
||||
endpointId: suggestion.endpointId,
|
||||
displayName: preferences.preferences.username
|
||||
)
|
||||
}
|
||||
|
||||
/// Decline, and remember the decline so the next transfer does not re-ask.
|
||||
func declineSuggestion(_ suggestion: PairingSuggestion) {
|
||||
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||
preferences.declinePairingSuggestion(suggestion.endpointId)
|
||||
}
|
||||
|
||||
// MARK: - Collecting waiting transfers
|
||||
|
||||
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||
state.checkForOffersOnOpen = enabled
|
||||
preferences.setCheckForOffersOnOpen(enabled)
|
||||
}
|
||||
|
||||
/// Called when the app comes to the foreground.
|
||||
///
|
||||
/// Opt-in, because asking every contact whether they have something waiting
|
||||
/// also tells them the app was opened. Never runs in the background.
|
||||
func checkForOffersOnForeground() async {
|
||||
guard state.checkForOffersOnOpen else { return }
|
||||
_ = await collectWaitingOffers()
|
||||
}
|
||||
|
||||
/// Explicit "check now". Returns how many transfers were collected so the
|
||||
/// caller can report an empty result, which a silent refresh cannot.
|
||||
@discardableResult
|
||||
func collectWaitingOffers() async -> UInt64 {
|
||||
guard !state.isCheckingForOffers else { return 0 }
|
||||
state.isCheckingForOffers = true
|
||||
defer { state.isCheckingForOffers = false }
|
||||
|
||||
switch await repository.pollContactsForOffers() {
|
||||
case .success(let collected):
|
||||
await refreshOffers()
|
||||
return collected
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
|
||||
|
||||
// MARK: - Pairing consent
|
||||
|
||||
/// Agree to be reachable by a device, typically right after a transfer.
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.allowDeviceToReachMe(
|
||||
endpointId: endpointId,
|
||||
displayName: displayName
|
||||
) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
/// Answer a device's offer to be remembered.
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
|
||||
case .success:
|
||||
// Drop the prompt immediately: the core has already consumed it, and
|
||||
// leaving it on screen invites a second answer that does nothing.
|
||||
state.pendingPairings.removeAll { $0.endpointId == endpointId }
|
||||
if accepted { await refresh() }
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Incoming offers
|
||||
|
||||
/// Answer an incoming offer. Returns the ticket when accepted so the caller
|
||||
/// can run the receive with a platform-appropriate destination; the core
|
||||
/// releases it only on acceptance.
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
state.busyOfferIds.insert(offerId)
|
||||
defer { state.busyOfferIds.remove(offerId) }
|
||||
|
||||
let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
|
||||
state.pendingOffers.removeAll { $0.offerId == offerId }
|
||||
return ticket
|
||||
}
|
||||
|
||||
// MARK: - Sending to a device
|
||||
|
||||
/// Start choosing files to send to a remembered device.
|
||||
func chooseFilesToSend(to endpointId: String) {
|
||||
sendTarget = endpointId
|
||||
pendingFilePick = true
|
||||
}
|
||||
|
||||
func onFilePickFailed(_ reason: String) {
|
||||
sendTarget = nil
|
||||
messages.error(InvitationError.raw(reason))
|
||||
}
|
||||
|
||||
/// Send the picked selection straight to the chosen device.
|
||||
///
|
||||
/// Only the receiving user is prompted; this call returns once they have
|
||||
/// answered, so the button stays busy until then.
|
||||
func onFilesPicked(_ files: [PickedShareFile]) async {
|
||||
guard let endpointId = sendTarget else { return }
|
||||
sendTarget = nil
|
||||
guard !files.isEmpty else { return }
|
||||
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
let result = await fileSystemService.sharePickedFiles(
|
||||
repository: repository,
|
||||
files: files,
|
||||
transferName: files.count == 1 ? files[0].displayName : "",
|
||||
senderName: preferences.preferences.username,
|
||||
destination: .contact(endpointId: endpointId)
|
||||
)
|
||||
await fileSystemService.discardPickedFiles(files)
|
||||
switch result {
|
||||
case .success(let outcome):
|
||||
// A closed app is a delay, not a failure: say so rather than
|
||||
// reporting success for something nobody has received.
|
||||
let text: UiText = outcome.delivered
|
||||
? .resource(L10n.Send.transferCreated)
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
|
||||
///
|
||||
/// Owned by the model rather than a view so the request survives the picker
|
||||
/// being dismissed: the answer depends on a person at the other device.
|
||||
func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
|
||||
Task { await offerTransfer(transferId: transferId, to: contact) }
|
||||
}
|
||||
|
||||
/// Push an existing transfer to a remembered device.
|
||||
///
|
||||
/// Returns whether it landed, so the caller can distinguish "accepted" from
|
||||
/// "waiting for that device to open the app".
|
||||
@discardableResult
|
||||
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
|
||||
state.busyEndpoints.insert(contact.endpointId)
|
||||
defer { state.busyEndpoints.remove(contact.endpointId) }
|
||||
|
||||
switch await repository.offerTransferToContact(
|
||||
transferId: transferId,
|
||||
endpointId: contact.endpointId
|
||||
) {
|
||||
case .success(let outcome):
|
||||
let text: UiText = outcome.delivered
|
||||
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
return outcome.delivered
|
||||
case .failure(let error) where error.offerRefusal != nil:
|
||||
// The offer was delivered and a person said no, or nobody answered.
|
||||
// Neither is a failure of this device, so neither is shown as one.
|
||||
let text = error.offerRefusal == .declined
|
||||
? L10n.Contacts.declinedByDevice(device: contact.displayName)
|
||||
: L10n.Contacts.noAnswer(device: contact.displayName)
|
||||
messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
|
||||
await refresh()
|
||||
return false
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Management
|
||||
|
||||
func setLabel(endpointId: String, label: String) async {
|
||||
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if case .failure(let error) = await repository.setContactLabel(
|
||||
endpointId: endpointId,
|
||||
label: trimmed.isEmpty ? nil : trimmed
|
||||
) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func forget(endpointId: String) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func forgetAll() async {
|
||||
if case .failure(let error) = await repository.forgetAllContacts() {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
state.selectedEndpointId = nil
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func block(endpointId: String) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func unblock(endpointId: String) async {
|
||||
if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||
state.grantLifetime = lifetime
|
||||
preferences.setGrantLifetime(lifetime)
|
||||
Task { await repository.setGrantLifetime(lifetime) }
|
||||
}
|
||||
}
|
||||
344
apple/VniDrop/Features/Contacts/ContactsScreen.swift
Normal file
@@ -0,0 +1,344 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Device history: the remembered devices, their detail, and the block list.
|
||||
///
|
||||
/// Pushed from Settings rather than owning a tab — it is a management surface,
|
||||
/// not part of the send/receive flow.
|
||||
struct ContactsScreen: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
/// Reports an empty result, which a silent refresh cannot convey.
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(String(localized: L10n.Contacts.subtitle))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if model.state.contacts.isEmpty {
|
||||
Section {
|
||||
ContactsEmptyState()
|
||||
}
|
||||
} else {
|
||||
Section(String(localized: L10n.Contacts.title)) {
|
||||
ForEach(model.state.contacts) { contact in
|
||||
NavigationLink(value: SettingsSection.contactDetail(endpointId: contact.endpointId)) {
|
||||
ContactRow(contact: contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !model.state.heldOffers.isEmpty {
|
||||
Section(String(localized: L10n.Contacts.waitingTitle)) {
|
||||
ForEach(model.state.heldOffers) { offer in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(offer.transferName)
|
||||
Text(String(offer.endpointId.prefix(16)))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.waitingHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.state.blocked.isEmpty {
|
||||
Section(String(localized: L10n.Contacts.blockedTitle)) {
|
||||
ForEach(model.state.blocked, id: \.self) { endpointId in
|
||||
BlockedRow(endpointId: endpointId) {
|
||||
Task { await model.unblock(endpointId: endpointId) }
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.unblockHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
|
||||
|
||||
GrantLifetimeSection(model: model)
|
||||
|
||||
if !model.state.contacts.isEmpty {
|
||||
Section {
|
||||
ForgetAllButton { Task { await model.forgetAll() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.title)))
|
||||
.task { await model.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactsEmptyState: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tint)
|
||||
Text(String(localized: L10n.Contacts.emptyTitle))
|
||||
.font(.headline)
|
||||
Text(String(localized: L10n.Contacts.emptyBody))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactRow: View {
|
||||
let contact: DeviceContact
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
if contact.canSend {
|
||||
if let lastTransferAt = contact.lastTransferAt {
|
||||
Text(L10n.Contacts.lastTransfer(date: Self.format(lastTransferAt)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
// Reachability is derived from holding a live grant, so this is
|
||||
// the honest signal that sending will not work.
|
||||
Label(
|
||||
String(localized: L10n.Contacts.unreachable),
|
||||
systemSymbol: .exclamationmarkTriangleFill
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func format(_ millis: Int64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1_000)
|
||||
return date.formatted(.relative(presentation: .named))
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockedRow: View {
|
||||
let endpointId: String
|
||||
let onUnblock: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(String(endpointId.prefix(16)))
|
||||
.font(.callout.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button(String(localized: L10n.Contacts.unblock), action: onUnblock)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollectOffersSection: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: L10n.Contacts.checkOnOpen),
|
||||
isOn: Binding(
|
||||
get: { model.state.checkForOffersOnOpen },
|
||||
set: { model.setCheckForOffersOnOpen($0) }
|
||||
)
|
||||
)
|
||||
Button {
|
||||
Task {
|
||||
let collected = await model.collectWaitingOffers()
|
||||
if collected == 0 { onNothingWaiting() }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(String(localized: L10n.Contacts.checkNow))
|
||||
if model.state.isCheckingForOffers {
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.state.isCheckingForOffers)
|
||||
} footer: {
|
||||
// The privacy cost is the point of the setting, so it is stated
|
||||
// where the switch is, not buried elsewhere.
|
||||
Text(String(localized: L10n.Contacts.checkOnOpenHint))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GrantLifetimeSection: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: L10n.Contacts.grantLifetimeTitle),
|
||||
selection: Binding(
|
||||
get: { model.state.grantLifetime },
|
||||
set: { model.setGrantLifetime($0) }
|
||||
)
|
||||
) {
|
||||
ForEach(GrantLifetimeOption.allCases) { option in
|
||||
Text(Self.label(option)).tag(option)
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.grantLifetimeHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private static func label(_ option: GrantLifetimeOption) -> String {
|
||||
guard let days = option.days else {
|
||||
return String(localized: L10n.Contacts.grantLifetimeNever)
|
||||
}
|
||||
return L10n.Contacts.grantLifetimeDays(count: days)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ForgetAllButton: View {
|
||||
let onConfirm: () -> Void
|
||||
@State private var isConfirming = false
|
||||
|
||||
var body: some View {
|
||||
Button(role: .destructive) {
|
||||
isConfirming = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.forgetAll))
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.forgetAll),
|
||||
isPresented: $isConfirming,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.forgetAll), role: .destructive, action: onConfirm)
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.forgetBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail for one remembered device: rename, send, forget, block.
|
||||
struct ContactDetailScreen: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let endpointId: String
|
||||
|
||||
@State private var label = ""
|
||||
@State private var isConfirmingForget = false
|
||||
@State private var isConfirmingBlock = false
|
||||
|
||||
private var contact: DeviceContact? {
|
||||
model.state.contacts.first { $0.endpointId == endpointId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let contact {
|
||||
Section {
|
||||
TextField(
|
||||
String(localized: L10n.Contacts.nameField),
|
||||
text: $label,
|
||||
prompt: Text(contact.displayName)
|
||||
)
|
||||
.onSubmit { commitLabel() }
|
||||
Text(String(localized: L10n.Contacts.nameHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
// The endpoint id is the only real identity: two devices can
|
||||
// claim the same name, but not the same key. Shown in full
|
||||
// and selectable so it can actually be compared.
|
||||
Text(L10n.Approval.endpointId(deviceId: contact.endpointId))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if contact.canSend {
|
||||
Section {
|
||||
Button {
|
||||
model.chooseFilesToSend(to: endpointId)
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.sendTo),
|
||||
systemSymbol: .paperplane
|
||||
)
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.unreachableBody),
|
||||
systemSymbol: .exclamationmarkTriangleFill
|
||||
)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
isConfirmingForget = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.forget))
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
isConfirmingBlock = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.block))
|
||||
}
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(contact?.displayName ?? ""))
|
||||
.contactSendPickers(model: model)
|
||||
.onAppear { label = contact?.localLabel ?? "" }
|
||||
.onDisappear { commitLabel() }
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.forget),
|
||||
isPresented: $isConfirmingForget,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.forget), role: .destructive) {
|
||||
Task { await model.forget(endpointId: endpointId) }
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.forgetBody))
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.block),
|
||||
isPresented: $isConfirmingBlock,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.block), role: .destructive) {
|
||||
Task { await model.block(endpointId: endpointId) }
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.unblockHint))
|
||||
}
|
||||
}
|
||||
|
||||
private func commitLabel() {
|
||||
guard label != (contact?.localLabel ?? "") else { return }
|
||||
Task { await model.setLabel(endpointId: endpointId, label: label) }
|
||||
}
|
||||
}
|
||||
73
apple/VniDrop/Features/Contacts/DevicePickerSheet.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Picks a remembered device to send an existing transfer to.
|
||||
///
|
||||
/// Offered next to the QR code as another way to deliver the same invitation,
|
||||
/// not as a second share of the same files.
|
||||
struct DevicePickerSheet: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let transferId: UInt64
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Only devices holding a live grant: the rest cannot be reached until they
|
||||
/// are paired again, so offering them here would fail on tap.
|
||||
private var reachable: [DeviceContact] {
|
||||
model.state.contacts.filter(\.canSend)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if reachable.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.pickDeviceTitle),
|
||||
systemSymbol: .macbookAndIphone
|
||||
)
|
||||
} description: {
|
||||
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
|
||||
}
|
||||
} else {
|
||||
List(reachable) { contact in
|
||||
Button {
|
||||
// Close first. The other device's user has to accept,
|
||||
// which can take as long as they take, and holding a
|
||||
// modal open on someone else's decision reads as a
|
||||
// hang. The outcome arrives as a message instead.
|
||||
dismiss()
|
||||
model.offerTransferInBackground(transferId: transferId, to: contact)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
Text(contact.shortFingerprint)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if model.state.busyEndpoints.contains(contact.endpointId) {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(contact.endpointId))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await model.refresh() }
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380, minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
|
||||
switch signal {
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
case .approvalChanged, .contactsChanged, .offersChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ enum ReceiveMethod {
|
||||
case invitationFile
|
||||
case qrCode
|
||||
case nfc
|
||||
/// Pushed by a remembered device and already accepted by the user, so no
|
||||
/// invitation was acquired by hand.
|
||||
case offer
|
||||
}
|
||||
|
||||
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||
@@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a transfer the user has already accepted in the offer prompt.
|
||||
///
|
||||
/// The consent happened in that prompt, so this does not ask again: it
|
||||
/// inspects the ticket and starts, falling back to the ordinary review sheet
|
||||
/// only when the destination is not usable and the user has to fix it.
|
||||
func receiveOffered(ticket: String) {
|
||||
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||
state.ticket = trimmed
|
||||
state.method = .offer
|
||||
state.inspection = nil
|
||||
state.isInspecting = true
|
||||
Task {
|
||||
switch await repository.inspectTicket(trimmed) {
|
||||
case .success(let inspection):
|
||||
state.inspection = inspection
|
||||
state.isInspecting = false
|
||||
if state.canReceive(coreInitialized: coreState.isInitialized) {
|
||||
receive()
|
||||
} else {
|
||||
// Usually a missing or unwritable destination: show the review
|
||||
// sheet so the user can point it somewhere valid.
|
||||
state.isAcquisitionOpen = true
|
||||
}
|
||||
case .failure(let error):
|
||||
state.ticket = ""
|
||||
state.method = nil
|
||||
state.inspection = nil
|
||||
state.isInspecting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func receive() {
|
||||
let current = state
|
||||
guard let folder = current.receiveFolder else { return }
|
||||
|
||||
@@ -96,6 +96,8 @@ final class SendModel: ObservableObject {
|
||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||
self.refreshReceiverStatuses(for: id)
|
||||
case .contactsChanged, .offersChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -351,9 +353,9 @@ final class SendModel: ObservableObject {
|
||||
files: current.selectedFiles,
|
||||
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
accessPolicy: current.accessPolicy
|
||||
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||
)
|
||||
switch result {
|
||||
switch result.map(\.share) {
|
||||
case .success(let share):
|
||||
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
||||
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
||||
|
||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||
struct SendScreen: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
/// Transfer pending an inline (list-level) delete confirmation.
|
||||
@@ -60,7 +61,7 @@ struct SendScreen: View {
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let shareTarget {
|
||||
TransferSharePanel(model: model, transfer: shareTarget)
|
||||
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +94,7 @@ struct SendScreen: View {
|
||||
/// alert attached here so they present from the detail's own context (presenting
|
||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||
private func detailView(for transfer: Transfer) -> some View {
|
||||
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
|
||||
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||
windowClass: windowClass,
|
||||
@@ -101,7 +102,7 @@ struct SendScreen: View {
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let panel = model.state.detailPanel {
|
||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
|
||||
@@ -6,6 +6,7 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct TransferDetailsView: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let events: [CoreEventModel]
|
||||
@State private var showStopConfirmation = false
|
||||
@@ -129,6 +130,7 @@ private struct DetailDestination: View {
|
||||
|
||||
struct DetailPanelContent: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let panel: TransferDetailPanel
|
||||
|
||||
@@ -146,7 +148,7 @@ struct DetailPanelContent: View {
|
||||
onAccept: model.acceptReceiver
|
||||
)
|
||||
case .share:
|
||||
TransferSharePanel(model: model, transfer: transfer)
|
||||
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +298,7 @@ private struct ReceiverRow: View {
|
||||
struct TransferSharePanel: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
|
||||
var body: some View {
|
||||
@@ -309,7 +312,7 @@ struct TransferSharePanel: View {
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
|
||||
case .preparing:
|
||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||
case .unavailable:
|
||||
|
||||
@@ -20,14 +20,25 @@ protocol TransferShareActions: AnyObject {
|
||||
struct ShareActionsView: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let ticket: String
|
||||
|
||||
@State private var actions: TransferShareActions = makePlatformShareActions()
|
||||
@State private var writingNfc = false
|
||||
@State private var choosingDevice = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Sending straight to a remembered device is another way to deliver
|
||||
// this same invitation, so it belongs with the other delivery
|
||||
// methods rather than in a separate flow.
|
||||
if contacts.state.contacts.contains(where: \.canSend) {
|
||||
SecondaryButton(
|
||||
title: String(localized: L10n.Contacts.sendToDevice),
|
||||
action: { choosingDevice = true }
|
||||
)
|
||||
}
|
||||
if actions.nfcAvailability != .hidden {
|
||||
SecondaryButton(
|
||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||
@@ -57,5 +68,8 @@ struct ShareActionsView: View {
|
||||
}, enabled: actions.canUseNativeShare)
|
||||
}
|
||||
.onDisappear { actions.cancelNfcWrite() }
|
||||
.sheet(isPresented: $choosingDevice) {
|
||||
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,3 @@ struct NoopBugReportService: BugReportService {
|
||||
}
|
||||
func previewLogBytes() async -> Int { 0 }
|
||||
}
|
||||
|
||||
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
|
||||
enum DiagnosticsBuildConfig {
|
||||
static let included = false
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ enum SettingsSection: Hashable {
|
||||
case appearance
|
||||
case notifications
|
||||
case network
|
||||
case contacts
|
||||
/// One device's detail. Part of this enum because the Settings stack has a
|
||||
/// typed path: a link carrying any other value type cannot push onto it.
|
||||
case contactDetail(endpointId: String)
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
@@ -19,6 +23,7 @@ enum SettingsSection: Hashable {
|
||||
case .appearance: return L10n.Appearance.title
|
||||
case .notifications: return L10n.Notifications.title
|
||||
case .network: return L10n.Settings.networkTitle
|
||||
case .contacts, .contactDetail: return L10n.Contacts.title
|
||||
case .storage: return L10n.Storage.title
|
||||
case .about: return L10n.About.title
|
||||
case .bugReport: return L10n.About.bugReport
|
||||
@@ -44,7 +49,6 @@ struct SettingsState: Equatable {
|
||||
var supportsCustomReceiveFolders = true
|
||||
var themeMode: ThemeMode = .system
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var relayMode: RelayPreferenceMode = .automatic
|
||||
var relayURLs: [String] = []
|
||||
var relayValidationError: RelayConfigurationValidationError?
|
||||
@@ -76,7 +80,7 @@ struct SettingsState: Equatable {
|
||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||
&& lhs.themeMode == rhs.themeMode
|
||||
&& lhs.notificationPermission == rhs.notificationPermission
|
||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||
&& lhs.appVersion == rhs.appVersion
|
||||
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||
&& lhs.relayValidationError == rhs.relayValidationError
|
||||
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||
@@ -111,7 +115,6 @@ final class SettingsModel: ObservableObject {
|
||||
private let notifications: LocalNotificationService
|
||||
private let messages: UiMessageController
|
||||
private let bugReports: BugReportService
|
||||
private let diagnosticsIncluded: Bool
|
||||
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
@@ -126,8 +129,7 @@ final class SettingsModel: ObservableObject {
|
||||
preferences: AppPreferencesRepository,
|
||||
notifications: LocalNotificationService,
|
||||
messages: UiMessageController,
|
||||
bugReports: BugReportService,
|
||||
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
|
||||
bugReports: BugReportService
|
||||
) {
|
||||
self.environment = environment
|
||||
self.deviceInfoProvider = deviceInfoProvider
|
||||
@@ -137,7 +139,6 @@ final class SettingsModel: ObservableObject {
|
||||
self.notifications = notifications
|
||||
self.messages = messages
|
||||
self.bugReports = bugReports
|
||||
self.diagnosticsIncluded = diagnosticsIncluded
|
||||
self.state = SettingsState(
|
||||
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
||||
appVersion: environment.appVersion
|
||||
@@ -151,7 +152,6 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||
self.state.receiveFolder = folder
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if !self.hasRelayConfigurationDraft {
|
||||
self.state.relayMode = prefs.relayConfiguration.mode
|
||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||
@@ -179,6 +179,12 @@ final class SettingsModel: ObservableObject {
|
||||
loadDeviceInfo()
|
||||
}
|
||||
|
||||
/// Surfaces "nothing waiting" from the contacts screen, which has no
|
||||
/// message controller of its own.
|
||||
func reportNothingWaiting() {
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info))
|
||||
}
|
||||
|
||||
func selectSection(_ section: SettingsSection) {
|
||||
state.selectedSection = section
|
||||
if section == .about || section == .bugReport {
|
||||
@@ -230,17 +236,6 @@ final class SettingsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
if !diagnosticsIncluded { return }
|
||||
Task {
|
||||
preferences.setDiagnosticsEnabled(enabled)
|
||||
messages.show(UiMessage(
|
||||
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
|
||||
tone: .success
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network
|
||||
|
||||
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
||||
/// navigation. The model stays the source of truth via a derived path binding.
|
||||
struct SettingsScreen: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let windowClass: WindowClass
|
||||
@State private var showBugReport = false
|
||||
|
||||
@@ -14,6 +15,8 @@ struct SettingsScreen: View {
|
||||
switch model.state.selectedSection {
|
||||
case .overview: return []
|
||||
case .bugReport: return [.about, .bugReport]
|
||||
case .contactDetail(let endpointId):
|
||||
return [.contacts, .contactDetail(endpointId: endpointId)]
|
||||
case let section: return [section]
|
||||
}
|
||||
},
|
||||
@@ -54,6 +57,15 @@ struct SettingsScreen: View {
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.contacts) {
|
||||
SettingsRow(
|
||||
icon: .macbookAndIphone,
|
||||
title: String(localized: L10n.Contacts.title),
|
||||
value: contacts.state.contacts.isEmpty
|
||||
? nil
|
||||
: String(contacts.state.contacts.count)
|
||||
)
|
||||
}
|
||||
}
|
||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||
NavigationLink(value: SettingsSection.network) {
|
||||
@@ -80,6 +92,21 @@ struct SettingsScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||
// Contacts brings its own Form and push destination, so it is not wrapped
|
||||
// in the shared section chrome.
|
||||
if case .contactDetail(let endpointId) = section {
|
||||
ContactDetailScreen(model: contacts, endpointId: endpointId)
|
||||
} else if section == .contacts {
|
||||
ContactsScreen(model: contacts) {
|
||||
model.reportNothingWaiting()
|
||||
}
|
||||
} else {
|
||||
settingsSectionForm(section)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsSectionForm(_ section: SettingsSection) -> some View {
|
||||
let content = Form {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
@@ -130,6 +157,9 @@ private struct SettingsSectionContent: View {
|
||||
NetworkSettings(model: model)
|
||||
case .storage:
|
||||
StorageSettings(model: model)
|
||||
case .contacts, .contactDetail:
|
||||
// Rendered by SettingsScreen itself, which owns the contacts model.
|
||||
EmptyView()
|
||||
case .about:
|
||||
AboutSettings(model: model)
|
||||
case .bugReport:
|
||||
|
||||
@@ -375,7 +375,7 @@ struct StorageSettings: View {
|
||||
struct AboutSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
|
||||
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
||||
private static let privacyPolicyURL = AppConfig.privacyPolicyURL
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
@@ -415,17 +415,6 @@ struct AboutSettings: View {
|
||||
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||
}
|
||||
}
|
||||
|
||||
if DiagnosticsBuildConfig.included {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { model.state.diagnosticsEnabled },
|
||||
set: { model.setDiagnosticsEnabled($0) }
|
||||
)) {
|
||||
Text(String(localized: L10n.Diagnostics.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,15 +59,24 @@ struct IosFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
let sources = files.map { $0.toIosShareSource() }
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: sources,
|
||||
transferName: transferName, senderName: senderName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||
|
||||
@@ -39,8 +39,8 @@ struct MacFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
@@ -63,9 +63,18 @@ struct MacFileSystemService: FileSystemService {
|
||||
let sources = files.map {
|
||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: sources,
|
||||
transferName: transferName, senderName: senderName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -70,6 +70,33 @@ struct SendPickers: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// File picker for "send to this device", reusing the share picker's selection
|
||||
/// handling so security-scoped bookmarks are captured the same way.
|
||||
struct ContactSendPickers: ViewModifier {
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.fileImporter(
|
||||
isPresented: $model.pendingFilePick,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) }
|
||||
if files.isEmpty {
|
||||
model.onFilePickFailed("The selected document could not be opened")
|
||||
} else {
|
||||
Task { await model.onFilesPicked(files) }
|
||||
}
|
||||
case .failure(let error):
|
||||
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PickerSupport {
|
||||
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
||||
#if os(iOS)
|
||||
@@ -129,4 +156,8 @@ extension View {
|
||||
func sendPickers(model: SendModel) -> some View {
|
||||
modifier(SendPickers(model: model))
|
||||
}
|
||||
|
||||
func contactSendPickers(model: ContactsModel) -> some View {
|
||||
modifier(ContactSendPickers(model: model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,57 @@
|
||||
{
|
||||
"fill": {
|
||||
"linear-gradient": [
|
||||
"extended-gray:1.00000,1.00000",
|
||||
"display-p3:0.55433,0.59923,0.92884,1.00000"
|
||||
]
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"blur-material": null,
|
||||
"layers": [
|
||||
{
|
||||
"image-name": "Mask.svg",
|
||||
"name": "Mask"
|
||||
}
|
||||
],
|
||||
"lighting": "individual",
|
||||
"refractivity": {
|
||||
"depth": 0.5,
|
||||
"enabled": true,
|
||||
"strength": 0
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.6
|
||||
},
|
||||
"specular": true,
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"image-name": "Drop.svg",
|
||||
"name": "Drop"
|
||||
},
|
||||
{
|
||||
"image-name": "U.svg",
|
||||
"name": "U"
|
||||
}
|
||||
],
|
||||
"lighting": "combined",
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.6
|
||||
},
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.4
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": [
|
||||
"watchOS"
|
||||
],
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"extended-gray:1.00000,1.00000",
|
||||
"srgb:0.84942,0.81480,0.95401,1.00000"
|
||||
]
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Mask.svg",
|
||||
"name" : "Mask"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.6
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Drop.svg",
|
||||
"name" : "Drop"
|
||||
},
|
||||
{
|
||||
"image-name" : "U.svg",
|
||||
"name" : "U"
|
||||
}
|
||||
],
|
||||
"lighting" : "combined",
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.8
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.4
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import VnidropCore
|
||||
|
||||
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||
/// How an offered transfer ended without being accepted.
|
||||
enum OfferRefusal {
|
||||
case declined
|
||||
case noAnswer
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func toUiText() -> UiText {
|
||||
if let invitation = self as? InvitationError {
|
||||
@@ -57,6 +63,19 @@ extension Error {
|
||||
|| haystack.contains("user canceled")
|
||||
}
|
||||
|
||||
/// The other device answered, and the answer was no.
|
||||
///
|
||||
/// Not a failure of this device: the offer was delivered and a person
|
||||
/// declined it, so it is reported as information rather than an error.
|
||||
var offerRefusal: OfferRefusal? {
|
||||
let haystack = technicalDetail.lowercased()
|
||||
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
|
||||
return .declined
|
||||
}
|
||||
if haystack.contains("no-response") { return .noAnswer }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Prefers a `VnidropError` reason; else the localized description.
|
||||
var technicalDetail: String {
|
||||
if let vni = self as? VnidropError {
|
||||
|
||||
@@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
|
||||
# This never touches the Rust crate — it only changes how the build is invoked.
|
||||
export CARGO_PROFILE_DEV_STRIP=none
|
||||
|
||||
# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build
|
||||
# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo
|
||||
# forbids overriding `lto` per build-override, so disable thin LTO for the whole
|
||||
# release build here — the crate is still fully optimized (opt-level 3, debuginfo
|
||||
# stripped), which is what shrinks the static lib. This never edits the Cargo crate.
|
||||
export CARGO_PROFILE_RELEASE_LTO=false
|
||||
|
||||
IOS_TARGET="aarch64-apple-ios"
|
||||
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
||||
SIM_X64_TARGET="x86_64-apple-ios"
|
||||
|
||||
44
apple/scripts/generate-appconfig.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties
|
||||
# so app-wide constants (privacy policy URL, …) have a single source of truth
|
||||
# across Apple and KMP. Regenerate instead of editing the output.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}"
|
||||
output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}"
|
||||
|
||||
read_property() {
|
||||
local key=$1
|
||||
local value
|
||||
value="$(sed -n "s/^${key}=//p" "$config_file")"
|
||||
[[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||
[[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] ||
|
||||
{ printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# Escape for a Swift string literal.
|
||||
swift_escape() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
privacy_url="$(read_property PRIVACY_POLICY_URL)"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")"
|
||||
cat > "$tmp" <<EOF
|
||||
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
|
||||
// Regenerate this file instead of editing it.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// App-wide constants injected at build time from the shared \`app.properties\`.
|
||||
enum AppConfig {
|
||||
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
|
||||
}
|
||||
EOF
|
||||
mv "$tmp" "$output_dir/AppConfig.swift"
|
||||
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
|
||||
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
generator="$script_dir/../generate-appconfig.sh"
|
||||
repo_root="$(cd "$script_dir/../../.." && pwd)"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
# Run the generator against a fixture app.properties, emitting into a temp dir.
|
||||
generate() {
|
||||
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
|
||||
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
|
||||
"$generator"
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
printf 'Expected command to fail: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file=$1 needle=$2
|
||||
grep -qF "$needle" "$file" ||
|
||||
{ printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; }
|
||||
}
|
||||
|
||||
out="$scratch/out/AppConfig.swift"
|
||||
|
||||
# 1. Nominal value is emitted verbatim as a Swift URL literal.
|
||||
printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties"
|
||||
generate
|
||||
assert_contains "$out" 'URL(string: "https://example.test/privacy/")!'
|
||||
assert_contains "$out" 'enum AppConfig'
|
||||
|
||||
# 2. Characters special to a Swift string literal are escaped.
|
||||
printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties"
|
||||
generate
|
||||
assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!'
|
||||
|
||||
# 3. A missing key fails instead of emitting an empty value.
|
||||
printf 'OTHER_KEY=value\n' > "$scratch/app.properties"
|
||||
expect_failure generate
|
||||
|
||||
# 4. A duplicated key fails.
|
||||
printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties"
|
||||
expect_failure generate
|
||||
|
||||
# 5. The real committed app.properties produces an https URL.
|
||||
VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator"
|
||||
assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://'
|
||||
|
||||
printf 'generate-appconfig tests passed.\n'
|
||||
40
ci_scripts/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Xcode Cloud CI scripts
|
||||
|
||||
Xcode Cloud runs the scripts in this directory around each build. Only
|
||||
`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` /
|
||||
`ci_post_xcodebuild.sh` here if later steps are needed.
|
||||
|
||||
## What `ci_post_clone.sh` does
|
||||
|
||||
The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not**
|
||||
committed — they are produced by XcodeGen, localization, and the Rust core build.
|
||||
Since Xcode Cloud only checks out the repository, the post-clone script:
|
||||
|
||||
1. installs `swiftlint`, `xcodegen`, and `bun`;
|
||||
2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from
|
||||
the matching GitHub Release asset `VnidropCore-<version>.zip` — Xcode Cloud
|
||||
never builds Rust;
|
||||
3. runs localization + version/app config codegen and `xcodegen generate`
|
||||
(equivalent to `make apple-project` without the `apple-core` step).
|
||||
|
||||
The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release
|
||||
before an Xcode Cloud build for that version runs (see
|
||||
`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`).
|
||||
|
||||
### Overrides (env vars, optional)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from |
|
||||
| `VNIDROP_CORE_TAG` | `v<product-version>` | Release tag holding the core asset |
|
||||
|
||||
## Workflow configuration (App Store Connect)
|
||||
|
||||
The workflow itself (product, scheme, triggers, actions) is configured in App
|
||||
Store Connect, not in the repository. Point it at:
|
||||
|
||||
- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script)
|
||||
- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`)
|
||||
|
||||
Archive actions use the release Rust profile via the published core asset; build
|
||||
and test actions reuse the same prebuilt core.
|
||||
72
ci_scripts/ci_post_clone.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Xcode Cloud post-clone step.
|
||||
#
|
||||
# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a
|
||||
# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud
|
||||
# only checks out the repository, so this script:
|
||||
# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun);
|
||||
# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the
|
||||
# matching GitHub Release asset — we never build Rust here;
|
||||
# 3. reproduces `make apple-project` minus the Rust `apple-core` step.
|
||||
#
|
||||
# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH
|
||||
# points at the checked-out repository root.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "==> Installing build tooling (Homebrew)"
|
||||
# swiftlint: enforced by a build phase (fails the build if missing).
|
||||
# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml.
|
||||
brew install swiftlint xcodegen
|
||||
|
||||
echo "==> Installing Bun (localization generator)"
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
fi
|
||||
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
|
||||
export PATH="$BUN_INSTALL/bin:$PATH"
|
||||
|
||||
# --- Prebuilt core: download instead of building Rust -------------------------
|
||||
# The Apple core (xcframework + UniFFI bindings) is published as a release asset
|
||||
# by apple/scripts/package-core.sh. See docs at the top of that script.
|
||||
VERSION="$(packaging/version/resolve-version.sh product)"
|
||||
CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}"
|
||||
CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}"
|
||||
CORE_ZIP="VnidropCore-$VERSION.zip"
|
||||
CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG"
|
||||
|
||||
PKG_DIR="$REPO_ROOT/apple/VnidropCore"
|
||||
DOWNLOAD_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
|
||||
|
||||
echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG"
|
||||
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP"
|
||||
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256"
|
||||
|
||||
echo "==> Verifying checksum"
|
||||
(
|
||||
cd "$DOWNLOAD_DIR"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum --check "$CORE_ZIP.sha256"
|
||||
else
|
||||
shasum -a 256 --check "$CORE_ZIP.sha256"
|
||||
fi
|
||||
)
|
||||
|
||||
echo "==> Installing core into apple/VnidropCore"
|
||||
unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted"
|
||||
# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh).
|
||||
rm -rf "$PKG_DIR/vnidrop.xcframework"
|
||||
cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework"
|
||||
mkdir -p "$PKG_DIR/Sources/VnidropCore"
|
||||
cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||
|
||||
# --- Generate the project (everything except the Rust core) -------------------
|
||||
echo "==> Generating localization, version/app config, and the Xcode project"
|
||||
make localization apple-version-config apple-app-config
|
||||
(cd "$REPO_ROOT/apple" && xcodegen generate)
|
||||
|
||||
echo "==> ci_post_clone complete"
|
||||
@@ -16,6 +16,7 @@ blake3 = "1.8.3"
|
||||
data-encoding = "2.11.0"
|
||||
futures = "0.3"
|
||||
futures-lite = "2.6.1"
|
||||
getrandom = "0.3.4"
|
||||
iroh = "1.0.3"
|
||||
iroh-blobs = "0.103.0"
|
||||
irpc = "0.17.0"
|
||||
|
||||
@@ -29,13 +29,6 @@ impl AccessPolicy {
|
||||
self.modes.write().await.insert(transfer_id, mode);
|
||||
}
|
||||
|
||||
pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool {
|
||||
matches!(
|
||||
self.modes.read().await.get(&transfer_id),
|
||||
Some(TransferAccessMode::Public)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
|
||||
self.modes.write().await.remove(&transfer_id);
|
||||
self.approved_sessions
|
||||
|
||||
@@ -180,6 +180,8 @@ pub struct CoreLimits {
|
||||
pub max_metadata_bytes: u64,
|
||||
pub max_events: u64,
|
||||
pub max_pending_approvals: u64,
|
||||
/// Incoming pairing offers awaiting the local user's decision.
|
||||
pub max_pending_offers: u64,
|
||||
pub max_concurrent_transfers: u64,
|
||||
pub event_queue_capacity: u64,
|
||||
}
|
||||
@@ -198,6 +200,9 @@ impl Default for CoreLimits {
|
||||
max_events: 500,
|
||||
// Bound handshake spam / notification pressure on the sender.
|
||||
max_pending_approvals: 64,
|
||||
// A pairing prompt needs the user in front of the device, so this
|
||||
// is far smaller than the handshake queue.
|
||||
max_pending_offers: 16,
|
||||
max_concurrent_transfers: 8,
|
||||
event_queue_capacity: 1_024,
|
||||
}
|
||||
@@ -215,6 +220,7 @@ impl CoreLimits {
|
||||
("max_metadata_bytes", self.max_metadata_bytes),
|
||||
("max_events", self.max_events),
|
||||
("max_pending_approvals", self.max_pending_approvals),
|
||||
("max_pending_offers", self.max_pending_offers),
|
||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||
("event_queue_capacity", self.event_queue_capacity),
|
||||
];
|
||||
@@ -225,6 +231,7 @@ impl CoreLimits {
|
||||
}
|
||||
for (name, value) in [
|
||||
("max_pending_approvals", self.max_pending_approvals),
|
||||
("max_pending_offers", self.max_pending_offers),
|
||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||
("event_queue_capacity", self.event_queue_capacity),
|
||||
] {
|
||||
@@ -452,6 +459,80 @@ pub struct TicketInspection {
|
||||
pub metadata: TransferMetadata,
|
||||
}
|
||||
|
||||
/// A device the user has chosen to remember.
|
||||
///
|
||||
/// Deliberately carries no grant material: capabilities never cross the UniFFI
|
||||
/// boundary, only the fact that one exists (`can_send`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct ContactSummary {
|
||||
pub endpoint_id: String,
|
||||
/// Set locally by the user. Authoritative for display.
|
||||
pub local_label: Option<String>,
|
||||
/// Last name the device claimed. Untrusted; never promoted to the label.
|
||||
pub remote_display_name: Option<String>,
|
||||
pub last_transfer_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
/// Whether this device can currently be sent to, i.e. a live grant is held.
|
||||
/// False after the peer revoked, expired, or reinstalled.
|
||||
pub can_send: bool,
|
||||
}
|
||||
|
||||
/// Outcome of sending straight to a remembered device.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct ContactSendResult {
|
||||
pub share: ShareResult,
|
||||
/// False when the device was not running: the transfer is held here and the
|
||||
/// device collects it the next time it opens VniDrop.
|
||||
pub delivered: bool,
|
||||
}
|
||||
|
||||
/// A transfer this device is holding until its target comes back online.
|
||||
///
|
||||
/// Cancelling the underlying transfer withdraws it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct HeldOfferSummary {
|
||||
pub offer_id: String,
|
||||
pub endpoint_id: String,
|
||||
pub transfer_id: u64,
|
||||
pub transfer_name: String,
|
||||
pub file_count: u64,
|
||||
pub total_bytes: u64,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// A transfer a paired device is offering.
|
||||
///
|
||||
/// The ticket is deliberately absent: it is a capability, and it is handed over
|
||||
/// only when the user accepts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct IncomingOffer {
|
||||
pub offer_id: String,
|
||||
pub from_endpoint_id: String,
|
||||
pub sender_display_name: Option<String>,
|
||||
pub transfer_name: String,
|
||||
pub file_count: u64,
|
||||
pub total_bytes: u64,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
/// A device offering to be remembered, awaiting the local user's decision.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct PendingPairing {
|
||||
pub endpoint_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
/// How long a grant survives without use, renewed on every accepted proof.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum GrantLifetimeSetting {
|
||||
Days30,
|
||||
#[default]
|
||||
Days90,
|
||||
Days365,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct ReceiverRequest {
|
||||
pub id: String,
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::sync::{oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
event_hub::EventHub,
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
@@ -198,10 +198,15 @@ impl ApprovalService {
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// An existing access session means this endpoint was already
|
||||
// authorised: either the share is public, or the sender pushed
|
||||
// this transfer to them. Prompting again would ask the sender
|
||||
// to approve a transfer they themselves initiated.
|
||||
if self
|
||||
.access_policy
|
||||
.allows_without_approval(request.transfer_id)
|
||||
.decide(request.transfer_id, Some(&remote_endpoint_id))
|
||||
.await
|
||||
== AccessDecision::Allow
|
||||
{
|
||||
self.allow_without_sender_decision(remote_endpoint_id, request)
|
||||
.await
|
||||
|
||||
627
crates/vnidrop/src/contacts.rs
Normal file
@@ -0,0 +1,627 @@
|
||||
//! Storage for device history: contacts, the grants that make them usable, and
|
||||
//! the block list.
|
||||
//!
|
||||
//! Split out of [`crate::repository`] to keep that file focused; the tables are
|
||||
//! created as part of the same schema migration and share its pool.
|
||||
//!
|
||||
//! Grant secrets live here. They are key material and follow the same rule as
|
||||
//! tickets: never logged, never emitted in an event, never returned across the
|
||||
//! UniFFI boundary.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::grant::{parse_secret, GrantId, HeldGrant, IssuedGrant};
|
||||
|
||||
/// How long a dead grant is kept before being swept.
|
||||
///
|
||||
/// A revoked grant stays as a tombstone so a returning peer is told `Revoked`
|
||||
/// rather than `Unknown`; after this long, a peer that has not come back is
|
||||
/// unlikely to, and the row is noise.
|
||||
pub(crate) const DEAD_GRANT_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
/// A device the user has transferred with and chosen to remember.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Contact {
|
||||
pub(crate) endpoint_id: String,
|
||||
/// Set by the local user. Never overwritten by a name the remote claims.
|
||||
pub(crate) local_label: Option<String>,
|
||||
/// Last name the remote sent. Untrusted display data.
|
||||
pub(crate) remote_display_name: Option<String>,
|
||||
/// Encoded `EndpointAddr` from the last successful connection, so the peer
|
||||
/// stays dialable in relay profiles without public address lookup.
|
||||
pub(crate) last_known_addr: Option<String>,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) last_transfer_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
local_label TEXT,
|
||||
remote_display_name TEXT,
|
||||
last_known_addr TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_transfer_at INTEGER
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Authoritative side: only the issuer can validate or revoke these.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS grants_issued (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
grant_secret TEXT NOT NULL,
|
||||
issued_to_endpoint_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_grants_issued_endpoint ON grants_issued(issued_to_endpoint_id);",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS grants_held (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
grant_secret TEXT NOT NULL,
|
||||
peer_endpoint_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_grants_held_endpoint ON grants_held(peer_endpoint_id);",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS held_offers (
|
||||
offer_id TEXT PRIMARY KEY,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
transfer_id INTEGER NOT NULL,
|
||||
ticket TEXT NOT NULL,
|
||||
transfer_name TEXT NOT NULL,
|
||||
sender_display_name TEXT,
|
||||
file_count INTEGER NOT NULL,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_held_offers_endpoint ON held_offers(endpoint_id);")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS blocked_endpoints (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An offer that could not be delivered because the target was not running.
|
||||
///
|
||||
/// Held on this device, not a server: the share stays here and the receiver
|
||||
/// collects the ticket when its app next comes to the foreground.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct HeldOffer {
|
||||
pub(crate) offer_id: String,
|
||||
pub(crate) endpoint_id: String,
|
||||
pub(crate) transfer_id: u64,
|
||||
pub(crate) ticket: String,
|
||||
pub(crate) transfer_name: String,
|
||||
pub(crate) sender_display_name: Option<String>,
|
||||
pub(crate) file_count: u64,
|
||||
pub(crate) total_bytes: u64,
|
||||
pub(crate) created_at: i64,
|
||||
}
|
||||
|
||||
/// Contacts, grants, and blocks over the shared repository pool.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ContactStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl ContactStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
// -- contacts ---------------------------------------------------------
|
||||
|
||||
/// Record a contact, or refresh the untrusted display name of an existing
|
||||
/// one. The local label is deliberately left untouched.
|
||||
pub(crate) async fn upsert_contact(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
remote_display_name: Option<&str>,
|
||||
now_ms: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO contacts (endpoint_id, remote_display_name, created_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(endpoint_id) DO UPDATE SET
|
||||
remote_display_name = COALESCE(excluded.remote_display_name, contacts.remote_display_name)
|
||||
"#,
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(remote_display_name)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_contact_label(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
label: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE contacts SET local_label = ?2 WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.bind(label)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn touch_transfer(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||
sqlx::query("UPDATE contacts SET last_transfer_at = ?2 WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_last_known_addr(&self, endpoint_id: &str, addr: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE contacts SET last_known_addr = ?2 WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.bind(addr)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_contacts(&self) -> Result<Vec<Contact>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT endpoint_id, local_label, remote_display_name, last_known_addr,
|
||||
created_at, last_transfer_at
|
||||
FROM contacts
|
||||
ORDER BY COALESCE(last_transfer_at, created_at) DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| Contact {
|
||||
endpoint_id: row.get(0),
|
||||
local_label: row.get(1),
|
||||
remote_display_name: row.get(2),
|
||||
last_known_addr: row.get(3),
|
||||
created_at: row.get(4),
|
||||
last_transfer_at: row.get(5),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn find_contact(&self, endpoint_id: &str) -> Result<Option<Contact>> {
|
||||
Ok(self
|
||||
.list_contacts()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|contact| contact.endpoint_id == endpoint_id))
|
||||
}
|
||||
|
||||
/// Remove a contact and every grant in both directions.
|
||||
///
|
||||
/// Returns the ids of the grants this device had issued, so the caller can
|
||||
/// send the best-effort revoke notification. Deletion succeeds regardless of
|
||||
/// whether that notification is ever delivered.
|
||||
pub(crate) async fn delete_contact(&self, endpoint_id: &str) -> Result<Vec<GrantId>> {
|
||||
let issued = self.issued_grant_ids_for(endpoint_id).await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM grants_issued WHERE issued_to_endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM grants_held WHERE peer_endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM contacts WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(issued)
|
||||
}
|
||||
|
||||
/// Wholesale delete, for the same surface that clears transfer history.
|
||||
pub(crate) async fn delete_all_contacts(&self) -> Result<Vec<GrantId>> {
|
||||
let issued = self.all_issued_grant_ids().await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM grants_issued")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM grants_held")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM contacts")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(issued)
|
||||
}
|
||||
|
||||
// -- issued grants ----------------------------------------------------
|
||||
|
||||
pub(crate) async fn insert_issued_grant(&self, grant: &IssuedGrant) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO grants_issued
|
||||
(grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL)
|
||||
"#,
|
||||
)
|
||||
.bind(grant.grant_id.encode())
|
||||
.bind(grant.secret.encode())
|
||||
.bind(&grant.issued_to_endpoint_id)
|
||||
.bind(grant.created_at)
|
||||
.bind(grant.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a grant by the id a peer presented.
|
||||
///
|
||||
/// A row whose secret fails to parse is corrupt storage, not a usable
|
||||
/// grant: surface the error rather than silently refusing the peer, which
|
||||
/// would look like revocation.
|
||||
pub(crate) async fn find_issued_grant(&self, grant_id: GrantId) -> Result<Option<IssuedGrant>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at
|
||||
FROM grants_issued
|
||||
WHERE grant_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(grant_id.encode())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(row_to_issued_grant).transpose()
|
||||
}
|
||||
|
||||
/// Push the idle deadline forward after an accepted proof.
|
||||
pub(crate) async fn renew_issued_grant(
|
||||
&self,
|
||||
grant_id: GrantId,
|
||||
expires_at: Option<i64>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE grants_issued SET expires_at = ?2 WHERE grant_id = ?1")
|
||||
.bind(grant_id.encode())
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// End the relationship from the issuing side. Tombstoned rather than
|
||||
/// deleted so a later attempt is answered `Revoked` instead of `Unknown`.
|
||||
pub(crate) async fn revoke_issued_grant(&self, grant_id: GrantId, now_ms: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE grants_issued SET revoked_at = ?2 WHERE grant_id = ?1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(grant_id.encode())
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke_issued_grants_for(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
now_ms: i64,
|
||||
) -> Result<Vec<GrantId>> {
|
||||
let ids = self.issued_grant_ids_for(endpoint_id).await?;
|
||||
sqlx::query(
|
||||
"UPDATE grants_issued SET revoked_at = ?2 WHERE issued_to_endpoint_id = ?1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn issued_grant_ids_for(&self, endpoint_id: &str) -> Result<Vec<GrantId>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT grant_id FROM grants_issued WHERE issued_to_endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| GrantId::decode(row.get::<String, _>(0).as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn all_issued_grant_ids(&self) -> Result<Vec<GrantId>> {
|
||||
let rows = sqlx::query("SELECT grant_id FROM grants_issued")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| GrantId::decode(row.get::<String, _>(0).as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -- held grants ------------------------------------------------------
|
||||
|
||||
pub(crate) async fn insert_held_grant(&self, grant: &HeldGrant) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO grants_held
|
||||
(grant_id, grant_secret, peer_endpoint_id, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(grant_id) DO UPDATE SET
|
||||
grant_secret = excluded.grant_secret,
|
||||
expires_at = excluded.expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(grant.grant_id.encode())
|
||||
.bind(grant.secret.encode())
|
||||
.bind(&grant.peer_endpoint_id)
|
||||
.bind(grant.created_at)
|
||||
.bind(grant.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The capability to reach `peer_endpoint_id`, if this device holds one.
|
||||
///
|
||||
/// Newest wins: re-pairing issues a fresh grant, and the old one is dead on
|
||||
/// the issuer's side anyway.
|
||||
pub(crate) async fn held_grant_for(&self, peer_endpoint_id: &str) -> Result<Option<HeldGrant>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT grant_id, grant_secret, peer_endpoint_id, created_at, expires_at
|
||||
FROM grants_held
|
||||
WHERE peer_endpoint_id = ?1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(row_to_held_grant).transpose()
|
||||
}
|
||||
|
||||
/// Drop a grant this device holds, after the issuer reported it dead.
|
||||
pub(crate) async fn delete_held_grant(&self, grant_id: GrantId) -> Result<()> {
|
||||
sqlx::query("DELETE FROM grants_held WHERE grant_id = ?1")
|
||||
.bind(grant_id.encode())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- block list -------------------------------------------------------
|
||||
|
||||
/// Block an endpoint and revoke anything it still holds, so blocking is not
|
||||
/// merely cosmetic while a live grant remains.
|
||||
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||
self.revoke_issued_grants_for(endpoint_id, now_ms).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES (?1, ?2)
|
||||
ON CONFLICT(endpoint_id) DO NOTHING",
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn unblock_endpoint(&self, endpoint_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_endpoints WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_blocked(&self, endpoint_id: &str) -> Result<bool> {
|
||||
let row =
|
||||
sqlx::query("SELECT EXISTS(SELECT 1 FROM blocked_endpoints WHERE endpoint_id = ?1)")
|
||||
.bind(endpoint_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>(0) == 1)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_blocked(&self) -> Result<Vec<String>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT endpoint_id FROM blocked_endpoints ORDER BY created_at DESC")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| row.get(0)).collect())
|
||||
}
|
||||
|
||||
// -- held offers ------------------------------------------------------
|
||||
|
||||
pub(crate) async fn insert_held_offer(&self, offer: &HeldOffer) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO held_offers
|
||||
(offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||
sender_display_name, file_count, total_bytes, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
"#,
|
||||
)
|
||||
.bind(&offer.offer_id)
|
||||
.bind(&offer.endpoint_id)
|
||||
.bind(offer.transfer_id as i64)
|
||||
.bind(&offer.ticket)
|
||||
.bind(&offer.transfer_name)
|
||||
.bind(offer.sender_display_name.as_deref())
|
||||
.bind(offer.file_count as i64)
|
||||
.bind(offer.total_bytes as i64)
|
||||
.bind(offer.created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Offers waiting for one device to come and collect them.
|
||||
pub(crate) async fn held_offers_for(&self, endpoint_id: &str) -> Result<Vec<HeldOffer>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||
sender_display_name, file_count, total_bytes, created_at
|
||||
FROM held_offers
|
||||
WHERE endpoint_id = ?1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_held_offer).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_held_offers(&self) -> Result<Vec<HeldOffer>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||
sender_display_name, file_count, total_bytes, created_at
|
||||
FROM held_offers
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_held_offer).collect())
|
||||
}
|
||||
|
||||
/// Consumed once handed over, so a device polling twice is not offered the
|
||||
/// same transfer again.
|
||||
pub(crate) async fn delete_held_offers(&self, offer_ids: &[String]) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
for offer_id in offer_ids {
|
||||
sqlx::query("DELETE FROM held_offers WHERE offer_id = ?1")
|
||||
.bind(offer_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_held_offers_for_transfer(&self, transfer_id: u64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM held_offers WHERE transfer_id = ?1")
|
||||
.bind(transfer_id as i64)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- maintenance ------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn corrupt_secret_for_test(&self, grant_id: GrantId) -> Result<()> {
|
||||
sqlx::query("UPDATE grants_issued SET grant_secret = 'not-hex' WHERE grant_id = ?1")
|
||||
.bind(grant_id.encode())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop grants that lapsed or were revoked long enough ago that no peer
|
||||
/// still needs to be told. Keeps tombstones bounded.
|
||||
pub(crate) async fn purge_dead_grants(&self, before_ms: i64) -> Result<u64> {
|
||||
let issued = sqlx::query(
|
||||
"DELETE FROM grants_issued
|
||||
WHERE (expires_at IS NOT NULL AND expires_at < ?1)
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < ?1)",
|
||||
)
|
||||
.bind(before_ms)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(issued)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_held_offer(row: sqlx::sqlite::SqliteRow) -> HeldOffer {
|
||||
HeldOffer {
|
||||
offer_id: row.get(0),
|
||||
endpoint_id: row.get(1),
|
||||
transfer_id: row.get::<i64, _>(2) as u64,
|
||||
ticket: row.get(3),
|
||||
transfer_name: row.get(4),
|
||||
sender_display_name: row.get(5),
|
||||
file_count: row.get::<i64, _>(6) as u64,
|
||||
total_bytes: row.get::<i64, _>(7) as u64,
|
||||
created_at: row.get(8),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_issued_grant(row: sqlx::sqlite::SqliteRow) -> Result<IssuedGrant> {
|
||||
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
|
||||
let secret = parse_secret(row.get::<String, _>(1).as_str())
|
||||
.context("stored grant secret is unusable")?;
|
||||
Ok(IssuedGrant {
|
||||
grant_id,
|
||||
secret,
|
||||
issued_to_endpoint_id: row.get(2),
|
||||
created_at: row.get(3),
|
||||
expires_at: row.get(4),
|
||||
revoked_at: row.get(5),
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_held_grant(row: sqlx::sqlite::SqliteRow) -> Result<HeldGrant> {
|
||||
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
|
||||
let secret = parse_secret(row.get::<String, _>(1).as_str())
|
||||
.context("stored grant secret is unusable")?;
|
||||
Ok(HeldGrant {
|
||||
grant_id,
|
||||
secret,
|
||||
peer_endpoint_id: row.get(2),
|
||||
created_at: row.get(3),
|
||||
expires_at: row.get(4),
|
||||
})
|
||||
}
|
||||
364
crates/vnidrop/src/grant.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
//! Grants: the capability a device issues so a known peer may reach it.
|
||||
//!
|
||||
//! A history entry is not "I remember this endpoint id", it is "this device
|
||||
//! issued me a capability". The issuer is the only party that can validate a
|
||||
//! grant, which is what makes both consent and revocation enforceable: refusing
|
||||
//! to issue leaves the peer with nothing usable, and deleting the issued record
|
||||
//! ends the relationship without the peer's cooperation.
|
||||
//!
|
||||
//! This module is pure: no storage, no network, no clock of its own. Callers
|
||||
//! supply `now_ms` so expiry and renewal stay testable.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use data_encoding::HEXLOWER;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Domain separator for the possession proof. Changing this invalidates every
|
||||
/// outstanding grant, so it is versioned rather than edited.
|
||||
const PROOF_CONTEXT: &[u8] = b"vnidrop-grant-v1";
|
||||
|
||||
const GRANT_ID_LEN: usize = 16;
|
||||
const GRANT_SECRET_LEN: usize = 32;
|
||||
const CHALLENGE_LEN: usize = 32;
|
||||
const PROOF_LEN: usize = 32;
|
||||
|
||||
/// Opaque public identifier for a grant. Safe to send in the clear.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub(crate) struct GrantId([u8; GRANT_ID_LEN]);
|
||||
|
||||
impl GrantId {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid grant id encoding")?;
|
||||
let bytes: [u8; GRANT_ID_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid grant id length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "GrantId({})", self.encode())
|
||||
}
|
||||
}
|
||||
|
||||
/// Key material. Never logged, never emitted in an event, never returned across
|
||||
/// the UniFFI boundary.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GrantSecret([u8; GRANT_SECRET_LEN]);
|
||||
|
||||
impl GrantSecret {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid grant secret encoding")?;
|
||||
let bytes: [u8; GRANT_SECRET_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid grant secret length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// Redacted on purpose: a secret must not reach a log line through a derived
|
||||
// Debug on some enclosing struct.
|
||||
impl fmt::Debug for GrantSecret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("GrantSecret(redacted)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Random challenge sent by the issuer to bind a proof to one connection.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct Challenge([u8; CHALLENGE_LEN]);
|
||||
|
||||
impl Challenge {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Challenge {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Challenge(..)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Proof that the sender holds the secret behind `grant_id`.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct GrantProof {
|
||||
pub(crate) grant_id: GrantId,
|
||||
mac: [u8; PROOF_LEN],
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrantProof {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GrantProof")
|
||||
.field("grant_id", &self.grant_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a presented proof was not accepted.
|
||||
///
|
||||
/// `Revoked` is reported to the peer so its client can drop the dead entry.
|
||||
/// `Unknown` is deliberately also used for blocked endpoints, so blocking
|
||||
/// cannot be detected by probing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GrantRejection {
|
||||
Unknown,
|
||||
Revoked,
|
||||
Expired,
|
||||
WrongEndpoint,
|
||||
BadProof,
|
||||
}
|
||||
|
||||
impl GrantRejection {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Revoked => "revoked",
|
||||
Self::Expired => "expired",
|
||||
Self::WrongEndpoint => "wrong-endpoint",
|
||||
Self::BadProof => "bad-proof",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant as held by the party that issued it. This is the authoritative
|
||||
/// record: `grants_held` on the peer is only a copy for display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct IssuedGrant {
|
||||
pub(crate) grant_id: GrantId,
|
||||
pub(crate) secret: GrantSecret,
|
||||
/// The grant is usable only by this endpoint, so it cannot be lent onward.
|
||||
pub(crate) issued_to_endpoint_id: String,
|
||||
pub(crate) created_at: i64,
|
||||
/// Idle expiry, pushed forward on every accepted proof. `None` never expires.
|
||||
pub(crate) expires_at: Option<i64>,
|
||||
pub(crate) revoked_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl IssuedGrant {
|
||||
pub(crate) fn mint(
|
||||
issued_to_endpoint_id: String,
|
||||
now_ms: i64,
|
||||
lifetime: GrantLifetime,
|
||||
) -> Self {
|
||||
Self {
|
||||
grant_id: GrantId::generate(),
|
||||
secret: GrantSecret::generate(),
|
||||
issued_to_endpoint_id,
|
||||
created_at: now_ms,
|
||||
expires_at: lifetime.deadline_from(now_ms),
|
||||
revoked_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a proof presented by `remote_endpoint_id` over this connection's
|
||||
/// challenge. Returns the renewed expiry the caller must persist.
|
||||
///
|
||||
/// Checks run in a fixed order so a caller cannot learn more from an early
|
||||
/// return than from a late one: revocation and expiry are properties of the
|
||||
/// issuer's own record, and the endpoint binding is checked before the MAC
|
||||
/// so a stolen grant cannot be probed for validity from another device.
|
||||
pub(crate) fn accept(
|
||||
&self,
|
||||
proof: &GrantProof,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
remote_endpoint_id: &str,
|
||||
now_ms: i64,
|
||||
lifetime: GrantLifetime,
|
||||
) -> Result<Option<i64>, GrantRejection> {
|
||||
if proof.grant_id != self.grant_id {
|
||||
return Err(GrantRejection::Unknown);
|
||||
}
|
||||
if self.revoked_at.is_some() {
|
||||
return Err(GrantRejection::Revoked);
|
||||
}
|
||||
if self.is_expired(now_ms) {
|
||||
return Err(GrantRejection::Expired);
|
||||
}
|
||||
if remote_endpoint_id != self.issued_to_endpoint_id {
|
||||
return Err(GrantRejection::WrongEndpoint);
|
||||
}
|
||||
|
||||
let expected = compute_proof(
|
||||
&self.secret,
|
||||
challenge,
|
||||
issuer_endpoint_id,
|
||||
remote_endpoint_id,
|
||||
);
|
||||
// Constant-time: blake3::Hash's PartialEq is constant-time by design.
|
||||
if !constant_time_eq(&expected, &proof.mac) {
|
||||
return Err(GrantRejection::BadProof);
|
||||
}
|
||||
|
||||
Ok(lifetime.deadline_from(now_ms))
|
||||
}
|
||||
|
||||
pub(crate) fn is_expired(&self, now_ms: i64) -> bool {
|
||||
self.expires_at
|
||||
.is_some_and(|expires_at| expires_at < now_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant as held by the party it was issued to: the capability used to reach
|
||||
/// the peer that minted it.
|
||||
///
|
||||
/// `expires_at` here is advisory only — a copy of what the issuer said at issue
|
||||
/// time, useful for showing "expires soon" in the UI. The issuer's record is
|
||||
/// authoritative and may have been renewed or revoked since.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct HeldGrant {
|
||||
pub(crate) grant_id: GrantId,
|
||||
pub(crate) secret: GrantSecret,
|
||||
/// The peer that issued this grant, and therefore the only one it works on.
|
||||
pub(crate) peer_endpoint_id: String,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl HeldGrant {
|
||||
/// Build the proof to present to the issuing peer.
|
||||
pub(crate) fn prove(&self, challenge: &Challenge, self_endpoint_id: &str) -> GrantProof {
|
||||
prove(
|
||||
self.grant_id,
|
||||
&self.secret,
|
||||
challenge,
|
||||
&self.peer_endpoint_id,
|
||||
self_endpoint_id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a grant survives without use. Grants expire on idleness rather than
|
||||
/// age, so a relationship in regular use never lapses while a forgotten one
|
||||
/// cleans itself up.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GrantLifetime {
|
||||
Days(u32),
|
||||
Never,
|
||||
}
|
||||
|
||||
impl GrantLifetime {
|
||||
pub(crate) const DEFAULT_DAYS: u32 = 90;
|
||||
|
||||
pub(crate) fn deadline_from(self, now_ms: i64) -> Option<i64> {
|
||||
match self {
|
||||
Self::Never => None,
|
||||
Self::Days(days) => Some(now_ms + i64::from(days) * 24 * 60 * 60 * 1_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GrantLifetime {
|
||||
fn default() -> Self {
|
||||
Self::Days(Self::DEFAULT_DAYS)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::api::GrantLifetimeSetting> for GrantLifetime {
|
||||
fn from(setting: crate::api::GrantLifetimeSetting) -> Self {
|
||||
match setting {
|
||||
crate::api::GrantLifetimeSetting::Days30 => Self::Days(30),
|
||||
crate::api::GrantLifetimeSetting::Days90 => Self::Days(90),
|
||||
crate::api::GrantLifetimeSetting::Days365 => Self::Days(365),
|
||||
crate::api::GrantLifetimeSetting::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the proof for a grant this device holds.
|
||||
pub(crate) fn prove(
|
||||
grant_id: GrantId,
|
||||
secret: &GrantSecret,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
holder_endpoint_id: &str,
|
||||
) -> GrantProof {
|
||||
GrantProof {
|
||||
grant_id,
|
||||
mac: compute_proof(secret, challenge, issuer_endpoint_id, holder_endpoint_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyed MAC over the challenge and both endpoint identities.
|
||||
///
|
||||
/// Binding the challenge stops a captured proof being replayed; binding both
|
||||
/// endpoint ids stops it being replayed against a different peer. Lengths are
|
||||
/// prefixed so two different id pairs cannot produce the same input.
|
||||
fn compute_proof(
|
||||
secret: &GrantSecret,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
holder_endpoint_id: &str,
|
||||
) -> [u8; PROOF_LEN] {
|
||||
let mut input = Vec::with_capacity(
|
||||
PROOF_CONTEXT.len()
|
||||
+ CHALLENGE_LEN
|
||||
+ issuer_endpoint_id.len()
|
||||
+ holder_endpoint_id.len()
|
||||
+ 16,
|
||||
);
|
||||
input.extend_from_slice(PROOF_CONTEXT);
|
||||
input.extend_from_slice(&challenge.0);
|
||||
push_length_prefixed(&mut input, issuer_endpoint_id.as_bytes());
|
||||
push_length_prefixed(&mut input, holder_endpoint_id.as_bytes());
|
||||
*blake3::keyed_hash(&secret.0, &input).as_bytes()
|
||||
}
|
||||
|
||||
fn push_length_prefixed(buffer: &mut Vec<u8>, bytes: &[u8]) {
|
||||
buffer.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
|
||||
buffer.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8; PROOF_LEN], right: &[u8; PROOF_LEN]) -> bool {
|
||||
// blake3::Hash compares in constant time; reuse it rather than hand-rolling.
|
||||
blake3::Hash::from_bytes(*left) == blake3::Hash::from_bytes(*right)
|
||||
}
|
||||
|
||||
/// Cryptographically secure random bytes.
|
||||
///
|
||||
/// Panics if the OS entropy source fails. That is unrecoverable and must never
|
||||
/// degrade into a weak grant, so it is not surfaced as a fallible API.
|
||||
fn random_bytes<const N: usize>() -> [u8; N] {
|
||||
let mut bytes = [0u8; N];
|
||||
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Parse a stored grant secret, rejecting anything malformed rather than
|
||||
/// silently producing a grant that can never validate.
|
||||
pub(crate) fn parse_secret(value: &str) -> Result<GrantSecret> {
|
||||
let secret = GrantSecret::decode(value)?;
|
||||
if secret.0.iter().all(|byte| *byte == 0) {
|
||||
bail!("refusing an all-zero grant secret");
|
||||
}
|
||||
Ok(secret)
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
mod access_policy;
|
||||
mod api;
|
||||
mod approval;
|
||||
mod contacts;
|
||||
mod error;
|
||||
mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod logging;
|
||||
mod offer;
|
||||
mod offer_inbox;
|
||||
mod pairing;
|
||||
mod repository;
|
||||
mod runtime;
|
||||
mod secret;
|
||||
@@ -14,11 +19,13 @@ mod transfer_state;
|
||||
mod util;
|
||||
|
||||
pub use api::{
|
||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
|
||||
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
|
||||
CoreRelayMode, CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
|
||||
PendingPairing, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
|
||||
ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
|
||||
TransferMetadata,
|
||||
};
|
||||
pub use error::VnidropError;
|
||||
pub use runtime::VnidropCore;
|
||||
|
||||
335
crates/vnidrop/src/offer.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
//! The contacts protocol: how paired devices reach each other directly.
|
||||
//!
|
||||
//! Separate ALPN from the transfer handshake because the trust model differs.
|
||||
//! `/vnidrop/handshake/2` serves anyone holding a ticket, subject to sender
|
||||
//! approval. This one serves nobody without a grant (see [`crate::grant`]), so
|
||||
//! an unpaired device cannot even raise a prompt on the far side.
|
||||
//!
|
||||
//! Every request except grant delivery carries a proof over a challenge this
|
||||
//! connection issued, so a captured proof cannot be replayed onto another
|
||||
//! connection.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use iroh::{
|
||||
endpoint::Connection,
|
||||
protocol::{AcceptError, ProtocolHandler},
|
||||
Endpoint, EndpointAddr,
|
||||
};
|
||||
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
||||
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
grant::{Challenge, GrantId, GrantProof},
|
||||
offer_inbox::OfferInbox,
|
||||
pairing::PairingService,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct OfferService {
|
||||
pairing: PairingService,
|
||||
inbox: OfferInbox,
|
||||
/// This device's endpoint id. Grants we issued are bound to it, so proofs
|
||||
/// must be verified against it rather than against whatever a peer claims.
|
||||
self_endpoint_id: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OfferService {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("OfferService")
|
||||
}
|
||||
}
|
||||
|
||||
impl OfferService {
|
||||
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/offer/1";
|
||||
|
||||
pub(crate) fn new(
|
||||
pairing: PairingService,
|
||||
inbox: OfferInbox,
|
||||
self_endpoint_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
pairing,
|
||||
inbox,
|
||||
self_endpoint_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> OfferClient {
|
||||
OfferClient {
|
||||
inner: Client::boxed(IrohLazyRemoteConnection::new(
|
||||
endpoint,
|
||||
addr,
|
||||
Self::ALPN.to_vec(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OfferService {
|
||||
/// Validate the grant, then hand the offer to the local user.
|
||||
///
|
||||
/// A refusal names the grant failure so the peer can drop a dead entry;
|
||||
/// `Unknown` covers both "never issued" and "blocked", which is what keeps
|
||||
/// blocking undetectable.
|
||||
async fn handle_offer(
|
||||
&self,
|
||||
remote_endpoint_id: &str,
|
||||
challenge: &Challenge,
|
||||
offer: SubmitOffer,
|
||||
) -> OfferResponse {
|
||||
if let Err(rejection) = self
|
||||
.pairing
|
||||
.verify_and_renew(
|
||||
&offer.proof,
|
||||
challenge,
|
||||
&self.self_endpoint_id,
|
||||
remote_endpoint_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return OfferResponse::Refused {
|
||||
reason: rejection.as_str().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
self.inbox
|
||||
.submit(
|
||||
remote_endpoint_id.to_string(),
|
||||
offer.transfer_name,
|
||||
offer.sender_display_name,
|
||||
offer.file_count,
|
||||
offer.total_bytes,
|
||||
offer.ticket,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl OfferService {
|
||||
/// Hand a device the offers this one is holding for it.
|
||||
///
|
||||
/// Needs no grant proof: iroh has already authenticated the remote endpoint
|
||||
/// key, and the only thing returned is what this device already decided to
|
||||
/// send to precisely that endpoint. A stranger polling gets an empty list.
|
||||
async fn handle_poll(&self, remote_endpoint_id: &str) -> PolledOffers {
|
||||
PolledOffers {
|
||||
offers: self.pairing.collect_held_offers(remote_endpoint_id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolHandler for OfferService {
|
||||
/// Accepts inbound connections from paired peers.
|
||||
///
|
||||
/// The challenge is per connection and never leaves this scope, which is
|
||||
/// what binds a proof to one session: a proof captured from an earlier
|
||||
/// connection cannot be presented on a later one.
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let remote_endpoint_id = connection.remote_id().to_string();
|
||||
let challenge = Challenge::generate();
|
||||
|
||||
while let Some(message) = read_request::<OfferProtocol>(&connection).await? {
|
||||
match message {
|
||||
OfferMessage::RequestChallenge(message) => {
|
||||
let WithChannels { tx, .. } = message;
|
||||
let _ = tx
|
||||
.send(ChallengeResponse {
|
||||
challenge: challenge.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
OfferMessage::DeliverGrant(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.pairing
|
||||
.receive_grant(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
OfferMessage::RevokeGrant(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.pairing
|
||||
.receive_revocation(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
OfferMessage::PollOffers(message) => {
|
||||
let WithChannels { tx, .. } = message;
|
||||
let response = self.handle_poll(&remote_endpoint_id).await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
OfferMessage::SubmitOffer(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.handle_offer(&remote_endpoint_id, &challenge, inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.closed().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OfferClient {
|
||||
inner: Client<OfferProtocol>,
|
||||
}
|
||||
|
||||
impl OfferClient {
|
||||
pub(crate) async fn poll_offers(&self) -> Result<PolledOffers, irpc::Error> {
|
||||
self.inner.rpc(PollOffers).await
|
||||
}
|
||||
|
||||
pub(crate) async fn request_challenge(&self) -> Result<Challenge, irpc::Error> {
|
||||
Ok(self.inner.rpc(RequestChallenge).await?.challenge)
|
||||
}
|
||||
|
||||
pub(crate) async fn submit_offer(
|
||||
&self,
|
||||
offer: SubmitOffer,
|
||||
) -> Result<OfferResponse, irpc::Error> {
|
||||
self.inner.rpc(offer).await
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_grant(
|
||||
&self,
|
||||
grant: DeliverGrant,
|
||||
) -> Result<GrantDeliveryResponse, irpc::Error> {
|
||||
self.inner.rpc(grant).await
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke_grant(
|
||||
&self,
|
||||
revocation: RevokeGrant,
|
||||
) -> Result<RevocationResponse, irpc::Error> {
|
||||
self.inner.rpc(revocation).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RequestChallenge;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ChallengeResponse {
|
||||
pub(crate) challenge: Challenge,
|
||||
}
|
||||
|
||||
/// Hand a peer the capability to reach this device.
|
||||
///
|
||||
/// Carries the secret itself, which is safe only because the iroh connection is
|
||||
/// already authenticated and encrypted to the recipient's endpoint key. The
|
||||
/// recipient still has to consent before it is stored.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct DeliverGrant {
|
||||
pub(crate) grant_id: GrantId,
|
||||
/// Hex-encoded grant secret.
|
||||
pub(crate) secret: String,
|
||||
pub(crate) expires_at: Option<i64>,
|
||||
/// Untrusted display data, shown only after the user consents.
|
||||
pub(crate) display_name: Option<String>,
|
||||
}
|
||||
|
||||
// The secret must not reach a log line through a derived Debug.
|
||||
impl fmt::Debug for DeliverGrant {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("DeliverGrant")
|
||||
.field("grant_id", &self.grant_id)
|
||||
.field("display_name", &self.display_name)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum GrantDeliveryResponse {
|
||||
/// Held pending the local user's decision. Not yet a contact.
|
||||
AwaitingConsent,
|
||||
/// Stored: the local user had already agreed to remember this device.
|
||||
Stored,
|
||||
Rejected {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tell a peer that a grant it holds is dead, so its entry disappears promptly
|
||||
/// rather than at its next attempt. Best effort: revocation is already complete
|
||||
/// on the issuing side before this is sent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RevokeGrant {
|
||||
pub(crate) grant_id: GrantId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum RevocationResponse {
|
||||
Removed,
|
||||
/// No such grant held from this peer. Also returned when the grant belongs
|
||||
/// to someone else, so a stranger cannot probe for grant ids.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Hand a paired device a ticket for content it may fetch.
|
||||
///
|
||||
/// The ticket is a capability, so this is sent only over a connection where the
|
||||
/// grant proof has already been presented.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct SubmitOffer {
|
||||
pub(crate) proof: GrantProof,
|
||||
pub(crate) ticket: String,
|
||||
pub(crate) transfer_name: String,
|
||||
pub(crate) sender_display_name: Option<String>,
|
||||
pub(crate) file_count: u64,
|
||||
pub(crate) total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum OfferResponse {
|
||||
/// The receiving user agreed. They fetch the content themselves next.
|
||||
Accepted,
|
||||
/// The receiving user said no, or never answered.
|
||||
Declined { reason: String },
|
||||
/// The grant did not validate. Names the reason so a peer holding a dead
|
||||
/// grant can clear it.
|
||||
Refused { reason: String },
|
||||
}
|
||||
|
||||
/// Ask a device whether it is holding anything for this one.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PollOffers;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PolledOffers {
|
||||
pub(crate) offers: Vec<PolledOffer>,
|
||||
}
|
||||
|
||||
/// An offer collected by polling rather than pushed. Carries the ticket because
|
||||
/// the sender already decided to send it to this endpoint; the local user still
|
||||
/// confirms before anything is fetched.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PolledOffer {
|
||||
pub(crate) ticket: String,
|
||||
pub(crate) transfer_name: String,
|
||||
pub(crate) sender_display_name: Option<String>,
|
||||
pub(crate) file_count: u64,
|
||||
pub(crate) total_bytes: u64,
|
||||
}
|
||||
|
||||
#[rpc_requests(message = OfferMessage)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
enum OfferProtocol {
|
||||
#[rpc(tx=oneshot::Sender<ChallengeResponse>)]
|
||||
RequestChallenge(RequestChallenge),
|
||||
#[rpc(tx=oneshot::Sender<GrantDeliveryResponse>)]
|
||||
DeliverGrant(DeliverGrant),
|
||||
#[rpc(tx=oneshot::Sender<RevocationResponse>)]
|
||||
RevokeGrant(RevokeGrant),
|
||||
#[rpc(tx=oneshot::Sender<OfferResponse>)]
|
||||
SubmitOffer(SubmitOffer),
|
||||
#[rpc(tx=oneshot::Sender<PolledOffers>)]
|
||||
PollOffers(PollOffers),
|
||||
}
|
||||
277
crates/vnidrop/src/offer_inbox.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! Incoming transfer offers from paired devices.
|
||||
//!
|
||||
//! An offer is only a delivery mechanism for a ticket: it replaces the QR code,
|
||||
//! not the transfer. Accepting hands the ticket to the platform layer, which
|
||||
//! runs the ordinary receive with its own destination rules.
|
||||
//!
|
||||
//! Nothing in this inbox is persisted: a prompt belongs to a live connection,
|
||||
//! so a restart correctly loses it rather than resurrecting one whose sender is
|
||||
//! long gone. Offers the *sender* could not deliver are a different thing and
|
||||
//! do persist — see `held_offers` in [`crate::contacts`].
|
||||
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use serde_json::json;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{event_hub::EventHub, offer::OfferResponse, util::now_ms};
|
||||
|
||||
/// How long the sender waits for the receiving user to decide.
|
||||
const OFFER_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Quiet period after a decline, so a paired device cannot re-prompt on a loop.
|
||||
/// A contact is not a stranger, but it is not unlimited either.
|
||||
const DECLINE_COOLDOWN_MS: i64 = 60 * 1_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingOffer {
|
||||
pub(crate) offer_id: String,
|
||||
pub(crate) from_endpoint_id: String,
|
||||
pub(crate) sender_display_name: Option<String>,
|
||||
pub(crate) transfer_name: String,
|
||||
pub(crate) file_count: u64,
|
||||
pub(crate) total_bytes: u64,
|
||||
pub(crate) received_at: i64,
|
||||
/// Released to the caller only once the local user accepts.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
struct Waiter {
|
||||
endpoint_id: String,
|
||||
responder: oneshot::Sender<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct OfferInbox {
|
||||
event_hub: Arc<EventHub>,
|
||||
pending: Arc<Mutex<HashMap<String, PendingOffer>>>,
|
||||
waiters: Arc<Mutex<HashMap<String, Waiter>>>,
|
||||
/// Endpoint → time before which new offers are refused.
|
||||
cooldowns: Arc<Mutex<HashMap<String, i64>>>,
|
||||
max_pending: usize,
|
||||
}
|
||||
|
||||
impl OfferInbox {
|
||||
pub(crate) fn new(event_hub: Arc<EventHub>, max_pending: usize) -> Self {
|
||||
Self {
|
||||
event_hub,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
waiters: Arc::new(Mutex::new(HashMap::new())),
|
||||
cooldowns: Arc::new(Mutex::new(HashMap::new())),
|
||||
max_pending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface an offer and block until the local user decides.
|
||||
///
|
||||
/// The caller has already proven a live grant, so this is a known device;
|
||||
/// the limits here bound nuisance rather than attack.
|
||||
pub(crate) async fn submit(
|
||||
&self,
|
||||
from_endpoint_id: String,
|
||||
transfer_name: String,
|
||||
sender_display_name: Option<String>,
|
||||
file_count: u64,
|
||||
total_bytes: u64,
|
||||
ticket: String,
|
||||
) -> OfferResponse {
|
||||
let now = now_ms();
|
||||
{
|
||||
let mut cooldowns = self.cooldowns.lock().await;
|
||||
cooldowns.retain(|_, until| *until > now);
|
||||
if cooldowns.contains_key(&from_endpoint_id) {
|
||||
return OfferResponse::Declined {
|
||||
reason: "declined-recently".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let offer_id = Uuid::new_v4().to_string();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
if pending.len() >= self.max_pending {
|
||||
return OfferResponse::Declined {
|
||||
reason: "too-many-pending-offers".to_string(),
|
||||
};
|
||||
}
|
||||
// One prompt per device at a time: a second offer would stack
|
||||
// notifications for the same sender.
|
||||
if pending
|
||||
.values()
|
||||
.any(|offer| offer.from_endpoint_id == from_endpoint_id)
|
||||
{
|
||||
return OfferResponse::Declined {
|
||||
reason: "offer-already-pending".to_string(),
|
||||
};
|
||||
}
|
||||
pending.insert(
|
||||
offer_id.clone(),
|
||||
PendingOffer {
|
||||
offer_id: offer_id.clone(),
|
||||
from_endpoint_id: from_endpoint_id.clone(),
|
||||
sender_display_name: sender_display_name.clone(),
|
||||
transfer_name: transfer_name.clone(),
|
||||
file_count,
|
||||
total_bytes,
|
||||
received_at: now,
|
||||
ticket,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.waiters.lock().await.insert(
|
||||
offer_id.clone(),
|
||||
Waiter {
|
||||
endpoint_id: from_endpoint_id.clone(),
|
||||
responder: tx,
|
||||
},
|
||||
);
|
||||
|
||||
// The ticket is deliberately absent: an event is a log record, and a
|
||||
// ticket is a capability.
|
||||
self.event_hub.emit_endpoint(
|
||||
"offer",
|
||||
"offer-received",
|
||||
json!({
|
||||
"offer_id": offer_id,
|
||||
"from_endpoint_id": from_endpoint_id,
|
||||
"sender_display_name": sender_display_name,
|
||||
"transfer_name": transfer_name,
|
||||
"file_count": file_count,
|
||||
"total_bytes": total_bytes,
|
||||
}),
|
||||
);
|
||||
|
||||
match tokio::time::timeout(OFFER_WAIT_TIMEOUT, rx).await {
|
||||
Ok(Ok(true)) => OfferResponse::Accepted,
|
||||
Ok(Ok(false)) => OfferResponse::Declined {
|
||||
reason: "receiver-declined".to_string(),
|
||||
},
|
||||
// Dropped responder or timeout: clear the prompt so it cannot
|
||||
// linger after the sender has given up.
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
self.discard(&offer_id).await;
|
||||
OfferResponse::Declined {
|
||||
reason: "no-response".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an offer collected by polling.
|
||||
///
|
||||
/// Unlike [`Self::submit`] there is no remote waiting on the answer: the
|
||||
/// sender handed the ticket over and moved on, so this returns immediately.
|
||||
pub(crate) async fn enqueue(
|
||||
&self,
|
||||
from_endpoint_id: String,
|
||||
transfer_name: String,
|
||||
sender_display_name: Option<String>,
|
||||
file_count: u64,
|
||||
total_bytes: u64,
|
||||
ticket: String,
|
||||
) -> bool {
|
||||
let offer_id = uuid::Uuid::new_v4().to_string();
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
if pending.len() >= self.max_pending {
|
||||
return false;
|
||||
}
|
||||
if pending
|
||||
.values()
|
||||
.any(|offer| offer.from_endpoint_id == from_endpoint_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
pending.insert(
|
||||
offer_id.clone(),
|
||||
PendingOffer {
|
||||
offer_id: offer_id.clone(),
|
||||
from_endpoint_id: from_endpoint_id.clone(),
|
||||
sender_display_name: sender_display_name.clone(),
|
||||
transfer_name: transfer_name.clone(),
|
||||
file_count,
|
||||
total_bytes,
|
||||
received_at: now_ms(),
|
||||
ticket,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.event_hub.emit_endpoint(
|
||||
"offer",
|
||||
"offer-collected",
|
||||
json!({
|
||||
"offer_id": offer_id,
|
||||
"from_endpoint_id": from_endpoint_id,
|
||||
"sender_display_name": sender_display_name,
|
||||
"transfer_name": transfer_name,
|
||||
"file_count": file_count,
|
||||
"total_bytes": total_bytes,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn list(&self) -> Vec<PendingOffer> {
|
||||
self.pending.lock().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Record the local user's decision.
|
||||
///
|
||||
/// Returns the ticket on acceptance: it leaves the core at the moment of
|
||||
/// consent and not before, so a declined offer never hands over a
|
||||
/// capability. The caller then runs the ordinary receive with it.
|
||||
pub(crate) async fn respond(&self, offer_id: &str, accepted: bool) -> Option<String> {
|
||||
let offer = self.pending.lock().await.remove(offer_id)?;
|
||||
let waiter = self.waiters.lock().await.remove(offer_id);
|
||||
|
||||
if !accepted {
|
||||
self.cooldowns.lock().await.insert(
|
||||
offer.from_endpoint_id.clone(),
|
||||
now_ms() + DECLINE_COOLDOWN_MS,
|
||||
);
|
||||
}
|
||||
if let Some(waiter) = waiter {
|
||||
let _ = waiter.responder.send(accepted);
|
||||
}
|
||||
self.event_hub.emit_endpoint(
|
||||
"offer",
|
||||
if accepted {
|
||||
"offer-accepted"
|
||||
} else {
|
||||
"offer-declined"
|
||||
},
|
||||
json!({
|
||||
"offer_id": offer_id,
|
||||
"from_endpoint_id": offer.from_endpoint_id,
|
||||
}),
|
||||
);
|
||||
|
||||
accepted.then_some(offer.ticket)
|
||||
}
|
||||
|
||||
/// Drop every prompt from a device, used when it is forgotten or blocked
|
||||
/// while an offer is on screen.
|
||||
pub(crate) async fn discard_from(&self, endpoint_id: &str) {
|
||||
let ids: Vec<String> = {
|
||||
let pending = self.pending.lock().await;
|
||||
pending
|
||||
.values()
|
||||
.filter(|offer| offer.from_endpoint_id == endpoint_id)
|
||||
.map(|offer| offer.offer_id.clone())
|
||||
.collect()
|
||||
};
|
||||
for offer_id in ids {
|
||||
self.discard(&offer_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn discard(&self, offer_id: &str) {
|
||||
self.pending.lock().await.remove(offer_id);
|
||||
if let Some(waiter) = self.waiters.lock().await.remove(offer_id) {
|
||||
let _ = waiter.responder.send(false);
|
||||
let _ = waiter.endpoint_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
386
crates/vnidrop/src/pairing.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
//! Consent and grant exchange for device history.
|
||||
//!
|
||||
//! Mirrors [`crate::approval`]: the protocol handler stays thin and the
|
||||
//! decisions live here. The rule this module exists to enforce is that a device
|
||||
//! is remembered only if *both* sides agree — refusing to issue a grant leaves
|
||||
//! the peer with a contact entry that cannot do anything.
|
||||
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
contacts::ContactStore,
|
||||
event_hub::EventHub,
|
||||
grant::{
|
||||
Challenge, GrantLifetime, GrantProof, GrantRejection, GrantSecret, HeldGrant, IssuedGrant,
|
||||
},
|
||||
offer::{DeliverGrant, GrantDeliveryResponse, PolledOffer, RevocationResponse, RevokeGrant},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
/// How long an incoming grant waits for the local user's decision.
|
||||
///
|
||||
/// Bounded so a peer cannot park entries in memory indefinitely, and short
|
||||
/// enough that a stale prompt does not outlive the context the user remembers.
|
||||
const CONSENT_WINDOW: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// A grant a peer has offered, waiting on the local user.
|
||||
///
|
||||
/// Not persisted: if the app restarts, the prompt is gone and the peer can
|
||||
/// offer again. Persisting would resurrect prompts whose context the user has
|
||||
/// long forgotten.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingGrant {
|
||||
pub(crate) peer_endpoint_id: String,
|
||||
pub(crate) display_name: Option<String>,
|
||||
pub(crate) received_at: i64,
|
||||
grant: HeldGrant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PairingService {
|
||||
contacts: ContactStore,
|
||||
event_hub: Arc<EventHub>,
|
||||
/// Keyed by peer endpoint id: one outstanding offer per peer, so a peer
|
||||
/// cannot flood the prompt queue by reconnecting.
|
||||
pending: Arc<Mutex<HashMap<String, PendingGrant>>>,
|
||||
max_pending: usize,
|
||||
max_metadata_bytes: u64,
|
||||
lifetime: Arc<Mutex<GrantLifetime>>,
|
||||
}
|
||||
|
||||
impl PairingService {
|
||||
pub(crate) fn new(
|
||||
contacts: ContactStore,
|
||||
event_hub: Arc<EventHub>,
|
||||
max_pending: usize,
|
||||
max_metadata_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
contacts,
|
||||
event_hub,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
max_pending,
|
||||
max_metadata_bytes,
|
||||
lifetime: Arc::new(Mutex::new(GrantLifetime::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_grant_lifetime(&self, lifetime: GrantLifetime) {
|
||||
*self.lifetime.lock().await = lifetime;
|
||||
}
|
||||
|
||||
pub(crate) async fn grant_lifetime(&self) -> GrantLifetime {
|
||||
*self.lifetime.lock().await
|
||||
}
|
||||
|
||||
// -- inbound ----------------------------------------------------------
|
||||
|
||||
/// A peer offers this device the capability to reach it.
|
||||
///
|
||||
/// Never stored on arrival: an unsolicited grant would otherwise create a
|
||||
/// contact the local user never agreed to. It waits for consent instead.
|
||||
pub(crate) async fn receive_grant(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
delivery: DeliverGrant,
|
||||
) -> GrantDeliveryResponse {
|
||||
if self
|
||||
.contacts
|
||||
.is_blocked(&peer_endpoint_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Indistinguishable from any other refusal: blocking must not be
|
||||
// detectable by probing.
|
||||
return GrantDeliveryResponse::Rejected {
|
||||
reason: "not-accepted".to_string(),
|
||||
};
|
||||
}
|
||||
if delivery
|
||||
.display_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.len() as u64 > self.max_metadata_bytes)
|
||||
{
|
||||
return GrantDeliveryResponse::Rejected {
|
||||
reason: "metadata-too-large".to_string(),
|
||||
};
|
||||
}
|
||||
let secret = match GrantSecret::decode(&delivery.secret) {
|
||||
Ok(secret) => secret,
|
||||
Err(_) => {
|
||||
return GrantDeliveryResponse::Rejected {
|
||||
reason: "malformed-grant".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let now = now_ms();
|
||||
let held = HeldGrant {
|
||||
grant_id: delivery.grant_id,
|
||||
secret,
|
||||
peer_endpoint_id: peer_endpoint_id.clone(),
|
||||
created_at: now,
|
||||
expires_at: delivery.expires_at,
|
||||
};
|
||||
|
||||
// Already a contact: the user agreed to this relationship, so a refreshed
|
||||
// grant (re-pairing, or a renewal after reinstall) replaces the old one
|
||||
// without prompting again.
|
||||
let already_known = self
|
||||
.contacts
|
||||
.find_contact(&peer_endpoint_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if already_known {
|
||||
if self.contacts.insert_held_grant(&held).await.is_err() {
|
||||
return GrantDeliveryResponse::Rejected {
|
||||
reason: "storage-error".to_string(),
|
||||
};
|
||||
}
|
||||
self.emit(
|
||||
"grant-refreshed",
|
||||
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||
);
|
||||
return GrantDeliveryResponse::Stored;
|
||||
}
|
||||
|
||||
let mut pending = self.pending.lock().await;
|
||||
self.drop_expired(&mut pending, now);
|
||||
if !pending.contains_key(&peer_endpoint_id) && pending.len() >= self.max_pending {
|
||||
drop(pending);
|
||||
return GrantDeliveryResponse::Rejected {
|
||||
reason: "too-many-pending".to_string(),
|
||||
};
|
||||
}
|
||||
pending.insert(
|
||||
peer_endpoint_id.clone(),
|
||||
PendingGrant {
|
||||
peer_endpoint_id: peer_endpoint_id.clone(),
|
||||
display_name: delivery.display_name.clone(),
|
||||
received_at: now,
|
||||
grant: held,
|
||||
},
|
||||
);
|
||||
drop(pending);
|
||||
|
||||
self.emit(
|
||||
"pairing-requested",
|
||||
json!({
|
||||
"peer_endpoint_id": peer_endpoint_id,
|
||||
"display_name": delivery.display_name,
|
||||
}),
|
||||
);
|
||||
GrantDeliveryResponse::AwaitingConsent
|
||||
}
|
||||
|
||||
/// A peer reports that a grant this device holds is dead.
|
||||
///
|
||||
/// Only the issuer may retire its own grant, so the held record must name
|
||||
/// this peer. A mismatch answers `Unknown` rather than an error, so a
|
||||
/// stranger cannot probe for grant ids belonging to someone else.
|
||||
pub(crate) async fn receive_revocation(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
revocation: RevokeGrant,
|
||||
) -> RevocationResponse {
|
||||
let held = self
|
||||
.contacts
|
||||
.held_grant_for(&peer_endpoint_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(held) = held else {
|
||||
return RevocationResponse::Unknown;
|
||||
};
|
||||
if held.grant_id != revocation.grant_id {
|
||||
return RevocationResponse::Unknown;
|
||||
}
|
||||
if self
|
||||
.contacts
|
||||
.delete_held_grant(revocation.grant_id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return RevocationResponse::Unknown;
|
||||
}
|
||||
self.emit(
|
||||
"contact-revoked-by-peer",
|
||||
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||
);
|
||||
RevocationResponse::Removed
|
||||
}
|
||||
|
||||
// -- local decisions --------------------------------------------------
|
||||
|
||||
pub(crate) async fn list_pending_grants(&self) -> Vec<PendingGrant> {
|
||||
let mut pending = self.pending.lock().await;
|
||||
self.drop_expired(&mut pending, now_ms());
|
||||
pending.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Accept a peer's offer to be remembered.
|
||||
///
|
||||
/// Stores their grant and records the contact. Issuing our own grant in
|
||||
/// return is a separate decision the caller makes, because "I want to reach
|
||||
/// them" and "they may reach me" are independent.
|
||||
pub(crate) async fn accept_pending_grant(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
let pending = {
|
||||
let mut pending = self.pending.lock().await;
|
||||
self.drop_expired(&mut pending, now_ms());
|
||||
pending.remove(peer_endpoint_id)
|
||||
};
|
||||
let Some(pending) = pending else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
self.contacts
|
||||
.upsert_contact(
|
||||
peer_endpoint_id,
|
||||
pending.display_name.as_deref(),
|
||||
pending.received_at,
|
||||
)
|
||||
.await?;
|
||||
self.contacts.insert_held_grant(&pending.grant).await?;
|
||||
self.emit(
|
||||
"contact-added",
|
||||
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Decline to be reachable through this peer's grant. The grant is dropped
|
||||
/// unstored, so nothing about the peer is retained.
|
||||
pub(crate) async fn decline_pending_grant(&self, peer_endpoint_id: &str) -> bool {
|
||||
let removed = {
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(peer_endpoint_id).is_some()
|
||||
};
|
||||
if removed {
|
||||
self.emit(
|
||||
"pairing-declined",
|
||||
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||
);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Mint a grant for a peer: our consent to be reached by them.
|
||||
///
|
||||
/// The caller delivers it over the offer protocol. Persisted before
|
||||
/// delivery so a grant we may already have handed over is never forgotten.
|
||||
pub(crate) async fn issue_grant(&self, peer_endpoint_id: &str) -> anyhow::Result<IssuedGrant> {
|
||||
let lifetime = self.grant_lifetime().await;
|
||||
let grant = IssuedGrant::mint(peer_endpoint_id.to_string(), now_ms(), lifetime);
|
||||
self.contacts.insert_issued_grant(&grant).await?;
|
||||
self.contacts
|
||||
.upsert_contact(peer_endpoint_id, None, now_ms())
|
||||
.await?;
|
||||
self.emit(
|
||||
"grant-issued",
|
||||
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||
);
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
/// Held offers addressed to `endpoint_id`, consumed as they are handed over.
|
||||
///
|
||||
/// Deleting on delivery is what keeps a device that polls twice from being
|
||||
/// offered the same transfer again.
|
||||
pub(crate) async fn collect_held_offers(&self, endpoint_id: &str) -> Vec<PolledOffer> {
|
||||
if self.contacts.is_blocked(endpoint_id).await.unwrap_or(false) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(held) = self.contacts.held_offers_for(endpoint_id).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
if held.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ids: Vec<String> = held.iter().map(|offer| offer.offer_id.clone()).collect();
|
||||
if let Err(error) = self.contacts.delete_held_offers(&ids).await {
|
||||
// Handing the same offer over twice is worse than not handing it
|
||||
// over at all, so a failed consume aborts the delivery.
|
||||
tracing::warn!(%error, "failed to consume held offers");
|
||||
return Vec::new();
|
||||
}
|
||||
self.emit(
|
||||
"held-offers-collected",
|
||||
json!({ "peer_endpoint_id": endpoint_id, "count": held.len() }),
|
||||
);
|
||||
held.into_iter()
|
||||
.map(|offer| PolledOffer {
|
||||
ticket: offer.ticket,
|
||||
transfer_name: offer.transfer_name,
|
||||
sender_display_name: offer.sender_display_name,
|
||||
file_count: offer.file_count,
|
||||
total_bytes: offer.total_bytes,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate a proof a peer presented, and push the idle deadline forward.
|
||||
///
|
||||
/// The grant record is ours: we issued it, so we are the only party that
|
||||
/// can decide it is still alive. A blocked endpoint is answered `Unknown`,
|
||||
/// the same as one we never issued to.
|
||||
pub(crate) async fn verify_and_renew(
|
||||
&self,
|
||||
proof: &GrantProof,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
remote_endpoint_id: &str,
|
||||
) -> Result<(), GrantRejection> {
|
||||
if self
|
||||
.contacts
|
||||
.is_blocked(remote_endpoint_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(GrantRejection::Unknown);
|
||||
}
|
||||
let grant = self
|
||||
.contacts
|
||||
.find_issued_grant(proof.grant_id)
|
||||
.await
|
||||
.map_err(|_| GrantRejection::Unknown)?
|
||||
.ok_or(GrantRejection::Unknown)?;
|
||||
|
||||
let now = now_ms();
|
||||
let lifetime = self.grant_lifetime().await;
|
||||
let renewed = grant.accept(
|
||||
proof,
|
||||
challenge,
|
||||
issuer_endpoint_id,
|
||||
remote_endpoint_id,
|
||||
now,
|
||||
lifetime,
|
||||
)?;
|
||||
// A failed renewal is not grounds to refuse a peer that just proved
|
||||
// possession; the grant stays valid until its existing deadline.
|
||||
if let Err(error) = self
|
||||
.contacts
|
||||
.renew_issued_grant(proof.grant_id, renewed)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error, "failed to renew grant deadline");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_expired(&self, pending: &mut HashMap<String, PendingGrant>, now_ms: i64) {
|
||||
let window = CONSENT_WINDOW.as_millis() as i64;
|
||||
pending.retain(|_, entry| now_ms - entry.received_at < window);
|
||||
}
|
||||
|
||||
fn emit(&self, kind: &str, data: serde_json::Value) {
|
||||
self.event_hub.emit_endpoint("contacts", kind, data);
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,12 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
access_policy::mode_from_storage,
|
||||
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
|
||||
contacts::ContactStore,
|
||||
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 7;
|
||||
const SCHEMA_VERSION: i64 = 9;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
@@ -315,12 +316,20 @@ impl Repository {
|
||||
.await?;
|
||||
}
|
||||
|
||||
crate::contacts::ensure_schema(&self.pool).await?;
|
||||
|
||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Device history, grants, and the block list. Shares this pool so the
|
||||
/// tables migrate together with the rest of the schema.
|
||||
pub(crate) fn contacts(&self) -> ContactStore {
|
||||
ContactStore::new(self.pool.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
||||
let row = sqlx::query("PRAGMA user_version")
|
||||
|
||||
695
crates/vnidrop/src/runtime/contacts.rs
Normal file
@@ -0,0 +1,695 @@
|
||||
//! Runtime operations for device history: pairing, forgetting, and blocking.
|
||||
//!
|
||||
//! The protocol side lives in [`crate::offer`] and the decisions in
|
||||
//! [`crate::pairing`]; this is where those meet the endpoint and the UniFFI
|
||||
//! surface.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{CoreInner, POLL_MIN_INTERVAL_MS};
|
||||
use crate::{
|
||||
api::{
|
||||
ContactSendResult, ContactSummary, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
|
||||
PendingPairing, ShareMetadataInput, ShareResult, ShareSource, TransferAccessMode,
|
||||
},
|
||||
contacts::HeldOffer,
|
||||
error::VnidropError,
|
||||
grant::{GrantId, HeldGrant},
|
||||
offer::{
|
||||
DeliverGrant, GrantDeliveryResponse, OfferResponse, OfferService, RevokeGrant, SubmitOffer,
|
||||
},
|
||||
ticket::{encode_persisted_sender_address, parse_persisted_sender_address},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
/// How long to wait for a device to answer before treating it as not running.
|
||||
///
|
||||
/// Without this an offline peer never fails, it just keeps being retried, and
|
||||
/// the offer is never handed to the hold-for-later path.
|
||||
const OFFER_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Whether a device may be polled again yet.
|
||||
///
|
||||
/// Split out because the surrounding call needs two live nodes to exercise,
|
||||
/// while the window itself is worth asserting on its own.
|
||||
pub(crate) fn should_poll(last_polled_ms: Option<i64>, now_ms: i64) -> bool {
|
||||
last_polled_ms.is_none_or(|last| now_ms - last >= POLL_MIN_INTERVAL_MS)
|
||||
}
|
||||
|
||||
impl CoreInner {
|
||||
pub(super) async fn list_contacts(&self) -> Result<Vec<ContactSummary>> {
|
||||
let contacts = self
|
||||
.repository
|
||||
.contacts()
|
||||
.list_contacts()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let store = self.repository.contacts();
|
||||
let mut summaries = Vec::with_capacity(contacts.len());
|
||||
for contact in contacts {
|
||||
// "Can I reach them" is exactly "do I hold a live grant", so the two
|
||||
// never drift apart in the UI.
|
||||
let can_send = store
|
||||
.held_grant_for(&contact.endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.is_some();
|
||||
summaries.push(ContactSummary {
|
||||
endpoint_id: contact.endpoint_id,
|
||||
local_label: contact.local_label,
|
||||
remote_display_name: contact.remote_display_name,
|
||||
last_transfer_at: contact.last_transfer_at,
|
||||
created_at: contact.created_at,
|
||||
can_send,
|
||||
});
|
||||
}
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
pub(super) async fn list_pending_pairings(&self) -> Vec<PendingPairing> {
|
||||
self.pairing
|
||||
.list_pending_grants()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|pending| PendingPairing {
|
||||
endpoint_id: pending.peer_endpoint_id,
|
||||
display_name: pending.display_name,
|
||||
received_at: pending.received_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Agree to be remembered by a peer, and hand them the capability to reach
|
||||
/// us.
|
||||
///
|
||||
/// The grant is persisted before delivery: a grant that may already have
|
||||
/// arrived must never be one we have forgotten issuing, or the peer would
|
||||
/// hold a capability we cannot validate or revoke.
|
||||
pub(super) async fn allow_device_to_reach_me(
|
||||
self: &Arc<Self>,
|
||||
endpoint_id: String,
|
||||
display_name: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.limits
|
||||
.validate_metadata_text("display name", display_name.as_deref())
|
||||
.map_err(VnidropError::invalid_input)?;
|
||||
if self
|
||||
.repository
|
||||
.contacts()
|
||||
.is_blocked(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
{
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"endpoint is blocked; unblock it before pairing"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let grant = self
|
||||
.pairing
|
||||
.issue_grant(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
|
||||
let addr = self.contact_addr(&endpoint_id).await?;
|
||||
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||
let response = client
|
||||
.deliver_grant(DeliverGrant {
|
||||
grant_id: grant.grant_id,
|
||||
secret: grant.secret.encode(),
|
||||
expires_at: grant.expires_at,
|
||||
display_name,
|
||||
})
|
||||
.await
|
||||
.context("failed to deliver grant")
|
||||
.map_err(VnidropError::transfer)?;
|
||||
|
||||
match response {
|
||||
GrantDeliveryResponse::AwaitingConsent | GrantDeliveryResponse::Stored => {
|
||||
self.remember_addr(&endpoint_id).await;
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"grant-delivered",
|
||||
json!({ "peer_endpoint_id": endpoint_id }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
GrantDeliveryResponse::Rejected { reason } => {
|
||||
// The peer would not take it, so the grant we just minted can
|
||||
// never be used. Retire it rather than leaving a live
|
||||
// capability nobody holds.
|
||||
let _ = self
|
||||
.repository
|
||||
.contacts()
|
||||
.revoke_issued_grant(grant.grant_id, now_ms())
|
||||
.await;
|
||||
Err(
|
||||
VnidropError::transfer(anyhow::anyhow!("peer refused the pairing: {reason}"))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Share content and push the ticket straight to a paired device.
|
||||
///
|
||||
/// Two things make this one prompt rather than two: the share is created
|
||||
/// with the ticket never leaving this device except over the authenticated
|
||||
/// offer connection, and the target endpoint is pre-authorised so the
|
||||
/// handshake it runs next does not ask us to approve a transfer we started.
|
||||
pub(super) async fn send_to_contact(
|
||||
self: &Arc<Self>,
|
||||
endpoint_id: String,
|
||||
sources: Vec<ShareSource>,
|
||||
mut metadata: ShareMetadataInput,
|
||||
) -> Result<ContactSendResult> {
|
||||
let store = self.repository.contacts();
|
||||
let grant = store
|
||||
.held_grant_for(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.ok_or_else(|| {
|
||||
VnidropError::permission(anyhow::anyhow!(
|
||||
"no live grant for this device; pair with it again"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Invariant: an offer-created share is never public. The recipient is a
|
||||
// specific device, so serving it to anyone holding the ticket would
|
||||
// widen access beyond what the user asked for.
|
||||
metadata.access_mode = TransferAccessMode::ApprovalRequired;
|
||||
let sender_name = metadata.sender_name.clone();
|
||||
let share = self.share_files(sources, metadata).await?;
|
||||
self.offer_share(endpoint_id, grant, share, sender_name.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Offer a share that already exists, so a transfer created for an
|
||||
/// invitation can also be pushed to a remembered device.
|
||||
///
|
||||
/// The ticket is the one already stored for the transfer: this adds another
|
||||
/// way to deliver it, it does not create a second share of the same files.
|
||||
pub(super) async fn offer_transfer_to_contact(
|
||||
self: &Arc<Self>,
|
||||
transfer_id: u64,
|
||||
endpoint_id: String,
|
||||
) -> Result<ContactSendResult> {
|
||||
let grant = self
|
||||
.repository
|
||||
.contacts()
|
||||
.held_grant_for(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.ok_or_else(|| {
|
||||
VnidropError::permission(anyhow::anyhow!(
|
||||
"no live grant for this device; pair with it again"
|
||||
))
|
||||
})?;
|
||||
|
||||
let stored = self
|
||||
.repository
|
||||
.list_transfers()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?
|
||||
.into_iter()
|
||||
.find(|transfer| transfer.transfer_id == transfer_id)
|
||||
.ok_or_else(|| {
|
||||
VnidropError::invalid_input(anyhow::anyhow!("unknown transfer {transfer_id}"))
|
||||
})?;
|
||||
|
||||
// Only a live share can be offered: a stopped one no longer serves its
|
||||
// content, so handing out its ticket would promise nothing.
|
||||
if stored.direction != TransferDirection::Send.as_str()
|
||||
|| stored.status != TransferStatus::Sharing.as_str()
|
||||
{
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"transfer {transfer_id} is not an active share"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let ticket = stored.ticket.clone().ok_or_else(|| {
|
||||
VnidropError::invalid_input(anyhow::anyhow!("transfer {transfer_id} has no invitation"))
|
||||
})?;
|
||||
|
||||
let share = ShareResult {
|
||||
transfer_id,
|
||||
ticket,
|
||||
hash: stored.content_hash.unwrap_or_default(),
|
||||
transfer_name: stored.transfer_name.unwrap_or_default(),
|
||||
file_count: stored.file_count,
|
||||
total_size: stored.total_size,
|
||||
};
|
||||
self.offer_share(endpoint_id, grant, share, None).await
|
||||
}
|
||||
|
||||
/// Deliver an offer for `share`, holding it when the device is not running.
|
||||
async fn offer_share(
|
||||
self: &Arc<Self>,
|
||||
endpoint_id: String,
|
||||
grant: HeldGrant,
|
||||
share: ShareResult,
|
||||
sender_name: Option<&str>,
|
||||
) -> Result<ContactSendResult> {
|
||||
let store = self.repository.contacts();
|
||||
|
||||
// An unreachable device is the common case on mobile, not an error: the
|
||||
// share stays here and the ticket waits for the peer to come and get it.
|
||||
let outcome = match self
|
||||
.deliver_offer(&endpoint_id, &grant, &share, sender_name)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
self.hold_offer(&endpoint_id, &share, sender_name).await?;
|
||||
tracing::debug!(%error, "offer held for later pickup");
|
||||
return Ok(ContactSendResult {
|
||||
share,
|
||||
delivered: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
OfferResponse::Accepted => {
|
||||
store
|
||||
.touch_transfer(&endpoint_id, now_ms())
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
self.remember_addr(&endpoint_id).await;
|
||||
self.emit_transfer(
|
||||
share.transfer_id,
|
||||
"send",
|
||||
"offer",
|
||||
"offer-accepted",
|
||||
json!({ "peer_endpoint_id": endpoint_id }),
|
||||
);
|
||||
Ok(ContactSendResult {
|
||||
share,
|
||||
delivered: true,
|
||||
})
|
||||
}
|
||||
OfferResponse::Declined { reason } | OfferResponse::Refused { reason } => {
|
||||
let _ = self.cancel_idle_or_share(share.transfer_id).await;
|
||||
self.emit_transfer(
|
||||
share.transfer_id,
|
||||
"send",
|
||||
"offer",
|
||||
"offer-refused",
|
||||
json!({ "peer_endpoint_id": endpoint_id, "reason": reason }),
|
||||
);
|
||||
// A refusal naming a dead grant is the peer telling us to stop
|
||||
// believing we can reach them.
|
||||
if matches!(reason.as_str(), "revoked" | "unknown" | "expired") {
|
||||
let _ = store.delete_held_grant(grant.grant_id).await;
|
||||
}
|
||||
Err(VnidropError::permission(anyhow::anyhow!(
|
||||
"device did not accept the transfer: {reason}"
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep an undeliverable offer on this device.
|
||||
///
|
||||
/// The target is pre-authorised now rather than at pickup: it will dial
|
||||
/// straight back after collecting the ticket, and the session outlives the
|
||||
/// round trip.
|
||||
async fn hold_offer(
|
||||
self: &Arc<Self>,
|
||||
endpoint_id: &str,
|
||||
share: &ShareResult,
|
||||
sender_name: Option<&str>,
|
||||
) -> Result<()> {
|
||||
self.access_policy
|
||||
.approve_endpoint(share.transfer_id, endpoint_id.to_string())
|
||||
.await;
|
||||
self.repository
|
||||
.contacts()
|
||||
.insert_held_offer(&HeldOffer {
|
||||
offer_id: uuid::Uuid::new_v4().to_string(),
|
||||
endpoint_id: endpoint_id.to_string(),
|
||||
transfer_id: share.transfer_id,
|
||||
ticket: share.ticket.clone(),
|
||||
transfer_name: share.transfer_name.clone(),
|
||||
sender_display_name: sender_name.map(ToOwned::to_owned),
|
||||
file_count: share.file_count,
|
||||
total_bytes: share.total_size,
|
||||
created_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
self.emit_transfer(
|
||||
share.transfer_id,
|
||||
"send",
|
||||
"offer",
|
||||
"offer-held",
|
||||
json!({ "peer_endpoint_id": endpoint_id }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask remembered devices whether they are holding anything for this one.
|
||||
///
|
||||
/// Deliberately only ever called from a foreground transition or an explicit
|
||||
/// user action: polling reveals to every contact that the app was opened,
|
||||
/// which is why it is neither automatic nor backgrounded.
|
||||
pub(super) async fn poll_contacts_for_offers(self: &Arc<Self>) -> Result<u64> {
|
||||
let store = self.repository.contacts();
|
||||
let contacts = store
|
||||
.list_contacts()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let now = now_ms();
|
||||
let mut collected = 0u64;
|
||||
|
||||
for contact in contacts {
|
||||
if store
|
||||
.is_blocked(&contact.endpoint_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
{
|
||||
// Rate limited per device so repeated app switching does not
|
||||
// turn into a presence beacon.
|
||||
let mut polled = self.last_polled.lock().await;
|
||||
if !should_poll(polled.get(&contact.endpoint_id).copied(), now) {
|
||||
continue;
|
||||
}
|
||||
polled.insert(contact.endpoint_id.clone(), now);
|
||||
}
|
||||
|
||||
let Ok(addr) = self.contact_addr(&contact.endpoint_id).await else {
|
||||
continue;
|
||||
};
|
||||
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||
let Ok(polled) = client.poll_offers().await else {
|
||||
// Offline is the expected outcome, not a failure worth surfacing.
|
||||
continue;
|
||||
};
|
||||
for offer in polled.offers {
|
||||
let added = self
|
||||
.offers
|
||||
.enqueue(
|
||||
contact.endpoint_id.clone(),
|
||||
offer.transfer_name,
|
||||
offer.sender_display_name,
|
||||
offer.file_count,
|
||||
offer.total_bytes,
|
||||
offer.ticket,
|
||||
)
|
||||
.await;
|
||||
if added {
|
||||
collected += 1;
|
||||
}
|
||||
}
|
||||
self.remember_addr(&contact.endpoint_id).await;
|
||||
}
|
||||
Ok(collected)
|
||||
}
|
||||
|
||||
async fn deliver_offer(
|
||||
self: &Arc<Self>,
|
||||
endpoint_id: &str,
|
||||
grant: &HeldGrant,
|
||||
share: &ShareResult,
|
||||
sender_name: Option<&str>,
|
||||
) -> Result<OfferResponse> {
|
||||
let addr = self.contact_addr(endpoint_id).await?;
|
||||
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||
let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge())
|
||||
.await
|
||||
.map_err(|_| VnidropError::transfer(anyhow::anyhow!("device did not answer in time")))?
|
||||
.context("device is not reachable")
|
||||
.map_err(VnidropError::transfer)?;
|
||||
|
||||
// Authorise before offering: the receiver may dial back the instant it
|
||||
// accepts, and an unauthorised endpoint would be refused by the
|
||||
// provider.
|
||||
self.access_policy
|
||||
.approve_endpoint(share.transfer_id, endpoint_id.to_string())
|
||||
.await;
|
||||
|
||||
client
|
||||
.submit_offer(SubmitOffer {
|
||||
proof: grant.prove(&challenge, &self.endpoint.id().to_string()),
|
||||
ticket: share.ticket.clone(),
|
||||
transfer_name: share.transfer_name.clone(),
|
||||
sender_display_name: sender_name.map(ToOwned::to_owned),
|
||||
file_count: share.file_count,
|
||||
total_bytes: share.total_size,
|
||||
})
|
||||
.await
|
||||
.context("failed to deliver the offer")
|
||||
.map_err(VnidropError::transfer)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Transfers waiting for their target to come back online.
|
||||
pub(super) async fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>> {
|
||||
let held = self
|
||||
.repository
|
||||
.contacts()
|
||||
.list_held_offers()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(held
|
||||
.into_iter()
|
||||
.map(|offer| HeldOfferSummary {
|
||||
offer_id: offer.offer_id,
|
||||
endpoint_id: offer.endpoint_id,
|
||||
transfer_id: offer.transfer_id,
|
||||
transfer_name: offer.transfer_name,
|
||||
file_count: offer.file_count,
|
||||
total_bytes: offer.total_bytes,
|
||||
created_at: offer.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) async fn list_pending_offers(&self) -> Vec<IncomingOffer> {
|
||||
self.offers
|
||||
.list()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|offer| IncomingOffer {
|
||||
offer_id: offer.offer_id,
|
||||
from_endpoint_id: offer.from_endpoint_id,
|
||||
sender_display_name: offer.sender_display_name,
|
||||
transfer_name: offer.transfer_name,
|
||||
file_count: offer.file_count,
|
||||
total_bytes: offer.total_bytes,
|
||||
received_at: offer.received_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Answer an incoming offer. Returns the ticket when accepted, so the
|
||||
/// platform layer can run the ordinary receive with its own destination.
|
||||
pub(super) async fn respond_to_offer(
|
||||
&self,
|
||||
offer_id: String,
|
||||
accepted: bool,
|
||||
) -> Option<String> {
|
||||
self.offers.respond(&offer_id, accepted).await
|
||||
}
|
||||
|
||||
pub(super) async fn respond_to_pairing(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
accepted: bool,
|
||||
) -> Result<bool> {
|
||||
if accepted {
|
||||
self.pairing
|
||||
.accept_pending_grant(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)
|
||||
.map_err(Into::into)
|
||||
} else {
|
||||
Ok(self.pairing.decline_pending_grant(&endpoint_id).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a peer from reaching us and drop the relationship locally.
|
||||
///
|
||||
/// Revocation completes locally first: the notification is best effort and
|
||||
/// the peer losing access must not depend on being online to hear about it.
|
||||
pub(super) async fn forget_contact(self: &Arc<Self>, endpoint_id: String) -> Result<()> {
|
||||
let store = self.repository.contacts();
|
||||
let revoked = store
|
||||
.delete_contact(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
// A prompt on screen from a device we just forgot would be actionable
|
||||
// with a grant that no longer exists.
|
||||
self.offers.discard_from(&endpoint_id).await;
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"contact-forgotten",
|
||||
json!({ "peer_endpoint_id": endpoint_id, "revoked": revoked.len() }),
|
||||
);
|
||||
self.notify_revoked(endpoint_id, revoked).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget every device at once, alongside the existing history-clearing
|
||||
/// actions. Every peer loses access; each is notified best effort.
|
||||
pub(super) async fn forget_all_contacts(self: &Arc<Self>) -> Result<u64> {
|
||||
let store = self.repository.contacts();
|
||||
let contacts = store
|
||||
.list_contacts()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let revoked = store
|
||||
.delete_all_contacts()
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
for contact in &contacts {
|
||||
self.offers.discard_from(&contact.endpoint_id).await;
|
||||
}
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"contacts-cleared",
|
||||
json!({ "contacts": contacts.len(), "revoked": revoked.len() }),
|
||||
);
|
||||
for contact in contacts.iter() {
|
||||
self.notify_revoked(contact.endpoint_id.clone(), revoked.clone())
|
||||
.await;
|
||||
}
|
||||
Ok(revoked.len() as u64)
|
||||
}
|
||||
|
||||
pub(super) async fn block_contact(self: &Arc<Self>, endpoint_id: String) -> Result<()> {
|
||||
let store = self.repository.contacts();
|
||||
let revoked = store
|
||||
.revoke_issued_grants_for(&endpoint_id, now_ms())
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
store
|
||||
.block_endpoint(&endpoint_id, now_ms())
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
store
|
||||
.delete_contact(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
self.offers.discard_from(&endpoint_id).await;
|
||||
self.emit_endpoint(
|
||||
"contacts",
|
||||
"contact-blocked",
|
||||
json!({ "peer_endpoint_id": endpoint_id }),
|
||||
);
|
||||
// A blocked peer is told nothing: silence here is what makes blocking
|
||||
// undetectable, unlike ordinary revocation.
|
||||
let _ = revoked;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn unblock_contact(&self, endpoint_id: String) -> Result<()> {
|
||||
self.repository
|
||||
.contacts()
|
||||
.unblock_endpoint(&endpoint_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn list_blocked_contacts(&self) -> Result<Vec<String>> {
|
||||
self.repository
|
||||
.contacts()
|
||||
.list_blocked()
|
||||
.await
|
||||
.map_err(VnidropError::repository)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(super) async fn set_contact_label(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
label: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.limits
|
||||
.validate_metadata_text("contact label", label.as_deref())
|
||||
.map_err(VnidropError::invalid_input)?;
|
||||
self.repository
|
||||
.contacts()
|
||||
.set_contact_label(&endpoint_id, label.as_deref())
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_grant_lifetime(&self, setting: GrantLifetimeSetting) {
|
||||
self.pairing.set_grant_lifetime(setting.into()).await;
|
||||
}
|
||||
|
||||
/// Best-effort "your entry is dead" notification, so the peer's list clears
|
||||
/// promptly instead of at its next attempt.
|
||||
async fn notify_revoked(self: &Arc<Self>, endpoint_id: String, revoked: Vec<GrantId>) {
|
||||
if revoked.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(addr) = self.contact_addr(&endpoint_id).await else {
|
||||
return;
|
||||
};
|
||||
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||
for grant_id in revoked {
|
||||
if let Err(error) = client.revoke_grant(RevokeGrant { grant_id }).await {
|
||||
tracing::debug!(%error, "revocation notice undeliverable; peer will learn on next attempt");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to dial a contact.
|
||||
///
|
||||
/// Prefers the address cached from the last successful connection, which is
|
||||
/// what keeps contacts usable in relay profiles that do not resolve
|
||||
/// endpoint ids through public discovery.
|
||||
async fn contact_addr(&self, endpoint_id: &str) -> Result<EndpointAddr> {
|
||||
let cached = self
|
||||
.repository
|
||||
.contacts()
|
||||
.find_contact(endpoint_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|contact| contact.last_known_addr)
|
||||
.and_then(|encoded| parse_persisted_sender_address(&encoded).ok());
|
||||
if let Some(addr) = cached {
|
||||
return Ok(addr);
|
||||
}
|
||||
let parsed: EndpointId = endpoint_id
|
||||
.parse()
|
||||
.context("contact has an unusable endpoint id")
|
||||
.map_err(VnidropError::invalid_input)?;
|
||||
Ok(EndpointAddr::from(parsed))
|
||||
}
|
||||
|
||||
/// Refresh the cached address after a successful exchange.
|
||||
async fn remember_addr(&self, endpoint_id: &str) {
|
||||
let Ok(parsed) = endpoint_id.parse::<EndpointId>() else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self.endpoint.remote_info(parsed).await else {
|
||||
return;
|
||||
};
|
||||
let mut addr = EndpointAddr::from(parsed);
|
||||
addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect();
|
||||
if let Ok(encoded) = encode_persisted_sender_address(&addr) {
|
||||
let _ = self
|
||||
.repository
|
||||
.contacts()
|
||||
.set_last_known_addr(endpoint_id, &encoded)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ use serde_json::json;
|
||||
use super::CoreInner;
|
||||
use crate::{
|
||||
api::{
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
||||
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
|
||||
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode,
|
||||
@@ -271,6 +272,148 @@ impl VnidropCore {
|
||||
.map_err(VnidropError::permission)
|
||||
}
|
||||
|
||||
/// Devices the user has chosen to remember.
|
||||
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
|
||||
self.block_on(self.inner.list_contacts())
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
/// Share content and push it straight to a paired device.
|
||||
///
|
||||
/// Only the receiving user is prompted: this device authorised the target
|
||||
/// when it created the offer.
|
||||
pub fn send_to_contact(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
sources: Vec<ShareSource>,
|
||||
metadata: ShareMetadataInput,
|
||||
) -> Result<ContactSendResult, VnidropError> {
|
||||
self.block_on(self.inner.send_to_contact(endpoint_id, sources, metadata))
|
||||
.map_err(VnidropError::transfer)
|
||||
}
|
||||
|
||||
/// Ask remembered devices whether they are holding transfers for this one.
|
||||
///
|
||||
/// Call only from a foreground transition or an explicit user action: it
|
||||
/// tells every contact that this device is awake. Returns how many offers
|
||||
/// were collected.
|
||||
pub fn poll_contacts_for_offers(&self) -> Result<u64, VnidropError> {
|
||||
self.block_on(self.inner.poll_contacts_for_offers())
|
||||
.map_err(VnidropError::transfer)
|
||||
}
|
||||
|
||||
/// Offer an existing share to a remembered device.
|
||||
///
|
||||
/// Another way to deliver the invitation already created for a transfer,
|
||||
/// alongside the QR code — not a second share of the same files.
|
||||
pub fn offer_transfer_to_contact(
|
||||
&self,
|
||||
transfer_id: u64,
|
||||
endpoint_id: String,
|
||||
) -> Result<ContactSendResult, VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.offer_transfer_to_contact(transfer_id, endpoint_id),
|
||||
)
|
||||
.map_err(VnidropError::transfer)
|
||||
}
|
||||
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
pub fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>, VnidropError> {
|
||||
self.block_on(self.inner.list_held_offers())
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
/// Transfers paired devices are offering, awaiting this user's decision.
|
||||
pub fn list_pending_offers(&self) -> Vec<IncomingOffer> {
|
||||
self.block_on(self.inner.list_pending_offers())
|
||||
}
|
||||
|
||||
/// Accept or decline an incoming offer.
|
||||
///
|
||||
/// Returns the ticket when accepted, which the caller passes to `receive`
|
||||
/// with its own destination. Declining returns none: a refused offer never
|
||||
/// yields a capability.
|
||||
pub fn respond_to_offer(&self, offer_id: String, accepted: bool) -> Option<String> {
|
||||
self.block_on(self.inner.respond_to_offer(offer_id, accepted))
|
||||
}
|
||||
|
||||
/// Devices offering to be remembered, awaiting the local user's decision.
|
||||
pub fn list_pending_pairings(&self) -> Vec<PendingPairing> {
|
||||
self.block_on(self.inner.list_pending_pairings())
|
||||
}
|
||||
|
||||
/// Agree to be reachable by a device, handing it a revocable capability.
|
||||
///
|
||||
/// Independent of whether that device agrees to be reachable by us: each
|
||||
/// direction is a separate decision.
|
||||
pub fn allow_device_to_reach_me(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
display_name: Option<String>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.allow_device_to_reach_me(endpoint_id, display_name),
|
||||
)
|
||||
.map_err(VnidropError::transfer)
|
||||
}
|
||||
|
||||
/// Accept or decline a device's offer to be remembered. Returns false when
|
||||
/// the offer already lapsed.
|
||||
pub fn respond_to_pairing(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
accepted: bool,
|
||||
) -> Result<bool, VnidropError> {
|
||||
self.block_on(self.inner.respond_to_pairing(endpoint_id, accepted))
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
/// Forget a device and revoke its access. Takes effect locally at once; the
|
||||
/// peer is notified best effort.
|
||||
pub fn forget_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.forget_contact(endpoint_id))
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
/// Forget every device at once. Returns how many grants were revoked.
|
||||
pub fn forget_all_contacts(&self) -> Result<u64, VnidropError> {
|
||||
self.block_on(self.inner.forget_all_contacts())
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
/// Refuse a device outright. Unlike forgetting, the peer is told nothing.
|
||||
pub fn block_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.block_contact(endpoint_id))
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
pub fn unblock_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.unblock_contact(endpoint_id))
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
pub fn list_blocked_contacts(&self) -> Result<Vec<String>, VnidropError> {
|
||||
self.block_on(self.inner.list_blocked_contacts())
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
pub fn set_contact_label(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
label: Option<String>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.set_contact_label(endpoint_id, label))
|
||||
.map_err(VnidropError::repository)
|
||||
}
|
||||
|
||||
/// Idle lifetime applied to grants issued from now on. Existing grants keep
|
||||
/// the lifetime they were issued with until they next renew.
|
||||
pub fn set_grant_lifetime(&self, lifetime: GrantLifetimeSetting) {
|
||||
self.block_on(self.inner.set_grant_lifetime(lifetime));
|
||||
}
|
||||
|
||||
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||
self.block_on(self.inner.repository.list_transfers())
|
||||
.map_err(VnidropError::repository)
|
||||
|
||||
@@ -54,6 +54,13 @@ impl CoreInner {
|
||||
drop(active_shares);
|
||||
self.unregister_transfer_hashes(transfer_id).await;
|
||||
self.access_policy.remove_transfer(transfer_id).await;
|
||||
// An offer waiting for pickup would hand out a ticket for content
|
||||
// this device no longer serves.
|
||||
let _ = self
|
||||
.repository
|
||||
.contacts()
|
||||
.delete_held_offers_for_transfer(transfer_id)
|
||||
.await;
|
||||
self.store.tags().delete(share_tag_name(&local_id)).await?;
|
||||
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
|
||||
return Ok(());
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! - [`receive`] — ticket receive, download, export
|
||||
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
||||
//! - [`provider`] — blob provider events and per-connection send progress
|
||||
//! - [`contacts`] — device history: pairing, forgetting, blocking
|
||||
|
||||
mod contacts;
|
||||
mod delivery;
|
||||
mod facade;
|
||||
mod lifecycle;
|
||||
@@ -15,6 +17,8 @@ mod receive;
|
||||
mod share;
|
||||
mod storage;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::contacts::should_poll;
|
||||
pub use facade::VnidropCore;
|
||||
#[cfg(test)]
|
||||
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
|
||||
@@ -55,6 +59,9 @@ use crate::{
|
||||
event_hub::EventHub,
|
||||
handshake::HandshakeService,
|
||||
logging::init_logging,
|
||||
offer::OfferService,
|
||||
offer_inbox::OfferInbox,
|
||||
pairing::PairingService,
|
||||
repository::Repository,
|
||||
secret::load_or_create_secret,
|
||||
ticket::ticket_matches_relay_profile,
|
||||
@@ -63,6 +70,10 @@ use crate::{
|
||||
|
||||
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Minimum gap between polls of the same device, so switching in and out of the
|
||||
/// app does not announce presence to every contact repeatedly.
|
||||
pub(super) const POLL_MIN_INTERVAL_MS: i64 = 5 * 60 * 1_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RelayStatus {
|
||||
Disabled,
|
||||
@@ -90,6 +101,10 @@ pub(super) struct CoreInner {
|
||||
pub(super) repository: Repository,
|
||||
pub(super) event_hub: Arc<EventHub>,
|
||||
pub(super) approval: ApprovalService,
|
||||
pub(super) pairing: PairingService,
|
||||
pub(super) offers: OfferInbox,
|
||||
/// Endpoint → last poll time, for the rate limit above.
|
||||
pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
|
||||
pub(super) limits: CoreLimits,
|
||||
pub(super) relay_mode: CoreRelayMode,
|
||||
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
||||
@@ -321,9 +336,28 @@ impl CoreInner {
|
||||
limits.max_metadata_bytes,
|
||||
);
|
||||
let handshake = HandshakeService::new(approval.clone());
|
||||
let pairing = PairingService::new(
|
||||
repository.contacts(),
|
||||
event_hub.clone(),
|
||||
limits.max_pending_offers as usize,
|
||||
limits.max_metadata_bytes,
|
||||
);
|
||||
// Sweep grants dead long enough that no peer still needs the tombstone.
|
||||
if let Err(error) = repository
|
||||
.contacts()
|
||||
.purge_dead_grants(crate::util::now_ms() - crate::contacts::DEAD_GRANT_RETENTION_MS)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error, "failed to sweep dead grants");
|
||||
}
|
||||
let offers = OfferInbox::new(event_hub.clone(), limits.max_pending_offers as usize);
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_blobs::ALPN, blobs)
|
||||
.accept(HandshakeService::ALPN, handshake)
|
||||
.accept(
|
||||
OfferService::ALPN,
|
||||
OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()),
|
||||
)
|
||||
.spawn();
|
||||
|
||||
let inner = Arc::new(Self {
|
||||
@@ -334,6 +368,9 @@ impl CoreInner {
|
||||
repository,
|
||||
event_hub,
|
||||
approval,
|
||||
pairing,
|
||||
offers,
|
||||
last_polled: TokioMutex::new(HashMap::new()),
|
||||
relay_mode,
|
||||
custom_relay_urls: relay_urls,
|
||||
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#[path = "tests/access_policy.rs"]
|
||||
mod access_policy_tests;
|
||||
#[path = "tests/contact_polling.rs"]
|
||||
mod contact_polling_tests;
|
||||
#[path = "tests/contacts.rs"]
|
||||
mod contacts_tests;
|
||||
#[path = "tests/error.rs"]
|
||||
mod error_tests;
|
||||
#[path = "tests/filesystem.rs"]
|
||||
mod filesystem_tests;
|
||||
#[path = "tests/grant.rs"]
|
||||
mod grant_tests;
|
||||
#[path = "tests/handshake.rs"]
|
||||
mod handshake_tests;
|
||||
#[path = "tests/limits.rs"]
|
||||
|
||||
30
crates/vnidrop/src/tests/contact_polling.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use crate::runtime::should_poll;
|
||||
|
||||
const MINUTE_MS: i64 = 60 * 1_000;
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
#[test]
|
||||
fn a_device_never_polled_is_polled() {
|
||||
assert!(should_poll(None, NOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_polled_recently_is_skipped() {
|
||||
// Switching in and out of the app must not re-announce presence.
|
||||
assert!(!should_poll(Some(NOW), NOW));
|
||||
assert!(!should_poll(Some(NOW - MINUTE_MS), NOW));
|
||||
assert!(!should_poll(Some(NOW - 4 * MINUTE_MS), NOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_polled_before_the_window_is_polled_again() {
|
||||
assert!(should_poll(Some(NOW - 5 * MINUTE_MS), NOW));
|
||||
assert!(should_poll(Some(NOW - 60 * MINUTE_MS), NOW));
|
||||
}
|
||||
|
||||
/// A clock that jumped backwards must not lock polling out forever.
|
||||
#[test]
|
||||
fn a_future_timestamp_is_treated_as_recent_rather_than_permanent() {
|
||||
assert!(!should_poll(Some(NOW + MINUTE_MS), NOW));
|
||||
assert!(should_poll(Some(NOW + MINUTE_MS), NOW + 6 * MINUTE_MS));
|
||||
}
|
||||
386
crates/vnidrop/src/tests/contacts.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
use crate::{
|
||||
contacts::ContactStore,
|
||||
grant::{Challenge, GrantId, GrantLifetime, GrantRejection, HeldGrant, IssuedGrant},
|
||||
repository::Repository,
|
||||
};
|
||||
|
||||
const PEER: &str = "peer-endpoint";
|
||||
const SELF_ID: &str = "self-endpoint";
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||
|
||||
async fn store(temp: &tempfile::TempDir) -> (Repository, ContactStore) {
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let contacts = repository.contacts();
|
||||
(repository, contacts)
|
||||
}
|
||||
|
||||
async fn contact_with_issued_grant(contacts: &ContactStore) -> IssuedGrant {
|
||||
contacts
|
||||
.upsert_contact(PEER, Some("Peer Laptop"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
let grant = IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default());
|
||||
contacts.insert_issued_grant(&grant).await.unwrap();
|
||||
grant
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contacts_and_grants_survive_reopening_the_same_data_dir() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let minted = {
|
||||
let (repository, contacts) = store(&temp).await;
|
||||
let grant = contact_with_issued_grant(&contacts).await;
|
||||
contacts
|
||||
.insert_held_grant(&HeldGrant {
|
||||
grant_id: GrantId::generate(),
|
||||
secret: grant.secret.clone(),
|
||||
peer_endpoint_id: PEER.to_string(),
|
||||
created_at: NOW,
|
||||
expires_at: Some(NOW + 90 * DAY_MS),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(repository);
|
||||
grant
|
||||
};
|
||||
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
|
||||
let reloaded = contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("issued grant persisted");
|
||||
assert_eq!(reloaded.secret, minted.secret);
|
||||
assert_eq!(reloaded.issued_to_endpoint_id, PEER);
|
||||
assert!(contacts.held_grant_for(PEER).await.unwrap().is_some());
|
||||
assert_eq!(contacts.list_contacts().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_persisted_grant_still_validates_a_proof() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
|
||||
// The round trip through hex storage must not disturb the secret.
|
||||
let reloaded = contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("issued grant persisted");
|
||||
let challenge = Challenge::generate();
|
||||
let held = HeldGrant {
|
||||
grant_id: minted.grant_id,
|
||||
secret: minted.secret.clone(),
|
||||
peer_endpoint_id: SELF_ID.to_string(),
|
||||
created_at: NOW,
|
||||
expires_at: None,
|
||||
};
|
||||
|
||||
let outcome = reloaded.accept(
|
||||
&held.prove(&challenge, PEER),
|
||||
&challenge,
|
||||
SELF_ID,
|
||||
PEER,
|
||||
NOW,
|
||||
GrantLifetime::default(),
|
||||
);
|
||||
|
||||
assert!(outcome.is_ok(), "expected acceptance, got {outcome:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renewal_is_persisted() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
let renewed_to = Some(NOW + 120 * DAY_MS);
|
||||
|
||||
contacts
|
||||
.renew_issued_grant(minted.grant_id, renewed_to)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reloaded = contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("issued grant persisted");
|
||||
assert_eq!(reloaded.expires_at, renewed_to);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revocation_is_tombstoned_so_the_peer_learns_it_was_revoked() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
|
||||
contacts
|
||||
.revoke_issued_grant(minted.grant_id, NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reloaded = contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("a revoked grant is kept as a tombstone, not deleted");
|
||||
assert_eq!(reloaded.revoked_at, Some(NOW));
|
||||
|
||||
// A tombstone answers Revoked, never Unknown: the peer needs to know to
|
||||
// drop the entry rather than retry forever.
|
||||
let challenge = Challenge::generate();
|
||||
let held = HeldGrant {
|
||||
grant_id: minted.grant_id,
|
||||
secret: minted.secret.clone(),
|
||||
peer_endpoint_id: SELF_ID.to_string(),
|
||||
created_at: NOW,
|
||||
expires_at: None,
|
||||
};
|
||||
assert_eq!(
|
||||
reloaded.accept(
|
||||
&held.prove(&challenge, PEER),
|
||||
&challenge,
|
||||
SELF_ID,
|
||||
PEER,
|
||||
NOW,
|
||||
GrantLifetime::default(),
|
||||
),
|
||||
Err(GrantRejection::Revoked)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_contact_removes_both_directions_and_reports_issued_grants() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
let held_id = GrantId::generate();
|
||||
contacts
|
||||
.insert_held_grant(&HeldGrant {
|
||||
grant_id: held_id,
|
||||
secret: minted.secret.clone(),
|
||||
peer_endpoint_id: PEER.to_string(),
|
||||
created_at: NOW,
|
||||
expires_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let to_notify = contacts.delete_contact(PEER).await.unwrap();
|
||||
|
||||
assert_eq!(to_notify, vec![minted.grant_id]);
|
||||
assert!(contacts.list_contacts().await.unwrap().is_empty());
|
||||
assert!(contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(contacts.held_grant_for(PEER).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_all_contacts_clears_every_grant() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
contact_with_issued_grant(&contacts).await;
|
||||
contacts
|
||||
.upsert_contact("other-peer", None, NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
let other = IssuedGrant::mint("other-peer".to_string(), NOW, GrantLifetime::default());
|
||||
contacts.insert_issued_grant(&other).await.unwrap();
|
||||
|
||||
let to_notify = contacts.delete_all_contacts().await.unwrap();
|
||||
|
||||
assert_eq!(to_notify.len(), 2);
|
||||
assert!(contacts.list_contacts().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_label_is_never_overwritten_by_a_name_the_remote_claims() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
contacts
|
||||
.upsert_contact(PEER, Some("Original"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
contacts
|
||||
.set_contact_label(PEER, Some("My Laptop"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
contacts
|
||||
.upsert_contact(PEER, Some("Totally Not Evil"), NOW + 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let contact = contacts.find_contact(PEER).await.unwrap().expect("contact");
|
||||
assert_eq!(contact.local_label.as_deref(), Some("My Laptop"));
|
||||
assert_eq!(
|
||||
contact.remote_display_name.as_deref(),
|
||||
Some("Totally Not Evil"),
|
||||
"the claimed name is still recorded, just not promoted to the label"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_keeps_the_original_creation_time_and_records_activity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
contacts.upsert_contact(PEER, None, NOW).await.unwrap();
|
||||
|
||||
contacts
|
||||
.upsert_contact(PEER, None, NOW + 5 * DAY_MS)
|
||||
.await
|
||||
.unwrap();
|
||||
contacts
|
||||
.touch_transfer(PEER, NOW + 6 * DAY_MS)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let contact = contacts.find_contact(PEER).await.unwrap().expect("contact");
|
||||
assert_eq!(contact.created_at, NOW);
|
||||
assert_eq!(contact.last_transfer_at, Some(NOW + 6 * DAY_MS));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_last_known_address_is_remembered_for_later_dialing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
contacts.upsert_contact(PEER, None, NOW).await.unwrap();
|
||||
|
||||
contacts
|
||||
.set_last_known_addr(PEER, "vndaddr1:encoded")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let contact = contacts.find_contact(PEER).await.unwrap().expect("contact");
|
||||
assert_eq!(contact.last_known_addr.as_deref(), Some("vndaddr1:encoded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_revokes_outstanding_grants_so_it_is_not_merely_cosmetic() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
|
||||
contacts.block_endpoint(PEER, NOW).await.unwrap();
|
||||
|
||||
assert!(contacts.is_blocked(PEER).await.unwrap());
|
||||
let reloaded = contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("grant kept as tombstone");
|
||||
assert_eq!(reloaded.revoked_at, Some(NOW));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unblocking_does_not_restore_the_revoked_grant() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
contacts.block_endpoint(PEER, NOW).await.unwrap();
|
||||
|
||||
contacts.unblock_endpoint(PEER).await.unwrap();
|
||||
|
||||
assert!(!contacts.is_blocked(PEER).await.unwrap());
|
||||
let reloaded = contacts
|
||||
.find_issued_grant(minted.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("grant kept as tombstone");
|
||||
assert!(
|
||||
reloaded.revoked_at.is_some(),
|
||||
"unblocking must not silently hand back access; the peer has to pair again"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newest_held_grant_wins_after_re_pairing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let older = HeldGrant {
|
||||
grant_id: GrantId::generate(),
|
||||
secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret,
|
||||
peer_endpoint_id: PEER.to_string(),
|
||||
created_at: NOW,
|
||||
expires_at: None,
|
||||
};
|
||||
let newer = HeldGrant {
|
||||
grant_id: GrantId::generate(),
|
||||
secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret,
|
||||
peer_endpoint_id: PEER.to_string(),
|
||||
created_at: NOW + DAY_MS,
|
||||
expires_at: None,
|
||||
};
|
||||
contacts.insert_held_grant(&older).await.unwrap();
|
||||
contacts.insert_held_grant(&newer).await.unwrap();
|
||||
|
||||
let selected = contacts.held_grant_for(PEER).await.unwrap().expect("grant");
|
||||
|
||||
assert_eq!(selected.grant_id, newer.grant_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_held_grant_is_dropped_once_the_issuer_reports_it_dead() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let held = HeldGrant {
|
||||
grant_id: GrantId::generate(),
|
||||
secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret,
|
||||
peer_endpoint_id: PEER.to_string(),
|
||||
created_at: NOW,
|
||||
expires_at: None,
|
||||
};
|
||||
contacts.insert_held_grant(&held).await.unwrap();
|
||||
|
||||
contacts.delete_held_grant(held.grant_id).await.unwrap();
|
||||
|
||||
assert!(contacts.held_grant_for(PEER).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purging_drops_lapsed_and_revoked_grants_but_keeps_live_ones() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let live = contact_with_issued_grant(&contacts).await;
|
||||
let lapsed = IssuedGrant::mint(
|
||||
"stale-peer".to_string(),
|
||||
NOW - 400 * DAY_MS,
|
||||
GrantLifetime::Days(1),
|
||||
);
|
||||
contacts.insert_issued_grant(&lapsed).await.unwrap();
|
||||
|
||||
let purged = contacts.purge_dead_grants(NOW).await.unwrap();
|
||||
|
||||
assert_eq!(purged, 1);
|
||||
assert!(contacts
|
||||
.find_issued_grant(live.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(contacts
|
||||
.find_issued_grant(lapsed.grant_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_corrupt_stored_secret_is_an_error_not_a_silent_refusal() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_repository, contacts) = store(&temp).await;
|
||||
let minted = contact_with_issued_grant(&contacts).await;
|
||||
contacts
|
||||
.corrupt_secret_for_test(minted.grant_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Refusing the peer here would be indistinguishable from revocation, so the
|
||||
// corruption has to surface instead.
|
||||
assert!(contacts.find_issued_grant(minted.grant_id).await.is_err());
|
||||
}
|
||||
226
crates/vnidrop/src/tests/grant.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use crate::grant::{
|
||||
parse_secret, prove, Challenge, GrantId, GrantLifetime, GrantRejection, GrantSecret,
|
||||
IssuedGrant,
|
||||
};
|
||||
|
||||
const ISSUER: &str = "issuer-endpoint";
|
||||
const HOLDER: &str = "holder-endpoint";
|
||||
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||
|
||||
fn issued(now_ms: i64) -> IssuedGrant {
|
||||
IssuedGrant::mint(HOLDER.to_string(), now_ms, GrantLifetime::default())
|
||||
}
|
||||
|
||||
fn accept_with(
|
||||
grant: &IssuedGrant,
|
||||
challenge: &Challenge,
|
||||
remote_endpoint_id: &str,
|
||||
now_ms: i64,
|
||||
) -> Result<Option<i64>, GrantRejection> {
|
||||
let proof = prove(
|
||||
grant.grant_id,
|
||||
&grant.secret,
|
||||
challenge,
|
||||
ISSUER,
|
||||
remote_endpoint_id,
|
||||
);
|
||||
grant.accept(
|
||||
&proof,
|
||||
challenge,
|
||||
ISSUER,
|
||||
remote_endpoint_id,
|
||||
now_ms,
|
||||
GrantLifetime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_valid_proof_and_returns_the_renewed_deadline() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
let challenge = Challenge::generate();
|
||||
|
||||
let renewed = accept_with(&grant, &challenge, HOLDER, now + DAY_MS).expect("proof accepted");
|
||||
|
||||
assert_eq!(renewed, Some(now + DAY_MS + 90 * DAY_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renewal_extends_past_the_original_expiry() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
let original = grant.expires_at.expect("default lifetime expires");
|
||||
|
||||
// Used one day before lapsing: the new deadline must be later than the old.
|
||||
let use_at = original - DAY_MS;
|
||||
let renewed = accept_with(&grant, &Challenge::generate(), HOLDER, use_at)
|
||||
.expect("proof accepted")
|
||||
.expect("renewed deadline");
|
||||
|
||||
assert!(renewed > original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_proof_bound_to_a_different_challenge() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
let captured = Challenge::from_bytes([7u8; 32]);
|
||||
let proof = prove(grant.grant_id, &grant.secret, &captured, ISSUER, HOLDER);
|
||||
|
||||
// Replaying a captured proof against a fresh challenge must fail.
|
||||
let outcome = grant.accept(
|
||||
&proof,
|
||||
&Challenge::from_bytes([9u8; 32]),
|
||||
ISSUER,
|
||||
HOLDER,
|
||||
now,
|
||||
GrantLifetime::default(),
|
||||
);
|
||||
|
||||
assert_eq!(outcome, Err(GrantRejection::BadProof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_proof_from_an_endpoint_the_grant_was_not_issued_to() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
|
||||
let outcome = accept_with(&grant, &Challenge::generate(), "someone-else", now);
|
||||
|
||||
assert_eq!(outcome, Err(GrantRejection::WrongEndpoint));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_proof_replayed_against_a_different_issuer() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
let challenge = Challenge::generate();
|
||||
let proof = prove(
|
||||
grant.grant_id,
|
||||
&grant.secret,
|
||||
&challenge,
|
||||
"other-issuer",
|
||||
HOLDER,
|
||||
);
|
||||
|
||||
let outcome = grant.accept(
|
||||
&proof,
|
||||
&challenge,
|
||||
ISSUER,
|
||||
HOLDER,
|
||||
now,
|
||||
GrantLifetime::default(),
|
||||
);
|
||||
|
||||
assert_eq!(outcome, Err(GrantRejection::BadProof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_revoked_grant_distinguishably() {
|
||||
let now = 1_700_000_000_000;
|
||||
let mut grant = issued(now);
|
||||
grant.revoked_at = Some(now);
|
||||
|
||||
// Revocation is reported as such so the peer can drop the dead entry.
|
||||
assert_eq!(
|
||||
accept_with(&grant, &Challenge::generate(), HOLDER, now),
|
||||
Err(GrantRejection::Revoked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_idle_grant_after_its_deadline() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
let expires_at = grant.expires_at.expect("default lifetime expires");
|
||||
|
||||
assert_eq!(
|
||||
accept_with(&grant, &Challenge::generate(), HOLDER, expires_at),
|
||||
Ok(Some(expires_at + 90 * DAY_MS)),
|
||||
"a grant is still usable on its deadline"
|
||||
);
|
||||
assert_eq!(
|
||||
accept_with(&grant, &Challenge::generate(), HOLDER, expires_at + 1),
|
||||
Err(GrantRejection::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_proof_for_a_different_grant_id() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = issued(now);
|
||||
let other = issued(now);
|
||||
let challenge = Challenge::generate();
|
||||
let proof = prove(other.grant_id, &other.secret, &challenge, ISSUER, HOLDER);
|
||||
|
||||
let outcome = grant.accept(
|
||||
&proof,
|
||||
&challenge,
|
||||
ISSUER,
|
||||
HOLDER,
|
||||
now,
|
||||
GrantLifetime::default(),
|
||||
);
|
||||
|
||||
assert_eq!(outcome, Err(GrantRejection::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_lifetime_produces_no_deadline() {
|
||||
let now = 1_700_000_000_000;
|
||||
let grant = IssuedGrant::mint(HOLDER.to_string(), now, GrantLifetime::Never);
|
||||
assert_eq!(grant.expires_at, None);
|
||||
|
||||
let challenge = Challenge::generate();
|
||||
let proof = prove(grant.grant_id, &grant.secret, &challenge, ISSUER, HOLDER);
|
||||
let renewed = grant
|
||||
.accept(
|
||||
&proof,
|
||||
&challenge,
|
||||
ISSUER,
|
||||
HOLDER,
|
||||
now + 10_000 * DAY_MS,
|
||||
GrantLifetime::Never,
|
||||
)
|
||||
.expect("proof accepted");
|
||||
|
||||
assert_eq!(renewed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_ids_and_secrets_round_trip_through_storage_encoding() {
|
||||
let id = GrantId::generate();
|
||||
assert_eq!(GrantId::decode(&id.encode()).expect("decodes"), id);
|
||||
|
||||
let secret = GrantSecret::generate();
|
||||
assert_eq!(parse_secret(&secret.encode()).expect("decodes"), secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_or_degenerate_stored_secrets() {
|
||||
assert!(parse_secret("not-hex").is_err());
|
||||
assert!(parse_secret("aabb").is_err(), "wrong length");
|
||||
assert!(
|
||||
parse_secret(&"00".repeat(32)).is_err(),
|
||||
"an all-zero secret means corrupt storage, not a usable grant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_redacted_in_debug_output() {
|
||||
let secret = GrantSecret::generate();
|
||||
let rendered = format!("{secret:?}");
|
||||
|
||||
assert!(!rendered.contains(&secret.encode()));
|
||||
assert_eq!(rendered, "GrantSecret(redacted)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_grants_are_unique() {
|
||||
let now = 1_700_000_000_000;
|
||||
let first = issued(now);
|
||||
let second = issued(now);
|
||||
|
||||
assert_ne!(first.grant_id, second.grant_id);
|
||||
assert_ne!(first.secret, second.secret);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
||||
async fn persists_transfers_and_events_across_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 9);
|
||||
repository
|
||||
.insert_transfer(transfer(
|
||||
7,
|
||||
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
||||
pool.close().await;
|
||||
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 9);
|
||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||
assert_eq!(stored.transfer_id, 7);
|
||||
assert_eq!(stored.local_id, "legacy-7-send");
|
||||
|
||||
651
crates/vnidrop/tests/offer.rs
Normal file
@@ -0,0 +1,651 @@
|
||||
//! Send-to-contact offers between two real nodes.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use support::{RecordingSink, TestNode};
|
||||
use vnidrop::{
|
||||
ContactSendResult, IncomingOffer, ShareMetadataInput, ShareSource, SourceKind,
|
||||
TransferAccessMode, VnidropCore, VnidropError,
|
||||
};
|
||||
|
||||
fn endpoint_id(node: &TestNode) -> String {
|
||||
node.core.status().endpoint_id
|
||||
}
|
||||
|
||||
/// Establish a one-way relationship: `issuer` becomes reachable by `holder`.
|
||||
fn pair(issuer: &TestNode, holder: &TestNode) {
|
||||
let issuer_id = endpoint_id(issuer);
|
||||
issuer
|
||||
.core
|
||||
.allow_device_to_reach_me(endpoint_id(holder), Some("Issuer".to_string()))
|
||||
.expect("grant delivered");
|
||||
|
||||
let started = Instant::now();
|
||||
while !holder
|
||||
.core
|
||||
.list_pending_pairings()
|
||||
.iter()
|
||||
.any(|pending| pending.endpoint_id == issuer_id)
|
||||
{
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"pairing offer never surfaced"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
holder
|
||||
.core
|
||||
.respond_to_pairing(issuer_id, true)
|
||||
.expect("consent recorded");
|
||||
}
|
||||
|
||||
fn sources(path: &Path) -> Vec<ShareSource> {
|
||||
vec![ShareSource {
|
||||
kind: SourceKind::Path,
|
||||
value: path.to_string_lossy().to_string(),
|
||||
display_name: Some("shared.txt".to_string()),
|
||||
is_directory: false,
|
||||
}]
|
||||
}
|
||||
|
||||
fn metadata(transfer_id: u64) -> ShareMetadataInput {
|
||||
ShareMetadataInput {
|
||||
transfer_id,
|
||||
transfer_name: Some("shared.txt".to_string()),
|
||||
sender_name: Some("Sender".to_string()),
|
||||
access_mode: TransferAccessMode::ApprovalRequired,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send in the background: the call blocks until the receiver decides.
|
||||
fn send_in_background(
|
||||
core: Arc<VnidropCore>,
|
||||
to: String,
|
||||
path: &Path,
|
||||
transfer_id: u64,
|
||||
) -> std::thread::JoinHandle<Result<ContactSendResult, VnidropError>> {
|
||||
let sources = sources(path);
|
||||
std::thread::spawn(move || core.send_to_contact(to, sources, metadata(transfer_id)))
|
||||
}
|
||||
|
||||
fn wait_for_offer(core: &VnidropCore) -> IncomingOffer {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if let Some(offer) = core.list_pending_offers().into_iter().next() {
|
||||
return offer;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"offer never surfaced on the receiver"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole point: the receiver is asked exactly once, the sender not at all.
|
||||
#[test]
|
||||
fn an_accepted_offer_transfers_without_prompting_the_sender() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"offered content").unwrap();
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
// The sender must be able to reach the receiver, so the receiver issues.
|
||||
pair(&receiver, &sender);
|
||||
|
||||
let handle = send_in_background(
|
||||
sender.core.arc(),
|
||||
endpoint_id(&receiver),
|
||||
&source_path,
|
||||
4_001,
|
||||
);
|
||||
|
||||
let offer = wait_for_offer(&receiver.core);
|
||||
assert_eq!(offer.from_endpoint_id, endpoint_id(&sender));
|
||||
assert_eq!(offer.transfer_name, "shared.txt");
|
||||
assert_eq!(offer.file_count, 1);
|
||||
assert_eq!(offer.sender_display_name.as_deref(), Some("Sender"));
|
||||
|
||||
let ticket = receiver
|
||||
.core
|
||||
.respond_to_offer(offer.offer_id, true)
|
||||
.expect("accepting yields the ticket");
|
||||
let share = handle.join().unwrap().expect("offer accepted");
|
||||
|
||||
receiver
|
||||
.core
|
||||
.receive(
|
||||
ticket,
|
||||
output_dir.path().to_string_lossy().to_string(),
|
||||
Some("Receiver".to_string()),
|
||||
)
|
||||
.expect("receive completes");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||
b"offered content"
|
||||
);
|
||||
|
||||
// The sender was never asked: the only receiver request on its side was
|
||||
// recorded as already approved.
|
||||
let requests = sender
|
||||
.core
|
||||
.list_receiver_requests(share.share.transfer_id)
|
||||
.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(
|
||||
matches!(requests[0].status.as_str(), "accepted" | "completed"),
|
||||
"sender should not have been prompted, got status {}",
|
||||
requests[0].status
|
||||
);
|
||||
assert!(requests[0].reason.is_none());
|
||||
}
|
||||
|
||||
/// Declining yields no ticket and stops the share.
|
||||
#[test]
|
||||
fn a_declined_offer_yields_no_ticket() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"offered content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
let handle = send_in_background(
|
||||
sender.core.arc(),
|
||||
endpoint_id(&receiver),
|
||||
&source_path,
|
||||
4_002,
|
||||
);
|
||||
let offer = wait_for_offer(&receiver.core);
|
||||
|
||||
assert!(
|
||||
receiver
|
||||
.core
|
||||
.respond_to_offer(offer.offer_id, false)
|
||||
.is_none(),
|
||||
"a declined offer must not hand over a ticket"
|
||||
);
|
||||
|
||||
let outcome = handle.join().unwrap();
|
||||
assert!(outcome.is_err(), "sender should see the refusal");
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
}
|
||||
|
||||
/// A device with no grant cannot offer at all.
|
||||
#[test]
|
||||
fn sending_without_a_grant_is_refused_locally() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
|
||||
let outcome = sender.core.send_to_contact(
|
||||
endpoint_id(&receiver),
|
||||
sources(&source_path),
|
||||
metadata(4_003),
|
||||
);
|
||||
|
||||
assert!(outcome.is_err(), "no grant means nothing to send with");
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
}
|
||||
|
||||
/// After the peer revokes, the offer is refused and the dead grant is dropped.
|
||||
#[test]
|
||||
fn a_revoked_grant_cannot_be_used_to_offer() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
// The receiver decides it no longer wants to hear from the sender.
|
||||
receiver
|
||||
.core
|
||||
.forget_contact(endpoint_id(&sender))
|
||||
.expect("forgotten");
|
||||
|
||||
let outcome = sender.core.send_to_contact(
|
||||
endpoint_id(&receiver),
|
||||
sources(&source_path),
|
||||
metadata(4_004),
|
||||
);
|
||||
|
||||
assert!(outcome.is_err());
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
let contacts = sender.core.list_contacts().unwrap();
|
||||
assert!(
|
||||
contacts.iter().all(|contact| !contact.can_send),
|
||||
"a refusal naming a dead grant must clear the sender's belief it can reach them"
|
||||
);
|
||||
}
|
||||
|
||||
/// An offer-created share is never public, whatever the caller asked for.
|
||||
#[test]
|
||||
fn an_offer_share_is_never_public() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
let core = sender.core.arc();
|
||||
let to = endpoint_id(&receiver);
|
||||
let sources = sources(&source_path);
|
||||
let handle = std::thread::spawn(move || {
|
||||
core.send_to_contact(
|
||||
to,
|
||||
sources,
|
||||
ShareMetadataInput {
|
||||
transfer_id: 4_005,
|
||||
transfer_name: Some("shared.txt".to_string()),
|
||||
sender_name: None,
|
||||
// Deliberately asking for the wider mode.
|
||||
access_mode: TransferAccessMode::Public,
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let offer = wait_for_offer(&receiver.core);
|
||||
receiver.core.respond_to_offer(offer.offer_id, true);
|
||||
let share = handle.join().unwrap().expect("offer accepted");
|
||||
|
||||
let stored = sender
|
||||
.core
|
||||
.list_transfers()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|transfer| transfer.transfer_id == share.share.transfer_id)
|
||||
.expect("share recorded");
|
||||
assert_eq!(stored.access_mode, TransferAccessMode::ApprovalRequired);
|
||||
}
|
||||
|
||||
/// A second offer while one is on screen is refused rather than stacked.
|
||||
#[test]
|
||||
fn only_one_offer_per_device_is_pending_at_a_time() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
let first = send_in_background(
|
||||
sender.core.arc(),
|
||||
endpoint_id(&receiver),
|
||||
&source_path,
|
||||
4_006,
|
||||
);
|
||||
wait_for_offer(&receiver.core);
|
||||
|
||||
let second = sender.core.send_to_contact(
|
||||
endpoint_id(&receiver),
|
||||
sources(&source_path),
|
||||
metadata(4_007),
|
||||
);
|
||||
assert!(second.is_err(), "a second prompt must not stack");
|
||||
assert_eq!(receiver.core.list_pending_offers().len(), 1);
|
||||
|
||||
let offer = receiver.core.list_pending_offers().remove(0);
|
||||
receiver.core.respond_to_offer(offer.offer_id, false);
|
||||
let _ = first.join().unwrap();
|
||||
}
|
||||
|
||||
/// Forgetting a device clears any prompt it left on screen, which would
|
||||
/// otherwise be actionable with a grant that no longer exists.
|
||||
#[test]
|
||||
fn forgetting_a_device_clears_its_pending_offer() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
let handle = send_in_background(
|
||||
sender.core.arc(),
|
||||
endpoint_id(&receiver),
|
||||
&source_path,
|
||||
4_008,
|
||||
);
|
||||
wait_for_offer(&receiver.core);
|
||||
|
||||
receiver
|
||||
.core
|
||||
.forget_contact(endpoint_id(&sender))
|
||||
.expect("forgotten");
|
||||
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
assert!(handle.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
/// The ordinary QR path still prompts the sender: pre-authorisation applies
|
||||
/// only to transfers the sender pushed.
|
||||
#[test]
|
||||
fn an_ordinary_ticket_receive_still_prompts_the_sender() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let sender_dir = tempfile::tempdir().unwrap();
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let sender = support::CoreGuard::start(sender_dir.path(), sink);
|
||||
let receiver = TestNode::new();
|
||||
|
||||
let share = sender
|
||||
.share_files(sources(&source_path), metadata(4_009))
|
||||
.expect("shared");
|
||||
|
||||
let core = receiver.core.arc();
|
||||
let ticket = share.ticket.clone();
|
||||
let output = output_dir.path().to_string_lossy().to_string();
|
||||
let handle =
|
||||
std::thread::spawn(move || core.receive(ticket, output, Some("Receiver".to_string())));
|
||||
|
||||
let request = support::wait_for_receiver_request(&sender, share.transfer_id);
|
||||
assert_eq!(
|
||||
request.status, "requested",
|
||||
"an unsolicited ticket receive must still ask the sender"
|
||||
);
|
||||
sender
|
||||
.respond_receiver_request(request.id, true, None)
|
||||
.unwrap();
|
||||
handle.join().unwrap().expect("receive completes");
|
||||
}
|
||||
|
||||
// MARK: - Held offers and the foreground pull
|
||||
|
||||
/// Restartable node, for simulating a device that was not running.
|
||||
struct RestartableNode {
|
||||
dir: tempfile::TempDir,
|
||||
core: Option<support::CoreGuard>,
|
||||
}
|
||||
|
||||
impl RestartableNode {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let core = support::CoreGuard::start(dir.path(), Arc::new(RecordingSink::default()));
|
||||
Self {
|
||||
dir,
|
||||
core: Some(core),
|
||||
}
|
||||
}
|
||||
|
||||
fn core(&self) -> &VnidropCore {
|
||||
self.core.as_ref().expect("node is running")
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
if let Some(core) = self.core.take() {
|
||||
core.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&mut self) {
|
||||
self.core = Some(support::CoreGuard::start(
|
||||
self.dir.path(),
|
||||
Arc::new(RecordingSink::default()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pair so `sender` may reach the restartable node.
|
||||
fn pair_with_restartable(sender: &TestNode, receiver: &RestartableNode) {
|
||||
let receiver_id = receiver.core().status().endpoint_id;
|
||||
receiver
|
||||
.core()
|
||||
.allow_device_to_reach_me(endpoint_id(sender), Some("Receiver".to_string()))
|
||||
.expect("grant delivered");
|
||||
|
||||
let started = Instant::now();
|
||||
while !sender
|
||||
.core
|
||||
.list_pending_pairings()
|
||||
.iter()
|
||||
.any(|pending| pending.endpoint_id == receiver_id)
|
||||
{
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"pairing offer never surfaced"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
sender
|
||||
.core
|
||||
.respond_to_pairing(receiver_id, true)
|
||||
.expect("consent recorded");
|
||||
}
|
||||
|
||||
/// The whole point of §11: a closed app is not an error, it is a delay.
|
||||
#[test]
|
||||
fn an_offer_to_a_device_that_is_not_running_is_held_and_collected_later() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"held content").unwrap();
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let mut receiver = RestartableNode::new();
|
||||
pair_with_restartable(&sender, &receiver);
|
||||
let receiver_id = receiver.core().status().endpoint_id;
|
||||
|
||||
receiver.stop();
|
||||
|
||||
let outcome = sender
|
||||
.core
|
||||
.send_to_contact(receiver_id, sources(&source_path), metadata(5_001))
|
||||
.expect("an unreachable device is not a failure");
|
||||
assert!(
|
||||
!outcome.delivered,
|
||||
"nothing was delivered, the offer is waiting"
|
||||
);
|
||||
let held = sender.core.list_held_offers().unwrap();
|
||||
assert_eq!(held.len(), 1);
|
||||
assert_eq!(held[0].transfer_id, outcome.share.transfer_id);
|
||||
|
||||
receiver.start();
|
||||
let collected = receiver
|
||||
.core()
|
||||
.poll_contacts_for_offers()
|
||||
.expect("poll succeeds");
|
||||
|
||||
assert_eq!(collected, 1);
|
||||
let offer = receiver.core().list_pending_offers().remove(0);
|
||||
assert_eq!(offer.transfer_name, "shared.txt");
|
||||
|
||||
let ticket = receiver
|
||||
.core()
|
||||
.respond_to_offer(offer.offer_id, true)
|
||||
.expect("accepting yields the ticket");
|
||||
receiver
|
||||
.core()
|
||||
.receive(
|
||||
ticket,
|
||||
output_dir.path().to_string_lossy().to_string(),
|
||||
Some("Receiver".to_string()),
|
||||
)
|
||||
.expect("receive completes");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||
b"held content"
|
||||
);
|
||||
assert!(
|
||||
sender.core.list_held_offers().unwrap().is_empty(),
|
||||
"a collected offer is no longer held"
|
||||
);
|
||||
}
|
||||
|
||||
/// Collected offers are consumed, so a second pull does not re-deliver them.
|
||||
#[test]
|
||||
fn polling_twice_does_not_collect_the_same_offer_again() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let mut receiver = RestartableNode::new();
|
||||
pair_with_restartable(&sender, &receiver);
|
||||
let receiver_id = receiver.core().status().endpoint_id;
|
||||
receiver.stop();
|
||||
sender
|
||||
.core
|
||||
.send_to_contact(receiver_id, sources(&source_path), metadata(5_002))
|
||||
.unwrap();
|
||||
|
||||
// A fresh core each time, so the per-device poll rate limit does not mask
|
||||
// the consume-on-delivery behaviour being asserted here.
|
||||
receiver.start();
|
||||
assert_eq!(receiver.core().poll_contacts_for_offers().unwrap(), 1);
|
||||
receiver.stop();
|
||||
receiver.start();
|
||||
assert_eq!(
|
||||
receiver.core().poll_contacts_for_offers().unwrap(),
|
||||
0,
|
||||
"the offer was already handed over"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancelling the transfer withdraws the ticket that was waiting for pickup.
|
||||
#[test]
|
||||
fn cancelling_a_transfer_withdraws_its_held_offer() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let mut receiver = RestartableNode::new();
|
||||
pair_with_restartable(&sender, &receiver);
|
||||
let receiver_id = receiver.core().status().endpoint_id;
|
||||
receiver.stop();
|
||||
let outcome = sender
|
||||
.core
|
||||
.send_to_contact(receiver_id, sources(&source_path), metadata(5_004))
|
||||
.unwrap();
|
||||
assert_eq!(sender.core.list_held_offers().unwrap().len(), 1);
|
||||
|
||||
sender
|
||||
.core
|
||||
.cancel_transfer(outcome.share.transfer_id)
|
||||
.unwrap();
|
||||
|
||||
assert!(sender.core.list_held_offers().unwrap().is_empty());
|
||||
receiver.start();
|
||||
assert_eq!(receiver.core().poll_contacts_for_offers().unwrap(), 0);
|
||||
}
|
||||
|
||||
/// A device with no relationship learns nothing by polling.
|
||||
#[test]
|
||||
fn polling_a_device_that_holds_nothing_for_you_returns_nothing() {
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
assert_eq!(receiver.core.poll_contacts_for_offers().unwrap(), 0);
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
}
|
||||
|
||||
/// A transfer created for an invitation can also be pushed to a device: the
|
||||
/// same ticket, another way to deliver it.
|
||||
#[test]
|
||||
fn an_existing_share_can_be_offered_to_a_contact() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"existing share").unwrap();
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
// An ordinary share, as if the user had created it for a QR code.
|
||||
let share = sender
|
||||
.core
|
||||
.share_files(sources(&source_path), metadata(6_001))
|
||||
.expect("shared");
|
||||
|
||||
let core = sender.core.arc();
|
||||
let to = endpoint_id(&receiver);
|
||||
let handle = std::thread::spawn(move || core.offer_transfer_to_contact(share.transfer_id, to));
|
||||
|
||||
let offer = wait_for_offer(&receiver.core);
|
||||
let ticket = receiver
|
||||
.core
|
||||
.respond_to_offer(offer.offer_id, true)
|
||||
.expect("accepting yields the ticket");
|
||||
let outcome = handle.join().unwrap().expect("offer accepted");
|
||||
|
||||
assert!(outcome.delivered);
|
||||
assert_eq!(
|
||||
outcome.share.transfer_id, share.transfer_id,
|
||||
"offering reuses the existing transfer rather than creating another"
|
||||
);
|
||||
assert_eq!(ticket, share.ticket, "the invitation is the stored one");
|
||||
|
||||
receiver
|
||||
.core
|
||||
.receive(
|
||||
ticket,
|
||||
output_dir.path().to_string_lossy().to_string(),
|
||||
Some("Receiver".to_string()),
|
||||
)
|
||||
.expect("receive completes");
|
||||
assert_eq!(
|
||||
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||
b"existing share"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stopped share serves nothing, so its ticket must not be handed out.
|
||||
#[test]
|
||||
fn a_stopped_share_cannot_be_offered() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("shared.txt");
|
||||
std::fs::write(&source_path, b"content").unwrap();
|
||||
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
let share = sender
|
||||
.core
|
||||
.share_files(sources(&source_path), metadata(6_002))
|
||||
.expect("shared");
|
||||
sender.core.cancel_transfer(share.transfer_id).unwrap();
|
||||
|
||||
let outcome = sender
|
||||
.core
|
||||
.offer_transfer_to_contact(share.transfer_id, endpoint_id(&receiver));
|
||||
|
||||
assert!(outcome.is_err());
|
||||
assert!(receiver.core.list_pending_offers().is_empty());
|
||||
}
|
||||
|
||||
/// Offering an unknown transfer is rejected rather than silently doing nothing.
|
||||
#[test]
|
||||
fn offering_an_unknown_transfer_is_rejected() {
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
pair(&receiver, &sender);
|
||||
|
||||
assert!(sender
|
||||
.core
|
||||
.offer_transfer_to_contact(9_999, endpoint_id(&receiver))
|
||||
.is_err());
|
||||
}
|
||||
252
crates/vnidrop/tests/pairing.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! Device history pairing over the offer ALPN, between two real nodes.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use support::TestNode;
|
||||
use vnidrop::VnidropCore;
|
||||
|
||||
fn endpoint_id(node: &TestNode) -> String {
|
||||
node.core.status().endpoint_id
|
||||
}
|
||||
|
||||
/// The pairing prompt arrives asynchronously on the peer's side.
|
||||
fn wait_for_pending_pairing(core: &VnidropCore, from_endpoint: &str) {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if core
|
||||
.list_pending_pairings()
|
||||
.iter()
|
||||
.any(|pending| pending.endpoint_id == from_endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"pairing offer from {from_endpoint} never surfaced"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
/// Alice agrees to be reachable by Bob; Bob consents; Bob can now reach Alice.
|
||||
#[test]
|
||||
fn a_delivered_grant_becomes_a_contact_only_after_the_peer_consents() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let bob_id = endpoint_id(&bob);
|
||||
let alice_id = endpoint_id(&alice);
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string()))
|
||||
.expect("grant delivered");
|
||||
|
||||
// Delivery alone must not create a contact: Bob has not agreed yet.
|
||||
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||
assert!(
|
||||
bob.core.list_contacts().unwrap().is_empty(),
|
||||
"an undelivered-consent grant must not appear as a contact"
|
||||
);
|
||||
|
||||
assert!(bob
|
||||
.core
|
||||
.respond_to_pairing(alice_id.clone(), true)
|
||||
.expect("consent recorded"));
|
||||
|
||||
let contacts = bob.core.list_contacts().unwrap();
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].endpoint_id, alice_id);
|
||||
assert!(
|
||||
contacts[0].can_send,
|
||||
"holding a live grant is what makes a contact reachable"
|
||||
);
|
||||
assert!(bob.core.list_pending_pairings().is_empty());
|
||||
}
|
||||
|
||||
/// Declining leaves nothing behind: no contact, no stored capability.
|
||||
#[test]
|
||||
fn declining_a_pairing_stores_nothing() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let alice_id = endpoint_id(&alice);
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(endpoint_id(&bob), None)
|
||||
.expect("grant delivered");
|
||||
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||
|
||||
assert!(bob
|
||||
.core
|
||||
.respond_to_pairing(alice_id.clone(), false)
|
||||
.unwrap());
|
||||
|
||||
assert!(bob.core.list_contacts().unwrap().is_empty());
|
||||
assert!(bob.core.list_pending_pairings().is_empty());
|
||||
assert!(
|
||||
!bob.core.respond_to_pairing(alice_id, true).unwrap(),
|
||||
"a declined offer cannot be accepted afterwards"
|
||||
);
|
||||
}
|
||||
|
||||
/// The pairing is directional: Alice issuing to Bob does not let Alice reach Bob.
|
||||
#[test]
|
||||
fn each_direction_is_a_separate_decision() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let alice_id = endpoint_id(&alice);
|
||||
let bob_id = endpoint_id(&bob);
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id.clone(), None)
|
||||
.expect("grant delivered");
|
||||
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||
|
||||
// Alice recorded Bob as a contact when she issued, but she holds no grant
|
||||
// from him, so she cannot reach him.
|
||||
let alice_contacts = alice.core.list_contacts().unwrap();
|
||||
assert_eq!(alice_contacts.len(), 1);
|
||||
assert_eq!(alice_contacts[0].endpoint_id, bob_id);
|
||||
assert!(
|
||||
!alice_contacts[0].can_send,
|
||||
"issuing a grant does not grant the issuer anything in return"
|
||||
);
|
||||
}
|
||||
|
||||
/// Revoking kills the peer's entry without their cooperation, and tells them.
|
||||
#[test]
|
||||
fn forgetting_a_contact_revokes_the_peers_access() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let alice_id = endpoint_id(&alice);
|
||||
let bob_id = endpoint_id(&bob);
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id.clone(), None)
|
||||
.expect("grant delivered");
|
||||
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||
assert!(bob.core.list_contacts().unwrap()[0].can_send);
|
||||
|
||||
alice.core.forget_contact(bob_id).expect("forgotten");
|
||||
|
||||
// Best-effort notification: Bob is online, so his dead entry should clear
|
||||
// promptly rather than at his next attempt.
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let contacts = bob.core.list_contacts().unwrap();
|
||||
let cleared = contacts.first().is_none_or(|contact| !contact.can_send);
|
||||
if cleared {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"revocation notice never reached the peer"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
assert!(alice.core.list_contacts().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A blocked device is refused, and cannot tell blocking from any other refusal.
|
||||
#[test]
|
||||
fn a_blocked_device_cannot_pair() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let bob_id = endpoint_id(&bob);
|
||||
|
||||
bob.core
|
||||
.block_contact(endpoint_id(&alice))
|
||||
.expect("blocked");
|
||||
|
||||
let outcome = alice.core.allow_device_to_reach_me(bob_id, None);
|
||||
|
||||
assert!(outcome.is_err(), "a blocked peer must refuse the grant");
|
||||
assert!(bob.core.list_pending_pairings().is_empty());
|
||||
assert!(bob.core.list_contacts().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Blocking locally also prevents pairing outward, so the block is symmetric
|
||||
/// from the user's point of view.
|
||||
#[test]
|
||||
fn blocking_prevents_issuing_a_grant_to_that_device() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let bob_id = endpoint_id(&bob);
|
||||
|
||||
alice.core.block_contact(bob_id.clone()).expect("blocked");
|
||||
|
||||
let outcome = alice.core.allow_device_to_reach_me(bob_id.clone(), None);
|
||||
assert!(outcome.is_err());
|
||||
|
||||
alice
|
||||
.core
|
||||
.unblock_contact(bob_id.clone())
|
||||
.expect("unblocked");
|
||||
assert!(alice.core.list_blocked_contacts().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Re-pairing an existing contact refreshes the grant without a second prompt.
|
||||
#[test]
|
||||
fn re_pairing_a_known_contact_does_not_prompt_again() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let alice_id = endpoint_id(&alice);
|
||||
let bob_id = endpoint_id(&bob);
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id.clone(), None)
|
||||
.unwrap();
|
||||
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id, None)
|
||||
.expect("re-issued");
|
||||
|
||||
assert!(
|
||||
bob.core.list_pending_pairings().is_empty(),
|
||||
"an established contact must not raise a fresh consent prompt"
|
||||
);
|
||||
assert_eq!(bob.core.list_contacts().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// The user's own label survives whatever the remote later calls itself.
|
||||
#[test]
|
||||
fn a_local_label_survives_a_remote_rename() {
|
||||
let alice = TestNode::new();
|
||||
let bob = TestNode::new();
|
||||
let alice_id = endpoint_id(&alice);
|
||||
let bob_id = endpoint_id(&bob);
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string()))
|
||||
.unwrap();
|
||||
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||
bob.core
|
||||
.set_contact_label(alice_id.clone(), Some("Work Mac".to_string()))
|
||||
.unwrap();
|
||||
|
||||
alice
|
||||
.core
|
||||
.allow_device_to_reach_me(bob_id, Some("Totally Not Evil".to_string()))
|
||||
.unwrap();
|
||||
|
||||
let contact = bob
|
||||
.core
|
||||
.list_contacts()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|contact| contact.endpoint_id == alice_id)
|
||||
.expect("contact");
|
||||
assert_eq!(contact.local_label.as_deref(), Some("Work Mac"));
|
||||
}
|
||||
@@ -3,14 +3,14 @@ import type { Metadata } from "next";
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy policy",
|
||||
description:
|
||||
"How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.",
|
||||
"How VniDrop handles transfers, local app data, optional bug reports, and website visits.",
|
||||
};
|
||||
|
||||
const sections = [
|
||||
["scope", "Scope"],
|
||||
["transfers", "Transfers"],
|
||||
["local-data", "Local data"],
|
||||
["diagnostics", "Diagnostics"],
|
||||
["bug-reports", "Bug reports"],
|
||||
["website", "Website"],
|
||||
["permissions", "Permissions"],
|
||||
["providers", "Service providers"],
|
||||
@@ -29,9 +29,9 @@ export default function PrivacyPage() {
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>
|
||||
This policy explains what moves between devices, what stays local, and what is sent
|
||||
only when you choose to share diagnostics or a bug report.
|
||||
only when you choose to submit a bug report.
|
||||
</p>
|
||||
<p className="privacy-meta">Effective July 16, 2026 · Version 1.1</p>
|
||||
<p className="privacy-meta">Effective August 2, 2026 · Version 1.2</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
VniDrop has no user accounts and does not upload your transfer to a VniDrop file
|
||||
store. Files travel over an authenticated, end-to-end encrypted connection.
|
||||
Product diagnostics are opt-in; a bug report is sent only when you submit one.
|
||||
VniDrop has no telemetry or analytics; a bug report is sent only when you submit one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
|
||||
<h2>Scope and who “VniDrop” means</h2>
|
||||
<p>
|
||||
This policy covers the official VniDrop website, the VniDrop applications for
|
||||
Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by
|
||||
Android, iOS, macOS, Windows, and Linux, and the bug-report service configured by
|
||||
the official project. For an official release, VniDrop’s data controller is the
|
||||
individual publisher named in the applicable app-store listing. In this policy,
|
||||
“VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
|
||||
@@ -72,7 +72,7 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
<p>
|
||||
VniDrop is open-source software. A build distributed or operated by someone else
|
||||
may use different networking infrastructure, diagnostics settings, or website
|
||||
may use different networking infrastructure, bug-report settings, or website
|
||||
hosting. That distributor is responsible for explaining its own practices.
|
||||
</p>
|
||||
</section>
|
||||
@@ -117,9 +117,9 @@ export default function PrivacyPage() {
|
||||
<ul>
|
||||
<li>device identity and networking keys used to establish secure connections;</li>
|
||||
<li>active shares, transfer history, receiver requests, progress, and status;</li>
|
||||
<li>app preferences, including access and diagnostics choices;</li>
|
||||
<li>app preferences, including access choices;</li>
|
||||
<li>download destinations and locally managed transfer data; and</li>
|
||||
<li>an anonymous installation identifier used only for diagnostics correlation.</li>
|
||||
<li>an anonymous installation identifier used only for bug-report correlation.</li>
|
||||
</ul>
|
||||
<p>
|
||||
This information remains until you remove the relevant history, stop or delete a
|
||||
@@ -129,33 +129,27 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="diagnostics" className="policy-section">
|
||||
<h2>Optional diagnostics and bug reports</h2>
|
||||
<h3>Automatic product diagnostics</h3>
|
||||
<section id="bug-reports" className="policy-section">
|
||||
<h2>Optional bug reports</h2>
|
||||
<p>
|
||||
Official releases indicate in the app settings whether automatic product
|
||||
diagnostics are included. When included, automatic usage events and crash reports
|
||||
are disabled until you enable “Share diagnostics.” If enabled, VniDrop may send an
|
||||
anonymous installation ID, app version, platform, sparse event names and properties,
|
||||
crash type and message, a redacted stack trace, timestamps, and recent in-app
|
||||
breadcrumbs. You can turn this off at any time; doing so also removes pending local
|
||||
crash reports.
|
||||
VniDrop has no automatic telemetry, usage analytics, or crash auto-reporting.
|
||||
Nothing is sent to a bug-report service unless you explicitly submit a report.
|
||||
</p>
|
||||
<h3>User-submitted bug reports</h3>
|
||||
<p>
|
||||
A bug report is separate from the diagnostics toggle and is sent only when you press
|
||||
submit. It can contain what you say happened, what you expected, reproduction steps,
|
||||
an optional contact email, app and platform versions, an anonymous installation ID,
|
||||
device name and model, operating system, network and battery information, recent
|
||||
breadcrumbs, and optional recent logs. You can exclude logs before submitting.
|
||||
A bug report is sent only when you press submit. It can contain what you say
|
||||
happened, what you expected, reproduction steps, an optional contact email, app and
|
||||
platform versions, an anonymous installation ID, device name and model, operating
|
||||
system, network and battery information, and optional recent logs. You can exclude
|
||||
logs before submitting.
|
||||
</p>
|
||||
<h3>Data deliberately excluded</h3>
|
||||
<p>
|
||||
Automatic diagnostics are designed to exclude transfer contents, invitations, and
|
||||
file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules
|
||||
intended to redact invitation tokens, endpoint identifiers, absolute paths, file and
|
||||
content URIs, and platform document identifiers. No redaction system is perfect, so
|
||||
review anything you type into a bug report and avoid including secrets.
|
||||
Bug reports are designed to exclude transfer contents, invitations, and file paths.
|
||||
Before optional logs are sent, VniDrop applies rules intended to redact invitation
|
||||
tokens, endpoint identifiers, absolute paths, file and content URIs, and platform
|
||||
document identifiers. No redaction system is perfect, so review anything you type
|
||||
into a bug report and avoid including secrets.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -223,7 +217,7 @@ export default function PrivacyPage() {
|
||||
<dt>Cloudflare</dt>
|
||||
<dd>
|
||||
Proxies website requests and provides DNS, security, and abuse controls. When
|
||||
the optional diagnostics service is configured, it uses Cloudflare Workers, D1,
|
||||
the optional bug-report service is configured, it uses Cloudflare Workers, D1,
|
||||
and R2.
|
||||
</dd>
|
||||
</div>
|
||||
@@ -300,11 +294,7 @@ export default function PrivacyPage() {
|
||||
<td>Until you delete them, clear app data, or uninstall</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Pending local crash reports</th>
|
||||
<td>Up to 30 days and 20 reports; deleted when diagnostics is disabled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Server diagnostics and bug reports</th>
|
||||
<th scope="row">Server bug reports</th>
|
||||
<td>The current project configuration is 90 days, with scheduled deletion</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -317,7 +307,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
Operational backups, provider logs, and deletion backlogs may persist briefly beyond
|
||||
the stated period where necessary for security, integrity, or legal obligations. If
|
||||
the production diagnostics retention configuration changes, this policy should be
|
||||
the production bug-report retention configuration changes, this policy should be
|
||||
updated to match it.
|
||||
</p>
|
||||
</section>
|
||||
@@ -325,7 +315,6 @@ export default function PrivacyPage() {
|
||||
<section id="choices" className="policy-section">
|
||||
<h2>Your choices and rights</h2>
|
||||
<ul>
|
||||
<li>Enable or disable “Share diagnostics” in VniDrop settings.</li>
|
||||
<li>
|
||||
Submit a bug report only when you choose, omit contact information, and exclude
|
||||
logs.
|
||||
@@ -343,7 +332,7 @@ export default function PrivacyPage() {
|
||||
<p>
|
||||
Depending on where you live, privacy law may provide rights to access, correct,
|
||||
delete, restrict, or object to processing of personal information. Because VniDrop
|
||||
has no account and automatic diagnostics use an anonymous installation ID, we may
|
||||
has no account and bug reports use an anonymous installation ID, we may
|
||||
not be able to connect a server record to you without additional information. Use
|
||||
the contact method below and provide only what is needed to locate your submission.
|
||||
</p>
|
||||
@@ -353,7 +342,7 @@ export default function PrivacyPage() {
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
VniDrop uses authenticated end-to-end encrypted connections, content verification,
|
||||
deny-by-default share access, bounded diagnostics payloads, redaction, and safe file
|
||||
deny-by-default share access, bounded bug-report payloads, redaction, and safe file
|
||||
publishing that avoids silently replacing an existing file. No system can guarantee
|
||||
absolute security. Keep invitations private, verify receiver names, keep your device
|
||||
updated, and stop sharing when a transfer is finished.
|
||||
|
||||
@@ -13,9 +13,8 @@ android.nonTransitiveRClass=true
|
||||
android.sourceset.disallowProvider=false
|
||||
android.useAndroidX=true
|
||||
|
||||
# VniDrop: compile-time diagnostics/telemetry product surface.
|
||||
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
||||
# Bug report UI remains available (user-initiated).
|
||||
# VniDrop: compile-time bug-report delivery surface.
|
||||
# false → user-initiated bug reports fall back to a NoOp transport (never sent).
|
||||
# Enable per build only when endpoint and ingest key are configured:
|
||||
# ./gradlew … -Pvnidrop.diagnostics.included=true
|
||||
vnidrop.diagnostics.included=false
|
||||
|
||||
@@ -1178,62 +1178,6 @@
|
||||
"ru": "Имя устройства"
|
||||
}
|
||||
},
|
||||
"diagnostics_description": {
|
||||
"context": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.",
|
||||
"translations": {
|
||||
"en": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.",
|
||||
"fr": "Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.",
|
||||
"es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.",
|
||||
"it": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.",
|
||||
"de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.",
|
||||
"pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.",
|
||||
"pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.",
|
||||
"nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.",
|
||||
"ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются."
|
||||
}
|
||||
},
|
||||
"diagnostics_disabled_message": {
|
||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.",
|
||||
"translations": {
|
||||
"en": "Diagnostics sharing is off.",
|
||||
"fr": "Le partage des diagnostics est désactivé.",
|
||||
"es": "El uso compartido de diagnósticos está desactivado.",
|
||||
"it": "La condivisione dei dati diagnostici è disattivata.",
|
||||
"de": "Die Freigabe von Diagnosedaten ist deaktiviert.",
|
||||
"pt": "A partilha de diagnósticos está desativada.",
|
||||
"pl": "Udostępnianie diagnostyki jest wyłączone.",
|
||||
"nl": "Het delen van diagnostische gegevens is uitgeschakeld.",
|
||||
"ru": "Передача диагностики отключена."
|
||||
}
|
||||
},
|
||||
"diagnostics_enabled_message": {
|
||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.",
|
||||
"translations": {
|
||||
"en": "Diagnostics sharing is on.",
|
||||
"fr": "Le partage des diagnostics est activé.",
|
||||
"es": "El uso compartido de diagnósticos está activado.",
|
||||
"it": "La condivisione dei dati diagnostici è attivata.",
|
||||
"de": "Die Freigabe von Diagnosedaten ist aktiviert.",
|
||||
"pt": "A partilha de diagnósticos está ativada.",
|
||||
"pl": "Udostępnianie diagnostyki jest włączone.",
|
||||
"nl": "Het delen van diagnostische gegevens is ingeschakeld.",
|
||||
"ru": "Передача диагностики включена."
|
||||
}
|
||||
},
|
||||
"diagnostics_title": {
|
||||
"context": "Settings > Diagnostics: toggle title.",
|
||||
"translations": {
|
||||
"en": "Share diagnostics",
|
||||
"fr": "Partager les diagnostics",
|
||||
"es": "Compartir diagnósticos",
|
||||
"it": "Condividi dati diagnostici",
|
||||
"de": "Diagnosedaten teilen",
|
||||
"pt": "Partilhar diagnósticos",
|
||||
"pl": "Udostępniaj diagnostykę",
|
||||
"nl": "Diagnostische gegevens delen",
|
||||
"ru": "Делиться диагностикой"
|
||||
}
|
||||
},
|
||||
"error_camera": {
|
||||
"context": "Error: camera permission is needed to scan a QR code.",
|
||||
"translations": {
|
||||
@@ -4673,6 +4617,713 @@
|
||||
"nl": "App-versie",
|
||||
"ru": "Версия приложения"
|
||||
}
|
||||
},
|
||||
"contacts_title": {
|
||||
"context": "Devices screen: title of the list of remembered devices.",
|
||||
"translations": {
|
||||
"en": "Devices",
|
||||
"fr": "Appareils",
|
||||
"es": "Dispositivos",
|
||||
"it": "Dispositivi",
|
||||
"de": "Geräte",
|
||||
"pt": "Dispositivos",
|
||||
"pl": "Urządzenia",
|
||||
"nl": "Apparaten",
|
||||
"ru": "Устройства"
|
||||
}
|
||||
},
|
||||
"contacts_subtitle": {
|
||||
"context": "Devices screen: one-line explanation under the title.",
|
||||
"translations": {
|
||||
"en": "Devices you have transferred with can receive files without a new invitation.",
|
||||
"fr": "Les appareils avec lesquels vous avez déjà échangé peuvent recevoir des fichiers sans nouvelle invitation.",
|
||||
"es": "Los dispositivos con los que ya has compartido pueden recibir archivos sin una nueva invitación.",
|
||||
"it": "I dispositivi con cui hai già scambiato file possono riceverne altri senza un nuovo invito.",
|
||||
"de": "Geräte, mit denen Sie bereits Dateien ausgetauscht haben, können ohne neue Einladung Dateien empfangen.",
|
||||
"pt": "Os dispositivos com os quais já transferiu podem receber ficheiros sem um novo convite.",
|
||||
"pl": "Urządzenia, z którymi już przesyłano pliki, mogą je odbierać bez nowego zaproszenia.",
|
||||
"nl": "Apparaten waarmee je al hebt overgedragen, kunnen bestanden ontvangen zonder nieuwe uitnodiging.",
|
||||
"ru": "Устройства, с которыми вы уже обменивались файлами, могут получать их без нового приглашения."
|
||||
}
|
||||
},
|
||||
"contacts_empty_title": {
|
||||
"context": "Devices screen: empty-state title when no device has been remembered yet.",
|
||||
"translations": {
|
||||
"en": "No remembered devices",
|
||||
"fr": "Aucun appareil enregistré",
|
||||
"es": "Ningún dispositivo guardado",
|
||||
"it": "Nessun dispositivo memorizzato",
|
||||
"de": "Keine gespeicherten Geräte",
|
||||
"pt": "Nenhum dispositivo guardado",
|
||||
"pl": "Brak zapamiętanych urządzeń",
|
||||
"nl": "Geen onthouden apparaten",
|
||||
"ru": "Нет сохранённых устройств"
|
||||
}
|
||||
},
|
||||
"contacts_empty_body": {
|
||||
"context": "Devices screen: empty-state explanation of how a device gets remembered.",
|
||||
"translations": {
|
||||
"en": "After a transfer, both devices can choose to remember each other.",
|
||||
"fr": "Après un transfert, les deux appareils peuvent choisir de se mémoriser mutuellement.",
|
||||
"es": "Tras una transferencia, ambos dispositivos pueden elegir recordarse mutuamente.",
|
||||
"it": "Dopo un trasferimento, entrambi i dispositivi possono scegliere di ricordarsi a vicenda.",
|
||||
"de": "Nach einer Übertragung können beide Geräte einander speichern.",
|
||||
"pt": "Após uma transferência, ambos os dispositivos podem optar por lembrar-se um do outro.",
|
||||
"pl": "Po przesłaniu plików oba urządzenia mogą zapamiętać się nawzajem.",
|
||||
"nl": "Na een overdracht kunnen beide apparaten elkaar onthouden.",
|
||||
"ru": "После передачи оба устройства могут запомнить друг друга."
|
||||
}
|
||||
},
|
||||
"contacts_unreachable": {
|
||||
"context": "Devices screen: badge on a device that can no longer be sent to.",
|
||||
"translations": {
|
||||
"en": "Needs pairing again",
|
||||
"fr": "Nouvel appairage nécessaire",
|
||||
"es": "Requiere emparejar de nuevo",
|
||||
"it": "Richiede un nuovo abbinamento",
|
||||
"de": "Muss erneut gekoppelt werden",
|
||||
"pt": "É preciso emparelhar novamente",
|
||||
"pl": "Wymaga ponownego sparowania",
|
||||
"nl": "Opnieuw koppelen vereist",
|
||||
"ru": "Требуется повторное сопряжение"
|
||||
}
|
||||
},
|
||||
"contacts_unreachable_body": {
|
||||
"context": "Device detail: explains why a remembered device can no longer be reached.",
|
||||
"translations": {
|
||||
"en": "This device withdrew access, or reinstalled VniDrop. Transfer to it once more to remember it again.",
|
||||
"fr": "Cet appareil a retiré l’accès ou a réinstallé VniDrop. Effectuez un nouveau transfert pour le mémoriser à nouveau.",
|
||||
"es": "Este dispositivo retiró el acceso o reinstaló VniDrop. Realiza otra transferencia para volver a recordarlo.",
|
||||
"it": "Questo dispositivo ha revocato l’accesso o ha reinstallato VniDrop. Effettua un altro trasferimento per memorizzarlo di nuovo.",
|
||||
"de": "Dieses Gerät hat den Zugriff entzogen oder VniDrop neu installiert. Übertragen Sie erneut, um es wieder zu speichern.",
|
||||
"pt": "Este dispositivo retirou o acesso ou reinstalou o VniDrop. Faça outra transferência para o voltar a guardar.",
|
||||
"pl": "To urządzenie cofnęło dostęp lub ponownie zainstalowało VniDrop. Wykonaj kolejne przesłanie, aby zapamiętać je ponownie.",
|
||||
"nl": "Dit apparaat heeft de toegang ingetrokken of VniDrop opnieuw geïnstalleerd. Draag opnieuw over om het weer te onthouden.",
|
||||
"ru": "Это устройство отозвало доступ или переустановило VniDrop. Выполните новую передачу, чтобы снова его запомнить."
|
||||
}
|
||||
},
|
||||
"contacts_name_field": {
|
||||
"context": "Device detail: text field label for the name the local user gives a device.",
|
||||
"translations": {
|
||||
"en": "Name on this device",
|
||||
"fr": "Nom sur cet appareil",
|
||||
"es": "Nombre en este dispositivo",
|
||||
"it": "Nome su questo dispositivo",
|
||||
"de": "Name auf diesem Gerät",
|
||||
"pt": "Nome neste dispositivo",
|
||||
"pl": "Nazwa na tym urządzeniu",
|
||||
"nl": "Naam op dit apparaat",
|
||||
"ru": "Имя на этом устройстве"
|
||||
}
|
||||
},
|
||||
"contacts_name_hint": {
|
||||
"context": "Device detail: note that the local name is never changed by the other device.",
|
||||
"translations": {
|
||||
"en": "Only you see this name. The other device can never change it.",
|
||||
"fr": "Vous seul voyez ce nom. L’autre appareil ne peut jamais le modifier.",
|
||||
"es": "Solo tú ves este nombre. El otro dispositivo nunca puede cambiarlo.",
|
||||
"it": "Solo tu vedi questo nome. L’altro dispositivo non può mai modificarlo.",
|
||||
"de": "Nur Sie sehen diesen Namen. Das andere Gerät kann ihn nie ändern.",
|
||||
"pt": "Só você vê este nome. O outro dispositivo nunca o pode alterar.",
|
||||
"pl": "Tylko Ty widzisz tę nazwę. Drugie urządzenie nigdy jej nie zmieni.",
|
||||
"nl": "Alleen jij ziet deze naam. Het andere apparaat kan die nooit wijzigen.",
|
||||
"ru": "Это имя видите только вы. Другое устройство не может его изменить."
|
||||
}
|
||||
},
|
||||
"contacts_forget": {
|
||||
"context": "Device detail: button that removes a device and revokes its access.",
|
||||
"translations": {
|
||||
"en": "Forget device",
|
||||
"fr": "Oublier l’appareil",
|
||||
"es": "Olvidar dispositivo",
|
||||
"it": "Dimentica dispositivo",
|
||||
"de": "Gerät entfernen",
|
||||
"pt": "Esquecer dispositivo",
|
||||
"pl": "Zapomnij urządzenie",
|
||||
"nl": "Apparaat vergeten",
|
||||
"ru": "Забыть устройство"
|
||||
}
|
||||
},
|
||||
"contacts_forget_body": {
|
||||
"context": "Device detail: confirmation explaining what forgetting a device does.",
|
||||
"translations": {
|
||||
"en": "This device will no longer be able to send you files without a new invitation. Files already received are kept.",
|
||||
"fr": "Cet appareil ne pourra plus vous envoyer de fichiers sans nouvelle invitation. Les fichiers déjà reçus sont conservés.",
|
||||
"es": "Este dispositivo ya no podrá enviarte archivos sin una nueva invitación. Los archivos ya recibidos se conservan.",
|
||||
"it": "Questo dispositivo non potrà più inviarti file senza un nuovo invito. I file già ricevuti vengono conservati.",
|
||||
"de": "Dieses Gerät kann Ihnen ohne neue Einladung keine Dateien mehr senden. Bereits empfangene Dateien bleiben erhalten.",
|
||||
"pt": "Este dispositivo deixará de lhe poder enviar ficheiros sem um novo convite. Os ficheiros já recebidos são mantidos.",
|
||||
"pl": "To urządzenie nie będzie mogło wysyłać Ci plików bez nowego zaproszenia. Już odebrane pliki pozostaną.",
|
||||
"nl": "Dit apparaat kan je zonder nieuwe uitnodiging geen bestanden meer sturen. Reeds ontvangen bestanden blijven behouden.",
|
||||
"ru": "Это устройство больше не сможет отправлять вам файлы без нового приглашения. Уже полученные файлы сохранятся."
|
||||
}
|
||||
},
|
||||
"contacts_forget_all": {
|
||||
"context": "Devices screen: button that forgets every remembered device at once.",
|
||||
"translations": {
|
||||
"en": "Forget all devices",
|
||||
"fr": "Oublier tous les appareils",
|
||||
"es": "Olvidar todos los dispositivos",
|
||||
"it": "Dimentica tutti i dispositivi",
|
||||
"de": "Alle Geräte entfernen",
|
||||
"pt": "Esquecer todos os dispositivos",
|
||||
"pl": "Zapomnij wszystkie urządzenia",
|
||||
"nl": "Alle apparaten vergeten",
|
||||
"ru": "Забыть все устройства"
|
||||
}
|
||||
},
|
||||
"contacts_block": {
|
||||
"context": "Device detail: button that blocks a device outright.",
|
||||
"translations": {
|
||||
"en": "Block device",
|
||||
"fr": "Bloquer l’appareil",
|
||||
"es": "Bloquear dispositivo",
|
||||
"it": "Blocca dispositivo",
|
||||
"de": "Gerät blockieren",
|
||||
"pt": "Bloquear dispositivo",
|
||||
"pl": "Zablokuj urządzenie",
|
||||
"nl": "Apparaat blokkeren",
|
||||
"ru": "Заблокировать устройство"
|
||||
}
|
||||
},
|
||||
"contacts_blocked_title": {
|
||||
"context": "Devices screen: section listing blocked devices.",
|
||||
"translations": {
|
||||
"en": "Blocked devices",
|
||||
"fr": "Appareils bloqués",
|
||||
"es": "Dispositivos bloqueados",
|
||||
"it": "Dispositivi bloccati",
|
||||
"de": "Blockierte Geräte",
|
||||
"pt": "Dispositivos bloqueados",
|
||||
"pl": "Zablokowane urządzenia",
|
||||
"nl": "Geblokkeerde apparaten",
|
||||
"ru": "Заблокированные устройства"
|
||||
}
|
||||
},
|
||||
"contacts_unblock": {
|
||||
"context": "Devices screen: button that removes a device from the block list.",
|
||||
"translations": {
|
||||
"en": "Unblock",
|
||||
"fr": "Débloquer",
|
||||
"es": "Desbloquear",
|
||||
"it": "Sblocca",
|
||||
"de": "Freigeben",
|
||||
"pt": "Desbloquear",
|
||||
"pl": "Odblokuj",
|
||||
"nl": "Deblokkeren",
|
||||
"ru": "Разблокировать"
|
||||
}
|
||||
},
|
||||
"contacts_unblock_hint": {
|
||||
"context": "Devices screen: note that unblocking does not restore the previous access.",
|
||||
"translations": {
|
||||
"en": "Unblocking does not restore access. The device has to be paired again.",
|
||||
"fr": "Le déblocage ne rétablit pas l’accès. L’appareil doit être appairé à nouveau.",
|
||||
"es": "Desbloquear no restaura el acceso. Hay que emparejar el dispositivo de nuevo.",
|
||||
"it": "Sbloccare non ripristina l’accesso. Il dispositivo deve essere abbinato di nuovo.",
|
||||
"de": "Die Freigabe stellt den Zugriff nicht wieder her. Das Gerät muss erneut gekoppelt werden.",
|
||||
"pt": "Desbloquear não restaura o acesso. O dispositivo tem de ser emparelhado novamente.",
|
||||
"pl": "Odblokowanie nie przywraca dostępu. Urządzenie trzeba sparować ponownie.",
|
||||
"nl": "Deblokkeren herstelt de toegang niet. Het apparaat moet opnieuw worden gekoppeld.",
|
||||
"ru": "Разблокировка не восстанавливает доступ. Устройство нужно сопрячь заново."
|
||||
}
|
||||
},
|
||||
"contacts_send_to": {
|
||||
"context": "Device detail: button that starts choosing files to send to this device.",
|
||||
"translations": {
|
||||
"en": "Send files",
|
||||
"fr": "Envoyer des fichiers",
|
||||
"es": "Enviar archivos",
|
||||
"it": "Invia file",
|
||||
"de": "Dateien senden",
|
||||
"pt": "Enviar ficheiros",
|
||||
"pl": "Wyślij pliki",
|
||||
"nl": "Bestanden sturen",
|
||||
"ru": "Отправить файлы"
|
||||
}
|
||||
},
|
||||
"contacts_last_transfer": {
|
||||
"context": "Devices screen: subtitle showing when the last transfer with a device happened. {date} = formatted date.",
|
||||
"args": [
|
||||
{
|
||||
"name": "date",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "Last transfer {date}",
|
||||
"fr": "Dernier transfert {date}",
|
||||
"es": "Última transferencia {date}",
|
||||
"it": "Ultimo trasferimento {date}",
|
||||
"de": "Letzte Übertragung {date}",
|
||||
"pt": "Última transferência {date}",
|
||||
"pl": "Ostatnie przesłanie {date}",
|
||||
"nl": "Laatste overdracht {date}",
|
||||
"ru": "Последняя передача {date}"
|
||||
}
|
||||
},
|
||||
"pairing_request_title": {
|
||||
"context": "Pairing prompt: title asking whether to remember a device that offered to be reachable.",
|
||||
"translations": {
|
||||
"en": "Remember this device?",
|
||||
"fr": "Mémoriser cet appareil ?",
|
||||
"es": "¿Recordar este dispositivo?",
|
||||
"it": "Ricordare questo dispositivo?",
|
||||
"de": "Dieses Gerät speichern?",
|
||||
"pt": "Lembrar este dispositivo?",
|
||||
"pl": "Zapamiętać to urządzenie?",
|
||||
"nl": "Dit apparaat onthouden?",
|
||||
"ru": "Запомнить это устройство?"
|
||||
}
|
||||
},
|
||||
"pairing_request_body": {
|
||||
"context": "Pairing prompt: explains what remembering a device allows. {device} = peer display name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "device",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{device} offered to let you send it files without a new invitation.",
|
||||
"fr": "{device} propose de recevoir vos fichiers sans nouvelle invitation.",
|
||||
"es": "{device} te ofrece enviarle archivos sin una nueva invitación.",
|
||||
"it": "{device} ti consente di inviargli file senza un nuovo invito.",
|
||||
"de": "{device} bietet an, Dateien ohne neue Einladung von Ihnen zu empfangen.",
|
||||
"pt": "{device} ofereceu-se para receber ficheiros seus sem um novo convite.",
|
||||
"pl": "{device} umożliwia wysyłanie plików bez nowego zaproszenia.",
|
||||
"nl": "{device} biedt aan bestanden van je te ontvangen zonder nieuwe uitnodiging.",
|
||||
"ru": "{device} разрешает отправлять файлы без нового приглашения."
|
||||
}
|
||||
},
|
||||
"pairing_accept": {
|
||||
"context": "Pairing prompt: button that remembers the device.",
|
||||
"translations": {
|
||||
"en": "Remember",
|
||||
"fr": "Mémoriser",
|
||||
"es": "Recordar",
|
||||
"it": "Ricorda",
|
||||
"de": "Speichern",
|
||||
"pt": "Lembrar",
|
||||
"pl": "Zapamiętaj",
|
||||
"nl": "Onthouden",
|
||||
"ru": "Запомнить"
|
||||
}
|
||||
},
|
||||
"pairing_decline": {
|
||||
"context": "Pairing prompt: button that declines to remember the device.",
|
||||
"translations": {
|
||||
"en": "Not now",
|
||||
"fr": "Pas maintenant",
|
||||
"es": "Ahora no",
|
||||
"it": "Non ora",
|
||||
"de": "Jetzt nicht",
|
||||
"pt": "Agora não",
|
||||
"pl": "Nie teraz",
|
||||
"nl": "Niet nu",
|
||||
"ru": "Не сейчас"
|
||||
}
|
||||
},
|
||||
"pairing_allow_title": {
|
||||
"context": "Post-transfer prompt: asks whether the other device may send files later without an invitation.",
|
||||
"translations": {
|
||||
"en": "Let this device send to you?",
|
||||
"fr": "Autoriser cet appareil à vous envoyer des fichiers ?",
|
||||
"es": "¿Permitir que este dispositivo te envíe archivos?",
|
||||
"it": "Consentire a questo dispositivo di inviarti file?",
|
||||
"de": "Diesem Gerät erlauben, Ihnen Dateien zu senden?",
|
||||
"pt": "Permitir que este dispositivo lhe envie ficheiros?",
|
||||
"pl": "Zezwolić temu urządzeniu na wysyłanie plików?",
|
||||
"nl": "Dit apparaat toestaan je bestanden te sturen?",
|
||||
"ru": "Разрешить этому устройству отправлять вам файлы?"
|
||||
}
|
||||
},
|
||||
"pairing_allow_body": {
|
||||
"context": "Post-transfer prompt: explains that the permission is revocable at any time.",
|
||||
"translations": {
|
||||
"en": "You will still confirm every transfer, and you can withdraw this at any time.",
|
||||
"fr": "Vous confirmerez toujours chaque transfert et pourrez révoquer cette autorisation à tout moment.",
|
||||
"es": "Seguirás confirmando cada transferencia y podrás retirar este permiso cuando quieras.",
|
||||
"it": "Confermerai comunque ogni trasferimento e potrai revocare questa autorizzazione in qualsiasi momento.",
|
||||
"de": "Sie bestätigen weiterhin jede Übertragung und können dies jederzeit widerrufen.",
|
||||
"pt": "Continuará a confirmar cada transferência e pode retirar esta permissão a qualquer momento.",
|
||||
"pl": "Nadal będziesz potwierdzać każde przesłanie i możesz w każdej chwili cofnąć zgodę.",
|
||||
"nl": "Je bevestigt nog steeds elke overdracht en kunt dit altijd intrekken.",
|
||||
"ru": "Вы по-прежнему будете подтверждать каждую передачу и сможете отозвать разрешение в любой момент."
|
||||
}
|
||||
},
|
||||
"pairing_allow_confirm": {
|
||||
"context": "Post-transfer prompt: button granting the other device permission to send later.",
|
||||
"translations": {
|
||||
"en": "Allow",
|
||||
"fr": "Autoriser",
|
||||
"es": "Permitir",
|
||||
"it": "Consenti",
|
||||
"de": "Erlauben",
|
||||
"pt": "Permitir",
|
||||
"pl": "Zezwól",
|
||||
"nl": "Toestaan",
|
||||
"ru": "Разрешить"
|
||||
}
|
||||
},
|
||||
"offer_title": {
|
||||
"context": "Offer prompt: title when a remembered device wants to send files.",
|
||||
"translations": {
|
||||
"en": "Incoming transfer",
|
||||
"fr": "Transfert entrant",
|
||||
"es": "Transferencia entrante",
|
||||
"it": "Trasferimento in arrivo",
|
||||
"de": "Eingehende Übertragung",
|
||||
"pt": "Transferência recebida",
|
||||
"pl": "Przychodzące przesłanie",
|
||||
"nl": "Inkomende overdracht",
|
||||
"ru": "Входящая передача"
|
||||
}
|
||||
},
|
||||
"offer_body": {
|
||||
"context": "Offer prompt: names the sender and what they want to send. {device} = sender, {transferName} = transfer title.",
|
||||
"args": [
|
||||
{
|
||||
"name": "device",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{device} wants to send you “{transferName}”.",
|
||||
"fr": "{device} souhaite vous envoyer « {transferName} ».",
|
||||
"es": "{device} quiere enviarte «{transferName}».",
|
||||
"it": "{device} vuole inviarti «{transferName}».",
|
||||
"de": "{device} möchte Ihnen „{transferName}“ senden.",
|
||||
"pt": "{device} quer enviar-lhe “{transferName}”.",
|
||||
"pl": "{device} chce wysłać Ci „{transferName}”.",
|
||||
"nl": "{device} wil je “{transferName}” sturen.",
|
||||
"ru": "{device} хочет отправить вам «{transferName}»."
|
||||
}
|
||||
},
|
||||
"offer_accept": {
|
||||
"context": "Offer prompt: button that accepts the transfer and starts receiving.",
|
||||
"translations": {
|
||||
"en": "Receive",
|
||||
"fr": "Recevoir",
|
||||
"es": "Recibir",
|
||||
"it": "Ricevi",
|
||||
"de": "Empfangen",
|
||||
"pt": "Receber",
|
||||
"pl": "Odbierz",
|
||||
"nl": "Ontvangen",
|
||||
"ru": "Получить"
|
||||
}
|
||||
},
|
||||
"offer_decline": {
|
||||
"context": "Offer prompt: button that declines the incoming transfer.",
|
||||
"translations": {
|
||||
"en": "Decline",
|
||||
"fr": "Refuser",
|
||||
"es": "Rechazar",
|
||||
"it": "Rifiuta",
|
||||
"de": "Ablehnen",
|
||||
"pt": "Recusar",
|
||||
"pl": "Odrzuć",
|
||||
"nl": "Weigeren",
|
||||
"ru": "Отклонить"
|
||||
}
|
||||
},
|
||||
"contacts_grant_lifetime_title": {
|
||||
"context": "Devices settings: how long a remembered device stays reachable while unused.",
|
||||
"translations": {
|
||||
"en": "Forget unused devices after",
|
||||
"fr": "Oublier les appareils inutilisés après",
|
||||
"es": "Olvidar dispositivos sin usar tras",
|
||||
"it": "Dimentica i dispositivi inutilizzati dopo",
|
||||
"de": "Ungenutzte Geräte entfernen nach",
|
||||
"pt": "Esquecer dispositivos não usados após",
|
||||
"pl": "Zapomnij nieużywane urządzenia po",
|
||||
"nl": "Ongebruikte apparaten vergeten na",
|
||||
"ru": "Забывать неиспользуемые устройства через"
|
||||
}
|
||||
},
|
||||
"contacts_grant_lifetime_hint": {
|
||||
"context": "Devices settings: clarifies that the countdown restarts on each transfer.",
|
||||
"translations": {
|
||||
"en": "The countdown restarts every time you transfer with the device.",
|
||||
"fr": "Le décompte redémarre à chaque transfert avec l’appareil.",
|
||||
"es": "La cuenta atrás se reinicia cada vez que transfieres con el dispositivo.",
|
||||
"it": "Il conteggio riparte a ogni trasferimento con il dispositivo.",
|
||||
"de": "Die Frist beginnt bei jeder Übertragung mit dem Gerät neu.",
|
||||
"pt": "A contagem reinicia sempre que transfere com o dispositivo.",
|
||||
"pl": "Odliczanie zaczyna się od nowa przy każdym przesłaniu.",
|
||||
"nl": "De teller start opnieuw bij elke overdracht met het apparaat.",
|
||||
"ru": "Отсчёт начинается заново при каждой передаче с устройством."
|
||||
}
|
||||
},
|
||||
"contacts_grant_lifetime_never": {
|
||||
"context": "Devices settings: option to never forget an unused device.",
|
||||
"translations": {
|
||||
"en": "Never",
|
||||
"fr": "Jamais",
|
||||
"es": "Nunca",
|
||||
"it": "Mai",
|
||||
"de": "Nie",
|
||||
"pt": "Nunca",
|
||||
"pl": "Nigdy",
|
||||
"nl": "Nooit",
|
||||
"ru": "Никогда"
|
||||
}
|
||||
},
|
||||
"contacts_grant_lifetime_days": {
|
||||
"context": "Devices settings: option label for a number of days. {count} = days.",
|
||||
"args": [
|
||||
{
|
||||
"name": "count",
|
||||
"type": "int"
|
||||
}
|
||||
],
|
||||
"plural": {
|
||||
"en": {
|
||||
"one": "{count} day",
|
||||
"other": "{count} days"
|
||||
},
|
||||
"fr": {
|
||||
"one": "{count} jour",
|
||||
"other": "{count} jours"
|
||||
},
|
||||
"es": {
|
||||
"one": "{count} día",
|
||||
"other": "{count} días"
|
||||
},
|
||||
"it": {
|
||||
"one": "{count} giorno",
|
||||
"other": "{count} giorni"
|
||||
},
|
||||
"de": {
|
||||
"one": "{count} Tag",
|
||||
"other": "{count} Tage"
|
||||
},
|
||||
"pt": {
|
||||
"one": "{count} dia",
|
||||
"other": "{count} dias"
|
||||
},
|
||||
"pl": {
|
||||
"one": "{count} dzień",
|
||||
"few": "{count} dni",
|
||||
"many": "{count} dni",
|
||||
"other": "{count} dnia"
|
||||
},
|
||||
"nl": {
|
||||
"one": "{count} dag",
|
||||
"other": "{count} dagen"
|
||||
},
|
||||
"ru": {
|
||||
"one": "{count} день",
|
||||
"few": "{count} дня",
|
||||
"many": "{count} дней",
|
||||
"other": "{count} дня"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_check_on_open": {
|
||||
"context": "Devices settings: toggle to check remembered devices for waiting transfers when the app opens.",
|
||||
"translations": {
|
||||
"en": "Check for waiting transfers",
|
||||
"fr": "Rechercher les transferts en attente",
|
||||
"es": "Buscar transferencias en espera",
|
||||
"it": "Cerca trasferimenti in attesa",
|
||||
"de": "Nach wartenden Übertragungen suchen",
|
||||
"pt": "Procurar transferências em espera",
|
||||
"pl": "Sprawdzaj oczekujące przesyłki",
|
||||
"nl": "Controleren op wachtende overdrachten",
|
||||
"ru": "Проверять ожидающие передачи"
|
||||
}
|
||||
},
|
||||
"contacts_check_on_open_hint": {
|
||||
"context": "Devices settings: warns that checking reveals to remembered devices when the app is opened.",
|
||||
"translations": {
|
||||
"en": "When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app.",
|
||||
"fr": "À l’ouverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir s’ils ont quelque chose pour vous. Cela leur indique quand vous ouvrez l’application.",
|
||||
"es": "Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación.",
|
||||
"it": "All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app.",
|
||||
"de": "Beim Öffnen von VniDrop werden Ihre gespeicherten Geräte gefragt, ob sie etwas für Sie haben. Dadurch erfahren sie, wann Sie die App geöffnet haben.",
|
||||
"pt": "Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação.",
|
||||
"pl": "Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację.",
|
||||
"nl": "Bij het openen van VniDrop wordt aan je onthouden apparaten gevraagd of ze iets voor je hebben. Zij weten daardoor wanneer je de app opende.",
|
||||
"ru": "При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение."
|
||||
}
|
||||
},
|
||||
"contacts_check_now": {
|
||||
"context": "Devices screen: button that checks remembered devices for waiting transfers right now.",
|
||||
"translations": {
|
||||
"en": "Check now",
|
||||
"fr": "Vérifier maintenant",
|
||||
"es": "Comprobar ahora",
|
||||
"it": "Controlla ora",
|
||||
"de": "Jetzt prüfen",
|
||||
"pt": "Verificar agora",
|
||||
"pl": "Sprawdź teraz",
|
||||
"nl": "Nu controleren",
|
||||
"ru": "Проверить сейчас"
|
||||
}
|
||||
},
|
||||
"contacts_check_none": {
|
||||
"context": "Devices screen: result message when no device had anything waiting.",
|
||||
"translations": {
|
||||
"en": "Nothing waiting",
|
||||
"fr": "Rien en attente",
|
||||
"es": "Nada en espera",
|
||||
"it": "Nulla in attesa",
|
||||
"de": "Nichts wartet",
|
||||
"pt": "Nada em espera",
|
||||
"pl": "Nic nie czeka",
|
||||
"nl": "Niets in de wacht",
|
||||
"ru": "Ничего не ожидает"
|
||||
}
|
||||
},
|
||||
"contacts_offer_held": {
|
||||
"context": "Shown after sending to a device that was not running: the transfer waits for it to open the app.",
|
||||
"translations": {
|
||||
"en": "That device is not open. The transfer will be delivered the next time it opens VniDrop.",
|
||||
"fr": "Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.",
|
||||
"es": "Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.",
|
||||
"it": "Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.",
|
||||
"de": "Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.",
|
||||
"pt": "Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.",
|
||||
"pl": "To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.",
|
||||
"nl": "Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.",
|
||||
"ru": "Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop."
|
||||
}
|
||||
},
|
||||
"contacts_waiting_title": {
|
||||
"context": "Devices screen: section listing transfers waiting for their target device to come online.",
|
||||
"translations": {
|
||||
"en": "Waiting to be delivered",
|
||||
"fr": "En attente de remise",
|
||||
"es": "Pendientes de entrega",
|
||||
"it": "In attesa di consegna",
|
||||
"de": "Wartet auf Zustellung",
|
||||
"pt": "A aguardar entrega",
|
||||
"pl": "Oczekuje na dostarczenie",
|
||||
"nl": "Wacht op bezorging",
|
||||
"ru": "Ожидает доставки"
|
||||
}
|
||||
},
|
||||
"contacts_waiting_hint": {
|
||||
"context": "Devices screen: explains that a waiting transfer is withdrawn by cancelling it.",
|
||||
"translations": {
|
||||
"en": "Cancel the transfer to withdraw it.",
|
||||
"fr": "Annulez le transfert pour le retirer.",
|
||||
"es": "Cancela la transferencia para retirarla.",
|
||||
"it": "Annulla il trasferimento per ritirarlo.",
|
||||
"de": "Brechen Sie die Übertragung ab, um sie zurückzuziehen.",
|
||||
"pt": "Cancele a transferência para a retirar.",
|
||||
"pl": "Anuluj przesyłkę, aby ją wycofać.",
|
||||
"nl": "Annuleer de overdracht om die in te trekken.",
|
||||
"ru": "Отмените передачу, чтобы отозвать её."
|
||||
}
|
||||
},
|
||||
"contacts_send_to_device": {
|
||||
"context": "Transfer share panel: action that sends this transfer straight to a remembered device.",
|
||||
"translations": {
|
||||
"en": "Send to a device",
|
||||
"fr": "Envoyer à un appareil",
|
||||
"es": "Enviar a un dispositivo",
|
||||
"it": "Invia a un dispositivo",
|
||||
"de": "An ein Gerät senden",
|
||||
"pt": "Enviar para um dispositivo",
|
||||
"pl": "Wyślij do urządzenia",
|
||||
"nl": "Naar een apparaat sturen",
|
||||
"ru": "Отправить на устройство"
|
||||
}
|
||||
},
|
||||
"contacts_pick_device_title": {
|
||||
"context": "Device picker sheet: title when choosing which remembered device to send a transfer to.",
|
||||
"translations": {
|
||||
"en": "Choose a device",
|
||||
"fr": "Choisir un appareil",
|
||||
"es": "Elegir un dispositivo",
|
||||
"it": "Scegli un dispositivo",
|
||||
"de": "Gerät auswählen",
|
||||
"pt": "Escolher um dispositivo",
|
||||
"pl": "Wybierz urządzenie",
|
||||
"nl": "Kies een apparaat",
|
||||
"ru": "Выберите устройство"
|
||||
}
|
||||
},
|
||||
"contacts_pick_device_empty": {
|
||||
"context": "Device picker sheet: shown when no remembered device can currently be sent to.",
|
||||
"translations": {
|
||||
"en": "No device can be reached right now. Remembered devices appear here after a transfer.",
|
||||
"fr": "Aucun appareil n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.",
|
||||
"es": "Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.",
|
||||
"it": "Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.",
|
||||
"de": "Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.",
|
||||
"pt": "Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.",
|
||||
"pl": "Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.",
|
||||
"nl": "Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.",
|
||||
"ru": "Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи."
|
||||
}
|
||||
},
|
||||
"contacts_sent_to_device": {
|
||||
"context": "Confirmation after a transfer was accepted by the device it was sent to. {device} = device name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "device",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{device} accepted the transfer",
|
||||
"fr": "{device} a accepté le transfert",
|
||||
"es": "{device} aceptó la transferencia",
|
||||
"it": "{device} ha accettato il trasferimento",
|
||||
"de": "{device} hat die Übertragung angenommen",
|
||||
"pt": "{device} aceitou a transferência",
|
||||
"pl": "{device} zaakceptowało przesyłkę",
|
||||
"nl": "{device} heeft de overdracht geaccepteerd",
|
||||
"ru": "{device} принял передачу"
|
||||
}
|
||||
},
|
||||
"contacts_declined_by_device": {
|
||||
"context": "Shown when the person on the other device declined an offered transfer. {device} = device name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "device",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{device} declined the transfer",
|
||||
"fr": "{device} a refusé le transfert",
|
||||
"es": "{device} rechazó la transferencia",
|
||||
"it": "{device} ha rifiutato il trasferimento",
|
||||
"de": "{device} hat die Übertragung abgelehnt",
|
||||
"pt": "{device} recusou a transferência",
|
||||
"pl": "{device} odrzuciło przesyłkę",
|
||||
"nl": "{device} heeft de overdracht geweigerd",
|
||||
"ru": "{device} отклонил передачу"
|
||||
}
|
||||
},
|
||||
"contacts_no_answer": {
|
||||
"context": "Shown when an offered transfer got no answer on the other device before timing out. {device} = device name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "device",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{device} did not answer",
|
||||
"fr": "{device} n’a pas répondu",
|
||||
"es": "{device} no respondió",
|
||||
"it": "{device} non ha risposto",
|
||||
"de": "{device} hat nicht geantwortet",
|
||||
"pt": "{device} não respondeu",
|
||||
"pl": "{device} nie odpowiedziało",
|
||||
"nl": "{device} heeft niet geantwoord",
|
||||
"ru": "{device} не ответил"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
packaging/apple/studio/assets/globe.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
packaging/apple/studio/assets/mask-rotated.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packaging/apple/studio/assets/mask-straight.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
packaging/apple/studio/assets/mockup-rotated.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
packaging/apple/studio/assets/mockup-straight.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 477 KiB |
BIN
packaging/apple/studio/assets/shots/en/choose-receivers.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.device.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.warped.png
Normal file
|
After Width: | Height: | Size: 781 KiB |
BIN
packaging/apple/studio/assets/shots/en/share-securely.device.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
packaging/apple/studio/assets/shots/en/share-securely.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
packaging/apple/studio/assets/shots/fr/choose-receivers.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
packaging/apple/studio/assets/shots/fr/send-anywhere.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
packaging/apple/studio/assets/shots/fr/send-anywhere.warped.png
Normal file
|
After Width: | Height: | Size: 957 KiB |
BIN
packaging/apple/studio/assets/shots/fr/share-securely.png
Normal file
|
After Width: | Height: | Size: 349 KiB |
BIN
packaging/apple/studio/generated/English/Choose Receivers.png
Normal file
|
After Width: | Height: | Size: 490 KiB |
BIN
packaging/apple/studio/generated/English/Send Anywhere.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
packaging/apple/studio/generated/English/Share Securely.png
Normal file
|
After Width: | Height: | Size: 400 KiB |
BIN
packaging/apple/studio/generated/English/Stay private.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
packaging/apple/studio/generated/French/Choose Receivers.png
Normal file
|
After Width: | Height: | Size: 531 KiB |
BIN
packaging/apple/studio/generated/French/Send Anywhere.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
packaging/apple/studio/generated/French/Share Securely.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
packaging/apple/studio/generated/French/Stay private.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -1,13 +1,13 @@
|
||||
# VniDrop diagnostics API
|
||||
|
||||
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted
|
||||
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs.
|
||||
Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable
|
||||
metadata; R2 stores the larger attached logs.
|
||||
|
||||
The service is designed for modest traffic and low operating cost:
|
||||
|
||||
- one D1 row is written per telemetry batch, not per event;
|
||||
- crash stacks and bug logs are stored in R2 instead of D1;
|
||||
- request and batch limits reject oversized work before storage writes;
|
||||
- one D1 row is written per bug report;
|
||||
- bug logs are stored in R2 instead of D1;
|
||||
- request limits reject oversized work before storage writes;
|
||||
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
|
||||
- no Queue, Durable Object, or KV resources are required.
|
||||
|
||||
@@ -35,15 +35,13 @@ X-VniDrop-Install-Id: <anonymous install UUID>
|
||||
|--------|------|------|
|
||||
| `GET` | `/live` | process liveness; does not touch storage |
|
||||
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
|
||||
| `POST` | `/v1/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` |
|
||||
| `POST` | `/v1/crashes` | app crash payload |
|
||||
| `POST` | `/v1/bugs` | app bug-report payload |
|
||||
|
||||
Batch and report IDs are client-generated UUIDs. A client must reuse the same ID
|
||||
when retrying so D1 can acknowledge the request without storing it twice.
|
||||
Report IDs are client-generated UUIDs. A client must reuse the same ID when
|
||||
retrying so D1 can acknowledge the request without storing it twice.
|
||||
|
||||
Accepted reports return `202`. Defaults are a 262,144-byte request limit and at
|
||||
most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per
|
||||
Accepted reports return `202`. The default is a 262,144-byte request limit.
|
||||
Cloudflare rate-limit bindings allow 30 requests per
|
||||
installation and 120 requests per source, per ingest route, per minute. Source
|
||||
limits run before shared-key verification so rejected traffic is bounded too.
|
||||
These counters are eventually consistent and local to a Cloudflare location, so
|
||||
@@ -153,11 +151,11 @@ migrations to the isolated local database assigned to each test file.
|
||||
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
|
||||
17 minutes past every hour. Cleanup works in bounded batches: it deletes each
|
||||
expired report's referenced R2 object before deleting that exact D1 row. The R2
|
||||
lifecycle rule is an independent backstop for stack and log objects, including
|
||||
objects left behind by a partial ingest failure. Each scheduled run can remove
|
||||
8,000 event batches and 7,200 rows from each report table while staying below
|
||||
D1's per-invocation query ceiling. Later hourly runs continue any backlog.
|
||||
Reaching the cap emits a structured warning with the remaining expired-row counts;
|
||||
lifecycle rule is an independent backstop for log objects, including objects left
|
||||
behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows
|
||||
while staying below D1's per-invocation query ceiling. Later hourly runs continue
|
||||
any backlog.
|
||||
Reaching the cap emits a structured warning with the remaining expired-row count;
|
||||
alert on that warning because
|
||||
retention is necessarily best-effort during sustained distributed abuse.
|
||||
|
||||
@@ -179,23 +177,20 @@ vnidrop.diagnostics.ingestKey=<same value as INGEST_KEY>
|
||||
|
||||
Both the endpoint and key are required. When both are empty the app uses its
|
||||
offline-safe no-op transport; configuring only one fails the Gradle build.
|
||||
`vnidrop.diagnostics.included=false` disables
|
||||
automatic telemetry and crash upload, but a configured endpoint can still accept
|
||||
an explicit user-submitted bug report. Treat the app-side key as an abuse-control
|
||||
token with the limitations described above.
|
||||
`vnidrop.diagnostics.included=false` routes bug reports to that no-op transport
|
||||
(never sent); a configured endpoint accepts an explicit user-submitted bug report.
|
||||
Treat the app-side key as an abuse-control token with the limitations described
|
||||
above.
|
||||
|
||||
## Reading reports
|
||||
|
||||
```bash
|
||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||
--command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20"
|
||||
|
||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
|
||||
```
|
||||
|
||||
R2 object keys use `crashes/<id>/<attempt-id>/stack.txt` and
|
||||
`bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment prevents a retry
|
||||
from overwriting an already accepted object before D1 detects the duplicate.
|
||||
R2 object keys use `bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment
|
||||
prevents a retry from overwriting an already accepted object before D1 detects
|
||||
the duplicate.
|
||||
There is no public administration endpoint; inspect reports through authenticated
|
||||
Cloudflare tools or a future Access-protected dashboard.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Telemetry and crash auto-reporting were removed from the app; only user-initiated
|
||||
-- bug reports remain. Drop the now-unused ingestion tables and their indexes.
|
||||
DROP INDEX IF EXISTS idx_event_batches_received;
|
||||
DROP INDEX IF EXISTS idx_event_batches_install;
|
||||
DROP TABLE IF EXISTS event_batches;
|
||||
|
||||
DROP INDEX IF EXISTS idx_crashes_received;
|
||||
DROP INDEX IF EXISTS idx_crashes_fingerprint;
|
||||
DROP INDEX IF EXISTS idx_crashes_install;
|
||||
DROP TABLE IF EXISTS crashes;
|
||||
@@ -1,20 +1,15 @@
|
||||
import {
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "./input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "./storage";
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||
const DEFAULT_MAX_EVENTS = 50;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||
@@ -72,41 +67,6 @@ export default {
|
||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/v1/events": {
|
||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeEvents(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
stored: result.stored,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/crashes": {
|
||||
const normalized = normalizeCrash(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeCrash(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
fingerprint: result.fingerprint,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/bugs": {
|
||||
const normalized = normalizeBug(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
@@ -159,12 +119,6 @@ async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Respon
|
||||
}
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||
),
|
||||
@@ -216,8 +170,8 @@ async function installRateLimited(
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
||||
function isIngestPath(path: string): path is "/v1/bugs" {
|
||||
return path === "/v1/bugs";
|
||||
}
|
||||
|
||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||
|
||||
@@ -8,21 +8,6 @@ export type InputFailure = {
|
||||
|
||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||
|
||||
export type NormalizedProperties = Record<string, string>;
|
||||
|
||||
export interface NormalizedEvent {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBreadcrumb {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
}
|
||||
|
||||
export interface NormalizedDevice {
|
||||
deviceName: string;
|
||||
deviceModel: string;
|
||||
@@ -31,28 +16,6 @@ export interface NormalizedDevice {
|
||||
batteryLevel: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEventsPayload {
|
||||
batchId: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
events: NormalizedEvent[];
|
||||
}
|
||||
|
||||
export interface NormalizedCrashPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
stackTrace: string;
|
||||
occurredAt: number;
|
||||
diagnosticsEnabledAtCapture: boolean;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBugPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
@@ -65,16 +28,12 @@ export interface NormalizedBugPayload {
|
||||
contact: string;
|
||||
logs: string;
|
||||
device: NormalizedDevice;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export const MAX_LOG_BYTES = 192 * 1024;
|
||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||
|
||||
const MAX_PROPERTIES = 12;
|
||||
const MAX_BREADCRUMBS = 40;
|
||||
const MISSING = Symbol("missing");
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UTF8_ENCODER = new TextEncoder();
|
||||
@@ -164,119 +123,6 @@ export async function readJsonObject(
|
||||
return success(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEvents(
|
||||
body: JsonObject,
|
||||
maxEvents = 50,
|
||||
): InputResult<NormalizedEventsPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
||||
throw new RangeError("maxEvents must be a positive safe integer");
|
||||
}
|
||||
|
||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
||||
if (!batchId.ok) return batchId;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
|
||||
const batchSchema = schemaVersion(body);
|
||||
if (!batchSchema.ok) return batchSchema;
|
||||
const rawEvents = pick(body, ["events"]);
|
||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
||||
|
||||
const events: NormalizedEvent[] = [];
|
||||
for (const rawEvent of rawEvents) {
|
||||
const event = normalizeEvent(rawEvent);
|
||||
if (!event.ok) return event;
|
||||
events.push(event.value);
|
||||
}
|
||||
|
||||
return success({
|
||||
batchId: batchId.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const exceptionType = stringField(
|
||||
body,
|
||||
["exceptionType", "exception_type"],
|
||||
120,
|
||||
"invalid_exception_type",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!exceptionType.ok) return exceptionType;
|
||||
const exceptionMessage = stringField(
|
||||
body,
|
||||
["exceptionMessage", "exception_message"],
|
||||
2_000,
|
||||
"invalid_exception_message",
|
||||
true,
|
||||
);
|
||||
if (!exceptionMessage.ok) return exceptionMessage;
|
||||
const stackTrace = stringField(
|
||||
body,
|
||||
["stackTrace", "stack_trace"],
|
||||
32_000,
|
||||
"invalid_stack_trace",
|
||||
true,
|
||||
);
|
||||
if (!stackTrace.ok) return stackTrace;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const diagnosticsEnabled = booleanField(
|
||||
body,
|
||||
[
|
||||
"diagnosticsEnabledAtCapture",
|
||||
"diagnostics_enabled_at_capture",
|
||||
"diagnostics_enabled",
|
||||
],
|
||||
"invalid_diagnostics_enabled",
|
||||
true,
|
||||
);
|
||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
exceptionType: exceptionType.value,
|
||||
exceptionMessage: exceptionMessage.value,
|
||||
stackTrace: stackTrace.value,
|
||||
occurredAt: occurredAt.value,
|
||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
@@ -319,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
||||
if (!logs.ok) return logs;
|
||||
const device = normalizeDevice(pick(body, ["device"]));
|
||||
if (!device.ok) return device;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
|
||||
@@ -336,80 +180,10 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
||||
contact: contact.value,
|
||||
logs: includeLogs.value === true ? logs.value : "",
|
||||
device: device.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
||||
if (!properties.ok) return properties;
|
||||
const version = schemaVersion(raw);
|
||||
if (!version.ok) return version;
|
||||
return success({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
||||
if (raw === MISSING) return success([]);
|
||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
||||
|
||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
||||
const properties = normalizeProperties(
|
||||
pick(item, ["properties", "props"]),
|
||||
"invalid_breadcrumbs",
|
||||
);
|
||||
if (!properties.ok) return properties;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
});
|
||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
||||
breadcrumbs.pop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return success(breadcrumbs);
|
||||
}
|
||||
|
||||
function normalizeProperties(
|
||||
raw: unknown | typeof MISSING,
|
||||
error: string,
|
||||
): InputResult<NormalizedProperties> {
|
||||
if (raw === MISSING) return success({});
|
||||
if (!isPlainObject(raw)) return failure(400, error);
|
||||
|
||||
const entries: Array<[string, string]> = [];
|
||||
const normalizedKeys = new Set<string>();
|
||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
||||
if (typeof value !== "string") return failure(400, error);
|
||||
const normalizedKey = truncateUtf8(key, 40);
|
||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
||||
return failure(400, error);
|
||||
}
|
||||
normalizedKeys.add(normalizedKey);
|
||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
||||
}
|
||||
return success(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||
if (raw === MISSING) raw = {};
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "./input";
|
||||
import type { NormalizedBugPayload } from "./input";
|
||||
|
||||
export type DiagnosticsEnv = Cloudflare.Env & {
|
||||
INGEST_KEY?: string;
|
||||
AE?: AnalyticsEngineDataset;
|
||||
};
|
||||
|
||||
export interface StoreResult {
|
||||
@@ -15,130 +10,6 @@ export interface StoreResult {
|
||||
stored: number;
|
||||
}
|
||||
|
||||
export async function storeEvents(
|
||||
payload: NormalizedEventsPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const result = await env.DB.prepare(
|
||||
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.batchId,
|
||||
Date.now(),
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.events.length,
|
||||
JSON.stringify(payload.events),
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (!duplicate && env.AE) {
|
||||
try {
|
||||
for (const event of payload.events) {
|
||||
env.AE.writeDataPoint({
|
||||
blobs: [
|
||||
event.name,
|
||||
payload.platform,
|
||||
payload.appVersion,
|
||||
payload.installId,
|
||||
JSON.stringify(event.properties),
|
||||
payload.batchId,
|
||||
],
|
||||
doubles: [event.timestampMillis, event.schemaVersion],
|
||||
indexes: [payload.installId],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// D1 remains the durable source of truth if the optional analytics index is unavailable.
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to index diagnostics event batch",
|
||||
batchId: payload.batchId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: payload.batchId,
|
||||
duplicate,
|
||||
stored: duplicate ? 0 : payload.events.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function storeCrash(
|
||||
payload: NormalizedCrashPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult & { fingerprint: string }> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (existing) {
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
|
||||
}
|
||||
|
||||
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
|
||||
const stackKey = payload.stackTrace
|
||||
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
|
||||
: null;
|
||||
|
||||
if (stackKey) {
|
||||
await env.BLOBS.put(stackKey, payload.stackTrace, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId, fingerprint },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO crashes (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.exceptionType,
|
||||
payload.exceptionMessage,
|
||||
fingerprint,
|
||||
payload.diagnosticsEnabledAtCapture ? 1 : 0,
|
||||
stackKey,
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate) {
|
||||
const stored = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (!stored) throw new Error("duplicate crash row was not readable");
|
||||
if (stackKey) await deleteAttemptBlob(env, stackKey);
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
|
||||
}
|
||||
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
|
||||
} catch (error) {
|
||||
if (stackKey) {
|
||||
await deleteAttemptBlob(env, stackKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeBug(
|
||||
payload: NormalizedBugPayload,
|
||||
env: DiagnosticsEnv,
|
||||
@@ -161,12 +32,12 @@ export async function storeBug(
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO bugs (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
`INSERT INTO bugs (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, status, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
@@ -181,7 +52,6 @@ export async function storeBug(
|
||||
payload.contact,
|
||||
logsKey,
|
||||
JSON.stringify(payload.device),
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
@@ -201,26 +71,22 @@ export async function storeBug(
|
||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation.
|
||||
// Eight passes plus the backlog check stay well within D1's 50 queries per invocation.
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const hasFullBatch = await runRetentionPass(env, cutoff);
|
||||
if (!hasFullBatch) return;
|
||||
}
|
||||
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
|
||||
cutoff,
|
||||
),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
|
||||
]);
|
||||
const bugs = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.first<{ count: number }>();
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention reached its per-run pass limit",
|
||||
cutoff,
|
||||
backlog: {
|
||||
eventBatches: events.results[0]?.count ?? 0,
|
||||
crashes: crashes.results[0]?.count ?? 0,
|
||||
bugs: bugs.results[0]?.count ?? 0,
|
||||
bugs: bugs?.count ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -228,28 +94,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
|
||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||
const reportBatchSize = 900;
|
||||
const eventBatchSize = 1_000;
|
||||
const [crashes, bugs] = await Promise.all([
|
||||
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
|
||||
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
|
||||
]);
|
||||
const bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize);
|
||||
|
||||
const blobKeys = [...crashes, ...bugs]
|
||||
const blobKeys = bugs
|
||||
.map((row) => row.blobKey)
|
||||
.filter((key): key is string => key !== null);
|
||||
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
||||
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
||||
}
|
||||
|
||||
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
|
||||
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
|
||||
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
|
||||
const [eventsResult] = await env.DB.batch(statements);
|
||||
return (
|
||||
eventsResult.meta.changes === eventBatchSize ||
|
||||
crashes.length === reportBatchSize ||
|
||||
bugs.length === reportBatchSize
|
||||
);
|
||||
if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run();
|
||||
return bugs.length === reportBatchSize;
|
||||
}
|
||||
|
||||
interface ExpiredBlobRow {
|
||||
@@ -259,8 +114,8 @@ interface ExpiredBlobRow {
|
||||
|
||||
async function expiredBlobRows(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
column: "stack_r2_key" | "logs_r2_key",
|
||||
table: "bugs",
|
||||
column: "logs_r2_key",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): Promise<ExpiredBlobRow[]> {
|
||||
@@ -277,25 +132,9 @@ async function expiredBlobRows(
|
||||
return result.results;
|
||||
}
|
||||
|
||||
function retentionStatement(
|
||||
database: D1Database,
|
||||
table: "event_batches" | "crashes" | "bugs",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(
|
||||
`DELETE FROM ${table}
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
|
||||
)`,
|
||||
)
|
||||
.bind(cutoff, batchSize);
|
||||
}
|
||||
|
||||
function deleteRowsById(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
table: "bugs",
|
||||
rows: ExpiredBlobRow[],
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
@@ -303,18 +142,6 @@ function deleteRowsById(
|
||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||
}
|
||||
|
||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
||||
const topFrames = stackTrace
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join("\n");
|
||||
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
||||
try {
|
||||
await env.BLOBS.delete(key);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
MAX_DEVICE_JSON_BYTES,
|
||||
MAX_LOG_BYTES,
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "../src/input";
|
||||
|
||||
@@ -57,7 +54,7 @@ describe("readJsonObject", () => {
|
||||
});
|
||||
|
||||
it("requires application/json with a UTF-8 charset", async () => {
|
||||
const missing = new Request("https://example.test/v1/events", {
|
||||
const missing = new Request("https://example.test/v1/bugs", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
@@ -108,18 +105,6 @@ describe("readJsonObject", () => {
|
||||
|
||||
describe("normalizers", () => {
|
||||
it("preserves false booleans and rejects their string representation", () => {
|
||||
const crash = crashPayload(false);
|
||||
const normalizedCrash = normalizeCrash(crash);
|
||||
expect(normalizedCrash.ok).toBe(true);
|
||||
if (normalizedCrash.ok) {
|
||||
expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false);
|
||||
}
|
||||
expect(normalizeCrash(crashPayload("false"))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_diagnostics_enabled",
|
||||
});
|
||||
|
||||
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
||||
const normalizedBug = normalizeBug(bug);
|
||||
expect(normalizedBug.ok).toBe(true);
|
||||
@@ -133,20 +118,11 @@ describe("normalizers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => {
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]),
|
||||
);
|
||||
const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({
|
||||
name: `crumb-${index}`,
|
||||
timestamp_millis: index,
|
||||
properties,
|
||||
}));
|
||||
it("keeps logs and device JSON within valid byte budgets", () => {
|
||||
const result = normalizeBug(
|
||||
bugPayload({
|
||||
include_logs: true,
|
||||
logs: "😀".repeat(60_000),
|
||||
breadcrumbs,
|
||||
device: {
|
||||
device_name: "\u0000".repeat(200),
|
||||
device_model: "\u0000".repeat(200),
|
||||
@@ -159,54 +135,38 @@ describe("normalizers", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
|
||||
const deviceJson = JSON.stringify(result.value.device);
|
||||
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
||||
expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual(
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
);
|
||||
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
||||
expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
|
||||
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
||||
});
|
||||
|
||||
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
||||
const result = normalizeEvents({
|
||||
events: [{ name: "opened", ts: 1, schema_version: 1 }],
|
||||
});
|
||||
expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
||||
const legacyInstall = normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: "legacy-test-install",
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
});
|
||||
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
||||
const missingInstall = normalizeEvents({
|
||||
batch_id: ID,
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
});
|
||||
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: "bad\u0000install",
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "invalid_install_id" });
|
||||
const missingId = normalizeBug(bugPayload({ id: undefined }));
|
||||
expect(missingId).toEqual({ ok: false, status: 400, error: "invalid_id" });
|
||||
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: "not-a-uuid",
|
||||
events: [{ name: "opened", timestamp_millis: 1 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" });
|
||||
const legacyInstall = normalizeBug(bugPayload({ install_id: "legacy-test-install" }));
|
||||
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
||||
|
||||
const missingInstall = normalizeBug(bugPayload({ install_id: undefined }));
|
||||
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
||||
|
||||
expect(normalizeBug(bugPayload({ install_id: "bad\u0000install" }))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_install_id",
|
||||
});
|
||||
|
||||
expect(normalizeBug(bugPayload({ id: "not-a-uuid" }))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_id",
|
||||
});
|
||||
expect(normalizeBug(bugPayload({ schema_version: 2 }))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "unsupported_schema_version",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,7 +175,7 @@ function chunkedJsonRequest(
|
||||
contentType = "application/json; charset=utf-8",
|
||||
contentLength?: string,
|
||||
): Request {
|
||||
return new Request("https://example.test/v1/events", {
|
||||
return new Request("https://example.test/v1/bugs", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
@@ -230,24 +190,8 @@ function chunkedJsonRequest(
|
||||
});
|
||||
}
|
||||
|
||||
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
|
||||
return {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
platform: "test",
|
||||
exception_type: "ExampleError",
|
||||
exception_message: "message",
|
||||
stack_trace: "stack",
|
||||
occurred_at: 1,
|
||||
diagnostics_enabled: diagnosticsEnabled,
|
||||
schema_version: 1,
|
||||
breadcrumbs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
@@ -259,8 +203,12 @@ function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unk
|
||||
contact: "",
|
||||
logs: "",
|
||||
device: {},
|
||||
breadcrumbs: [],
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
};
|
||||
// An explicit `undefined` override omits the key entirely (simulating a missing field).
|
||||
for (const key of Object.keys(overrides)) {
|
||||
if (overrides[key] === undefined) delete payload[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,8 @@ import { env, exports } from "cloudflare:workers";
|
||||
import { createExecutionContext } from "cloudflare:test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "../src/input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "../src/storage";
|
||||
import type { NormalizedBugPayload } from "../src/input";
|
||||
import { type DiagnosticsEnv, runRetention, storeBug } from "../src/storage";
|
||||
|
||||
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
||||
|
||||
@@ -35,7 +25,7 @@ describe("diagnostics Worker", () => {
|
||||
expect(unknown.status).toBe(404);
|
||||
|
||||
const unauthorized = await exports.default.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"),
|
||||
jsonRequest("/v1/bugs", bugPayload(uuid(1), "logs"), "wrong-key"),
|
||||
);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
||||
@@ -44,7 +34,7 @@ describe("diagnostics Worker", () => {
|
||||
);
|
||||
|
||||
const preflight = await exports.default.fetch(
|
||||
new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }),
|
||||
new Request("https://diagnostics.test/v1/bugs", { method: "OPTIONS" }),
|
||||
);
|
||||
expect(preflight.status).toBe(204);
|
||||
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
||||
@@ -68,7 +58,7 @@ describe("diagnostics Worker", () => {
|
||||
const context = createExecutionContext();
|
||||
|
||||
const response = await worker.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"),
|
||||
jsonRequest("/v1/bugs", bugPayload(uuid(3), "logs"), "wrong-key", "198.51.100.3"),
|
||||
limitedEnv,
|
||||
context,
|
||||
);
|
||||
@@ -80,7 +70,7 @@ describe("diagnostics Worker", () => {
|
||||
});
|
||||
|
||||
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
||||
const invalid = await exports.default.fetch(jsonRequest("/v1/events", null));
|
||||
const invalid = await exports.default.fetch(jsonRequest("/v1/bugs", null));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
||||
|
||||
@@ -92,6 +82,8 @@ describe("diagnostics Worker", () => {
|
||||
};
|
||||
const rejectingDatabase = {
|
||||
prepare: () => statement,
|
||||
batch: async () => Promise.reject(rejection),
|
||||
withSession: () => ({ prepare: () => statement }),
|
||||
} as unknown as D1Database;
|
||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||
|
||||
@@ -106,7 +98,7 @@ describe("diagnostics Worker", () => {
|
||||
|
||||
const ingestContext = createExecutionContext();
|
||||
const failedIngest = await worker.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"),
|
||||
jsonRequest("/v1/bugs", bugPayload(uuid(2), "logs"), env.INGEST_KEY, "198.51.100.2"),
|
||||
rejectingEnv,
|
||||
ingestContext,
|
||||
);
|
||||
@@ -114,101 +106,6 @@ describe("diagnostics Worker", () => {
|
||||
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
||||
});
|
||||
|
||||
it("deduplicates event batches using the client batch ID", async () => {
|
||||
const id = uuid(10);
|
||||
const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
||||
const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
expect(await first.json()).toMatchObject({
|
||||
ok: true,
|
||||
id,
|
||||
stored: 1,
|
||||
duplicate: false,
|
||||
});
|
||||
expect(second.status).toBe(202);
|
||||
expect(await second.json()).toMatchObject({
|
||||
ok: true,
|
||||
id,
|
||||
stored: 0,
|
||||
duplicate: true,
|
||||
});
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ eventCount: number; payloadJson: string }>();
|
||||
expect(row?.eventCount).toBe(1);
|
||||
expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([
|
||||
{
|
||||
name: "app_open",
|
||||
timestampMillis: 1,
|
||||
properties: { screen: "home" },
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps D1 idempotency when the optional analytics index is enabled", async () => {
|
||||
const points: AnalyticsEngineDataPoint[] = [];
|
||||
const analytics = {
|
||||
writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point),
|
||||
} as AnalyticsEngineDataset;
|
||||
const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics };
|
||||
const payload: NormalizedEventsPayload = {
|
||||
batchId: uuid(11),
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
events: [
|
||||
{
|
||||
name: "indexed",
|
||||
timestampMillis: 1,
|
||||
properties: {},
|
||||
schemaVersion: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 });
|
||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 });
|
||||
expect(points).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the accepted crash blob when a duplicate request arrives", async () => {
|
||||
const id = uuid(20);
|
||||
const first = await exports.default.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(id, "first stack")),
|
||||
);
|
||||
const second = await exports.default.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(id, "second stack")),
|
||||
);
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
const firstBody = await first.json<{ fingerprint: string }>();
|
||||
expect(firstBody).toMatchObject({ ok: true, id, duplicate: false });
|
||||
expect(second.status).toBe(202);
|
||||
const secondBody = await second.json<{ fingerprint: string }>();
|
||||
expect(secondBody).toMatchObject({ ok: true, id, duplicate: true });
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson,
|
||||
fingerprint
|
||||
FROM crashes WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>();
|
||||
expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`));
|
||||
expect(firstBody.fingerprint).toBe(row?.fingerprint);
|
||||
expect(secondBody.fingerprint).toBe(row?.fingerprint);
|
||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]);
|
||||
expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack");
|
||||
|
||||
const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` });
|
||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]);
|
||||
});
|
||||
|
||||
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
||||
const id = uuid(30);
|
||||
const payload = bugPayload(id, "first logs");
|
||||
@@ -224,7 +121,7 @@ describe("diagnostics Worker", () => {
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
||||
device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson
|
||||
device_json AS deviceJson
|
||||
FROM bugs WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -232,7 +129,6 @@ describe("diagnostics Worker", () => {
|
||||
occurredAt: number;
|
||||
logsKey: string;
|
||||
deviceJson: string;
|
||||
breadcrumbsJson: string;
|
||||
}>();
|
||||
expect(row?.occurredAt).toBe(3);
|
||||
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
||||
@@ -242,9 +138,6 @@ describe("diagnostics Worker", () => {
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
});
|
||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([
|
||||
{ name: "opened", timestampMillis: 2, properties: {} },
|
||||
]);
|
||||
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
||||
|
||||
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
||||
@@ -252,9 +145,7 @@ describe("diagnostics Worker", () => {
|
||||
});
|
||||
|
||||
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
||||
const crash = normalizedCrash(uuid(31), "accepted stack");
|
||||
const bug = normalizedBug(uuid(32), "accepted logs");
|
||||
const firstCrash = await storeCrash(crash, env);
|
||||
await storeBug(bug, env);
|
||||
let blobWrites = 0;
|
||||
const unavailableBlobs = {
|
||||
@@ -265,14 +156,6 @@ describe("diagnostics Worker", () => {
|
||||
} as unknown as R2Bucket;
|
||||
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
||||
|
||||
await expect(
|
||||
storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv),
|
||||
).resolves.toEqual({
|
||||
id: crash.id,
|
||||
duplicate: true,
|
||||
stored: 0,
|
||||
fingerprint: firstCrash.fingerprint,
|
||||
});
|
||||
await expect(
|
||||
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
||||
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
||||
@@ -286,88 +169,56 @@ describe("diagnostics Worker", () => {
|
||||
async () => Promise.reject(rejection),
|
||||
);
|
||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||
const crashId = uuid(33);
|
||||
const bugId = uuid(34);
|
||||
|
||||
const crashResponse = await worker.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"),
|
||||
rejectingEnv,
|
||||
createExecutionContext(),
|
||||
);
|
||||
const bugResponse = await worker.fetch(
|
||||
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
||||
rejectingEnv,
|
||||
createExecutionContext(),
|
||||
);
|
||||
|
||||
expect(crashResponse.status).toBe(500);
|
||||
expect(await crashResponse.json()).toEqual({ error: "internal" });
|
||||
expect(bugResponse.status).toBe(500);
|
||||
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
||||
expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]);
|
||||
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
||||
const oldEventId = uuid(40);
|
||||
const oldCrashId = uuid(41);
|
||||
const oldBugId = uuid(42);
|
||||
const currentEventId = uuid(43);
|
||||
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
|
||||
const currentBugId = uuid(43);
|
||||
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||
|
||||
await Promise.all([
|
||||
env.BLOBS.put(oldCrashKey, "expired crash"),
|
||||
env.BLOBS.put(oldBugKey, "expired logs"),
|
||||
]);
|
||||
await env.BLOBS.put(oldBugKey, "expired logs");
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
`INSERT INTO event_batches
|
||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
||||
).bind(oldEventId, oldReceivedAt, INSTALL_ID),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO event_batches
|
||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
||||
).bind(currentEventId, Date.now(), INSTALL_ID),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO crashes
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`,
|
||||
).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO bugs
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`,
|
||||
device_json, status, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', 'open', 1)`,
|
||||
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO bugs
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, status, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', 'open', 1)`,
|
||||
).bind(currentBugId, Date.now(), Date.now(), INSTALL_ID),
|
||||
]);
|
||||
|
||||
await runRetention(env);
|
||||
|
||||
for (const [table, id] of [
|
||||
["event_batches", oldEventId],
|
||||
["crashes", oldCrashId],
|
||||
["bugs", oldBugId],
|
||||
] as const) {
|
||||
const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first();
|
||||
expect(row).toBeNull();
|
||||
}
|
||||
expect(await env.BLOBS.head(oldCrashKey)).toBeNull();
|
||||
expect(
|
||||
await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(oldBugId).first(),
|
||||
).toBeNull();
|
||||
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
||||
expect(
|
||||
await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(),
|
||||
await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(currentBugId).first(),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
||||
let queryCount = 0;
|
||||
let batchCalls = 0;
|
||||
const blobDeleteBatchSizes: number[] = [];
|
||||
const rows = Array.from({ length: 900 }, (_, index) => ({
|
||||
id: `expired-${index}`,
|
||||
@@ -381,17 +232,17 @@ describe("diagnostics Worker", () => {
|
||||
queryCount += 1;
|
||||
return d1Result(rows, 0);
|
||||
},
|
||||
run: async () => {
|
||||
queryCount += 1;
|
||||
return d1Result([], rows.length);
|
||||
},
|
||||
first: async () => {
|
||||
queryCount += 1;
|
||||
return { count: rows.length };
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
batch: async (statements: D1PreparedStatement[]) => {
|
||||
batchCalls += 1;
|
||||
queryCount += statements.length;
|
||||
if (batchCalls === 9) {
|
||||
return statements.map(() => d1Result([{ count: 1 }], 0));
|
||||
}
|
||||
return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900));
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const blobs = {
|
||||
@@ -406,9 +257,10 @@ describe("diagnostics Worker", () => {
|
||||
warning.mockRestore();
|
||||
}
|
||||
|
||||
expect(queryCount).toBe(43);
|
||||
expect(blobDeleteBatchSizes).toHaveLength(16);
|
||||
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000);
|
||||
// Eight passes (one SELECT + one DELETE each) plus the final backlog SELECT.
|
||||
expect(queryCount).toBe(17);
|
||||
expect(blobDeleteBatchSizes).toHaveLength(8);
|
||||
expect(Math.max(...blobDeleteBatchSizes)).toBe(900);
|
||||
});
|
||||
|
||||
it("converges an expired report backlog across bounded retention runs", async () => {
|
||||
@@ -424,13 +276,13 @@ describe("diagnostics Worker", () => {
|
||||
CROSS JOIN digits AS ones
|
||||
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
||||
)
|
||||
INSERT INTO crashes (
|
||||
INSERT INTO bugs (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, status, schema_version
|
||||
)
|
||||
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
||||
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1
|
||||
'failed', 'worked', '', '', NULL, '{}', 'open', 1
|
||||
FROM sequence`,
|
||||
)
|
||||
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
||||
@@ -440,13 +292,13 @@ describe("diagnostics Worker", () => {
|
||||
try {
|
||||
await runRetention(env);
|
||||
const afterFirstRun = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
||||
"SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
|
||||
).first<{ count: number }>();
|
||||
expect(afterFirstRun?.count).toBe(1);
|
||||
|
||||
await runRetention(env);
|
||||
const afterSecondRun = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
||||
"SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
|
||||
).first<{ count: number }>();
|
||||
expect(afterSecondRun?.count).toBe(0);
|
||||
} finally {
|
||||
@@ -482,39 +334,6 @@ function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function eventPayload(batchId: string): Record<string, unknown> {
|
||||
return {
|
||||
batchId,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
events: [
|
||||
{
|
||||
name: "app_open",
|
||||
timestampMillis: 1,
|
||||
properties: { screen: "home" },
|
||||
schemaVersion: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function crashPayload(id: string, stackTrace: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
exceptionType: "TestError",
|
||||
exceptionMessage: "failed",
|
||||
stackTrace,
|
||||
timestampMillis: 2,
|
||||
diagnosticsEnabledAtCapture: true,
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
@@ -535,23 +354,6 @@ function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
exceptionType: "TestError",
|
||||
exceptionMessage: "failed",
|
||||
stackTrace,
|
||||
occurredAt: 2,
|
||||
diagnosticsEnabledAtCapture: true,
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
@@ -575,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload {
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable */
|
||||
// Generated by Wrangler by running `wrangler types` (hash: e2953336d40e96c4b5125a5de01f7cac)
|
||||
// Generated by Wrangler by running `wrangler types` (hash: 9c27cfd9219ba0d4efc153db78cad4c3)
|
||||
// Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat
|
||||
interface __BaseEnv_Env {
|
||||
BLOBS: R2Bucket;
|
||||
@@ -7,7 +7,6 @@ interface __BaseEnv_Env {
|
||||
INSTALL_RATE_LIMITER: RateLimit;
|
||||
SOURCE_RATE_LIMITER: RateLimit;
|
||||
MAX_BODY_BYTES: "262144";
|
||||
MAX_EVENTS_PER_BATCH: "50";
|
||||
RETENTION_DAYS: "90";
|
||||
}
|
||||
declare namespace Cloudflare {
|
||||
@@ -21,7 +20,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||
};
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "MAX_EVENTS_PER_BATCH" | "RETENTION_DAYS">> {}
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "RETENTION_DAYS">> {}
|
||||
}
|
||||
|
||||
// Begin runtime types
|
||||
|
||||