31 Commits

Author SHA1 Message Date
c852a68f28 fix(apple): correct the device id row on the detail screen
The fingerprint row reused the no-name placeholder as its label, so it read
"A nearby device — aaab1c58", and it duplicated the device id shown
directly beneath it. Now one selectable full id, which is what someone
comparing devices actually needs.
2026-08-07 10:59:00 +02:00
225ff9ad22 fix: stop the device picker hanging on an offer
Two causes. The connect step had no timeout, so an unreachable device was
retried indefinitely instead of falling through to hold-for-later; it now
gives up after 15s and holds the offer as designed.

The picker also waited on the whole exchange, which includes a person on the
other device deciding — up to two minutes. It now closes on tap and reports
the outcome as a message, and a decline or an unanswered offer is shown as
information rather than an error, since the offer did arrive.
2026-08-07 10:49:32 +02:00
677fc3c6d5 fix(apple): make the device detail screen reachable
The Settings stack has a typed path of [SettingsSection], so a
NavigationLink carrying a String could never push onto it: tapping a device
in the list did nothing. Contact detail is now a SettingsSection case, and
the path maps it to the two-level push the way the bug report screen
already does.
2026-08-07 10:33:10 +02:00
3441280599 feat(apple): send a transfer to a device from the share panel
Send to a device now sits alongside the QR code, NFC, and export actions,
since an offer is another way to deliver the same invitation. Picking a
device pushes the existing transfer rather than re-sharing the files.

The picker lists only devices holding a live grant, so nothing offered there
can fail on tap, and it distinguishes accepted from waiting for that device
to open the app.

Also fixes the deprecated SF Symbol and the two Sendable warnings introduced
with the contacts screen: the sections now talk to the model directly rather
than storing view callbacks that a Binding setter has to convert.
2026-08-07 10:24:05 +02:00
d8587851a3 feat(core): offer an existing share to a remembered device
Another way to deliver an invitation the user already created, alongside the
QR code, rather than a second share of the same files: the ticket handed over
is the stored one and the transfer id is unchanged.

Only an active share can be offered. A stopped one no longer serves its
content, so handing out its ticket would promise nothing.
2026-08-07 10:14:53 +02:00
cba51ad504 docs(design): mark device history as implemented 2026-08-06 19:00:24 +02:00
0f70663263 feat(apple): collect transfers held for this device
Adds the opt-in foreground check and an explicit Check now, the waiting-to-
be-delivered list on the sender side, and honest reporting when a send could
not be delivered: a closed app is a delay, not a success nobody received.

The setting is off by default and its footer states that checking reveals
app-open times to remembered devices, since that is the reason it is a
setting at all.

Records in the design doc that this shipped as one global toggle rather than
the per-contact opt-in originally specified.
2026-08-06 19:00:04 +02:00
94a8ba2103 feat(core): hold undeliverable offers and collect them on demand
An unreachable device is a delay, not a failure: the share stays here and
the ticket waits in held_offers (schema 8 -> 9) until that device comes and
collects it. No server and no push, per the design.

Polling needs no grant proof. iroh has already authenticated the remote
endpoint key, and a device is only handed offers addressed to precisely that
endpoint, so a stranger polling learns nothing. Offers are consumed on
delivery, so polling twice does not re-deliver, and cancelling the transfer
withdraws the waiting ticket.

Polling is rate limited per device: it tells every contact the app was
opened, so it must never become a presence beacon.
2026-08-06 18:47:29 +02:00
268fbf161d feat(apple): send files to a remembered device
Adds the Send files action to the device detail, routing the picked
selection through sendToContact.

Rather than a second picker path, sharePickedFiles now takes a
ShareDestination, so the macOS security-scoped access handling covers both
routes. A contact destination carries no access policy, matching the core's
rule that an offer share is never public.
2026-08-06 18:21:44 +02:00
dc23e87c56 feat(apple): offer to remember a device after a transfer
Closes the loop: until now nothing in the UI could create a contact, so the
list stayed empty unless the peer initiated.

A completed receive names its sender and a completed delivery names its
receiver, so both sides get the suggestion. Declining is persisted, or every
later transfer with the same device would re-ask a question already
answered; pairing deliberately afterwards clears that.

The suggestion sheet ranks below the two other prompts, since nobody is
waiting on the answer.
2026-08-06 18:06:19 +02:00
1369df4578 feat(apple): contacts screen and consent prompts
Device list, detail, and block list under Settings, plus the two sheets:
an incoming offer and a device asking to be remembered. Both are
answer-only, since swiping away would leave the sender waiting.

Accepting an offer routes the released ticket into the ordinary receive
path, so the platform still chooses the destination. The prompt does not
ask a second time; it only falls back to the review sheet when the
destination is unusable.
2026-08-06 17:57:54 +02:00
256ff89423 feat(apple): add the contacts feature model
MVVM model for the device list, its detail, and the two consent prompts.
Accepting an offer is the only path that returns a ticket, matching the
core: a declined offer hands over nothing.

Grant lifetime lives in preferences because the core holds it in memory
only, so it is pushed back on every start rather than silently reverting to
the default.
2026-08-06 17:48:18 +02:00
3c89c34c6e feat(apple): expose device history through the core gateway
Adds contact, pairing, and offer models plus the gateway surface the feature
models will use. Contacts and offers are endpoint-scoped events with no
transfer id, so they get their own coalesced signals.

DeviceContact.displayName prefers the local label over the name the peer
claims, and carries a short endpoint fingerprint for telling apart devices
using the same name.
2026-08-06 17:40:38 +02:00
178def0629 i18n: add device history strings
Contacts list and detail, pairing consent prompts, incoming offer prompt,
and the grant lifetime setting, in all nine supported languages. Added to
strings.json and regenerated; targeted at both platforms so the Compose
resources exist when the KMP side is built.
2026-08-06 17:29:43 +02:00
5f5f7e0515 chore(apple): drop the unused SwiftPM manifest
Package.swift named its module VniDropApp while all 16 test files import
VniDrop, and it never declared the SFSafeSymbols dependency that project.yml
links, so neither swift build nor swift test worked. The Xcode project is
already the only functioning build definition for the UI and the tests.

Removes a second dependency list that had drifted, and corrects the README,
which documented the CLI path as if it worked.
2026-08-06 17:21:51 +02:00
e041fbeda2 feat(core): send transfers straight to a paired device
Adds SubmitOffer to the contacts ALPN: the sender creates an ordinary share
and pushes the ticket over an authenticated connection, replacing the QR
code without changing the transfer itself.

Only the receiving user is prompted. The sender pre-authorises the target
endpoint before offering, and the approval service now honours an existing
access session, so the handshake the receiver runs next does not ask the
sender to approve a transfer they initiated. An unsolicited ticket receive
still prompts as before.

The ticket leaves the core only when the user accepts; declining yields
nothing. Offer-created shares are never public, one prompt per device is
pending at a time, a decline starts a cooldown, and forgetting a device
clears any prompt it left on screen.
2026-08-06 16:56:52 +02:00
7aa99304b2 feat(core): add the contacts protocol and grant exchange
New /vnidrop/offer/1 ALPN carrying grant delivery and revocation, with a
per-connection challenge so a captured proof cannot be replayed onto another
connection. Unlike the transfer handshake, this serves nobody without a
grant, so an unpaired device cannot raise a prompt on the far side.

A delivered grant is never stored on arrival: it waits for the local user's
consent, so an unsolicited grant cannot create a contact. Forgetting a
contact revokes locally first and notifies the peer best effort. A blocked
endpoint is refused indistinguishably from any other refusal.

Adds the UniFFI surface for listing, pairing, forgetting, blocking, labels,
and grant lifetime.
2026-08-06 16:35:36 +02:00
9fbcf653e8 feat(core): persist contacts, grants, and the block list
Schema 7 -> 8 adds contacts, grants_issued, grants_held, and
blocked_endpoints. Kept in their own module so repository.rs does not grow
further; the tables migrate with the rest of the schema through the shared
pool.

Revocation tombstones rather than deletes, so a returning peer is answered
Revoked instead of Unknown and can drop its dead entry. Blocking revokes any
outstanding grant, and unblocking does not hand access back.
2026-08-06 16:12:31 +02:00
4cfee786fc feat(core): add grant primitives for device history
Grants are the capability a device issues so a known peer may reach it. The
issuer is the only party that can validate one, which is what makes consent
and revocation enforceable without the peer's cooperation.

Pure module: proof construction and constant-time verification bound to the
challenge and both endpoint ids, idle expiry renewed on use, and secrets
redacted in Debug output.
2026-08-06 16:01:20 +02:00
0388422318 docs(design): specify delivery when the recipient is not running
Sender-held offers with a bounded foreground pull instead of push
infrastructure. Records that APNs is out of scope and that mobile-to-mobile
with both apps closed is unsupported.
2026-08-06 15:51:00 +02:00
7afc7d0892 docs(design): device history and direct offers
Design for remembering devices after a transfer and sending to them without
a new invitation. Grant-based contacts so consent and revocation are
enforceable by the party being remembered. Local network discovery
considered and deferred (Appendix A).
2026-08-06 13:35:02 +02:00
Hammed Abass
e8c3eadfc8 Merge pull request #41 from sudosylabs/feat/shared-app-config
Shared app config + remove telemetry (keep bug reports)
2026-08-02 19:50:49 +02:00
877083a3ed refactor(diagnostics): remove bug report breadcrumbs 2026-08-02 19:18:18 +02:00
7cb2270d56 ci(apple): add Xcode Cloud post-clone script
Xcode Cloud only checks out the repo, so ci_post_clone.sh installs swiftlint,
xcodegen and bun, downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift)
from the matching GitHub Release asset, and generates the project via localization,
version/app config codegen and xcodegen. Rust is never built on Xcode Cloud.
2026-08-02 10:29:46 +02:00
eb8498168d fix(shared): avoid java accessor shadowing when reading app.properties
In a Gradle Kotlin DSL script `java` resolves to the Java plugin extension
accessor, so `java.util.Properties` failed script compilation with
"Unresolved reference 'util'", breaking the shared/Linux/Windows KMP jobs.
Import java.util.Properties and use it unqualified, matching the root build.
2026-08-02 10:29:45 +02:00
ac6e837560 docs: describe bug reports instead of telemetry
Update the site privacy policy (no telemetry/analytics, bug-report only, v1.2)
and the README/apple README to reflect that only user-submitted bug reports
remain.
2026-08-02 10:03:29 +02:00
3e18378610 refactor(diagnostics-api): drop telemetry and crash ingestion, keep bug reports
Remove the /v1/events and /v1/crashes routes, their normalizers and storage
paths, and simplify retention to the bugs table. Add a migration dropping the
now-unused event_batches and crashes tables, and regenerate worker types.
2026-08-02 10:03:12 +02:00
b68d338097 refactor(apple): remove diagnostics opt-in toggle, keep bug reports
Drop the Share-diagnostics preference, its Settings toggle and the
DiagnosticsBuildConfig stub. Bug reporting (NoopBugReportService) and the
diagnostics install id used for bug-report correlation are retained.
2026-08-02 10:02:49 +02:00
b8a002a2ad refactor(shared): remove telemetry and crash reporting, keep bug reports
Delete the TelemetryRecorder, CrashReporter, PendingCrashStore and platform
crash hooks along with their models, JSON encoders and the diagnostics opt-in
preference. The DiagnosticsTransport interface is narrowed to sendBugReport, and
DiagnosticsCoordinator now only wires the bug-report service and install id.

Bug reporting, the breadcrumb buffer, log redaction and the diagnostics endpoint
config are kept. Regenerate localization after dropping the diagnostics_* keys.
2026-08-02 10:02:28 +02:00
232fb125d3 feat(config): shared app.properties for app-wide constants
Add a single source of truth (root app.properties) for public app-wide
constants, injected at build time on both platforms instead of hardcoding.

- Apple: generate-appconfig.sh -> Generated/AppConfig.swift (wired into
  `make apple-app-config`), consumed as AppConfig.privacyPolicyURL.
- KMP: generateAppConfig task -> AppConfig.kt (mirrors DiagnosticsBuildConfig),
  consumed as AppConfig.PRIVACY_POLICY_URL.

Replaces the stale hardcoded privacy-policy URL on both sides with
https://vnidrop.sudosy.fr/privacy/.

Also fix the Apple release core build: disable release LTO in build-core.sh
(Cargo forbids lto in a build-override) to avoid the proc-macro
"mis-aligned LINKEDIT string pool" corruption, so release archives are
compact instead of shipping the debug core.

Update the app icon.

Tests: shell test for the generator (escaping, missing/duplicate key),
plus XCTest and jvmTest asserting the generated value matches app.properties.
2026-08-01 19:56:07 +02:00
Hammed Abass
d52ac52cea Merge pull request #40 from sudosylabs/feat/macos-approval-modal-fix
fix(apple): show macOS approval modal + publish prebuilt core bundle
2026-07-31 17:07:40 +02:00
141 changed files with 9283 additions and 3148 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(swift test *)"
]
}
}

1
Cargo.lock generated
View File

@@ -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
View 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, challengeresponse 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 challengeresponse
(§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 2428
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.

View File

@@ -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.

View File

@@ -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
View 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/

View File

@@ -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"
),
]
)

View File

@@ -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.
```

View 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)")
}
}

View 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)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
preferences: preferences,
notifications: LocalNotificationService(),
messages: UiMessageController(),
bugReports: NoopBugReportService(),
diagnosticsIncluded: false
bugReports: NoopBugReportService()
)
}

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 {

View 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)
}
}

View 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) }
}
}

View 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) }
}
}

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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:

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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:

View File

@@ -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))
}
}
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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"
}
}

View File

@@ -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 {

View File

@@ -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"

View 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"

View 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
View 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
View 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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View 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
View 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)
}

View File

@@ -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
View 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),
}

View 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;
}
}
}

View 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);
}
}

View File

@@ -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")

View 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;
}
}
}

View File

@@ -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)

View File

@@ -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(());

View File

@@ -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),

View File

@@ -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"]

View 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));
}

View 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());
}

View 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);
}

View File

@@ -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");

View 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());
}

View 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"));
}

View File

@@ -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, VniDrops 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 publishers
@@ -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.

View File

@@ -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

View File

@@ -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 dutilisation 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 duso 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é laccè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 laccesso 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. Lautre appareil ne peut jamais le modifier.",
"es": "Solo tú ves este nombre. El otro dispositivo nunca puede cambiarlo.",
"it": "Solo tu vedi questo nome. Laltro 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 lappareil",
"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 lappareil",
"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 laccès. Lappareil doit être appairé à nouveau.",
"es": "Desbloquear no restaura el acceso. Hay que emparejar el dispositivo de nuevo.",
"it": "Sbloccare non ripristina laccesso. 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 lappareil.",
"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": "À louverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir sils ont quelque chose pour vous. Cela leur indique quand vous ouvrez lapplication.",
"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": "Allapertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri lapp.",
"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 nest 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 nest 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} na 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} не ответил"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -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.

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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");

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More