refactor(core): remove prototype contact paths for experimental saved devices

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 06:05:31 +02:00
parent 0a6ecb5c3b
commit a2385912c2
63 changed files with 514 additions and 7806 deletions

View File

@@ -1,10 +1,11 @@
# Design — Saved devices and targeted transfers # Design — Saved devices and targeted transfers
Status: **proposed for the experimental 0.3.x line**. Status: **experimental foundation for the 0.3.x line**.
This document supersedes the previous device-history design. The implementation The unreleased contact/held-offer/polling prototype has been removed. The
currently on `feat/device-history` is an unreleased prototype. Its database and implementation on this branch is the versioned saved-device, device-relationship,
wire formats are not compatibility commitments and may be replaced. and targeted-transfer foundation described below. Its wire protocol is
experimental and versioned; product UI remains deferred.
The feature lets two VniDrop installations remember one another after a The feature lets two VniDrop installations remember one another after a
successful transfer, with explicit consent on both devices. A saved device can successful transfer, with explicit consent on both devices. A saved device can
@@ -486,11 +487,12 @@ versioned from its first merge. Removing the experimental gate requires:
- Stable downgrade, revocation, recovery, and lifecycle behavior. - Stable downgrade, revocation, recovery, and lifecycle behavior.
- No regression in invitation-based multi-recipient transfers. - No regression in invitation-based multi-recipient transfers.
The unreleased `feat/device-history` schema, held offers, polling behavior, The unreleased `feat/device-history` contact schema, held offers, polling
expiring grants, `Contact` terminology, Apple-only feature UI, and ordinary- behavior, expiring grants, `Contact` terminology, Apple-only feature UI, and
share offer authorization are prototype artifacts. They may be removed without ordinary-share offer authorization were prototype artifacts and have been
a migration. Useful low-level cryptographic, repository, protocol, and test removed without a compatibility migration. Useful low-level cryptographic,
patterns may be retained only after they are checked against this design. repository, protocol, and test patterns were retained only after they were
checked against this design.
--- ---

View File

@@ -1,640 +0,0 @@
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,98 +81,6 @@ final class FakeCoreGateway: CoreGateway {
return responseResult return responseResult
} }
func refresh() async -> Result<Void, Error> { .success(()) } 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. /// Minimal `FileSystemService` fake a writable path receive folder, no reveal.
@@ -186,19 +94,14 @@ final class FakeFileSystemService: FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
private(set) var shareDestinations: [ShareDestination] = [] private(set) var shareDestinations: [ShareDestination] = []
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> { func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
shareDestinations.append(destination) shareDestinations.append(destination)
switch destination { guard case .invitation(let accessPolicy) = destination else {
case .invitation(let accessPolicy): return .failure(TestError.unimplemented)
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
)
} }
return await repository.shareSources(
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
} }
} }

View File

@@ -12,7 +12,6 @@ final class AppGraph: ObservableObject {
let preferencesRepository: AppPreferencesRepository let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator let approvalCoordinator: ApprovalCoordinator
let contactsModel: ContactsModel
let transferNotificationCoordinator: TransferNotificationCoordinator let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController let backgroundActivity: BackgroundActivityController
@@ -28,12 +27,6 @@ final class AppGraph: ObservableObject {
themeMode: .system themeMode: .system
) )
) )
self.contactsModel = ContactsModel(
repository: coreRepository,
messages: messages,
preferences: preferencesRepository,
fileSystemService: dependencies.fileSystemService
)
self.approvalCoordinator = ApprovalCoordinator( self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository, repository: coreRepository,
notifications: dependencies.notificationService, notifications: dependencies.notificationService,

View File

@@ -60,11 +60,6 @@ struct RootView: View {
approvals: graph.approvalCoordinator, approvals: graph.approvalCoordinator,
sendModel: sendModel sendModel: sendModel
) )
ContactPromptLayer(
contacts: graph.contactsModel,
receiveModel: receiveModel,
approvals: graph.approvalCoordinator
)
// Top-most so the toast is never covered by the approval overlay's // Top-most so the toast is never covered by the approval overlay's
// full-bleed clear layer. Observes the live `graph.messages` directly. // full-bleed clear layer. Observes the live `graph.messages` directly.
SnackbarHost(controller: graph.messages) SnackbarHost(controller: graph.messages)
@@ -93,9 +88,6 @@ struct RootView: View {
// unfocused/occluded (common on macOS) live events may not have // unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale. // rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() } 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: case .background:
graph.visibility.setForeground(false) graph.visibility.setForeground(false)
// Hold the process open for iOS's grace window so an active // Hold the process open for iOS's grace window so an active
@@ -173,10 +165,10 @@ struct RootView: View {
@ViewBuilder @ViewBuilder
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View { private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
switch destination { switch destination {
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass) case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass) case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .settings: case .settings:
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass) SettingsScreen(model: settingsModel, windowClass: windowClass)
} }
} }
@@ -299,50 +291,3 @@ import AppKit
/// belongs to a transfer this device is sending, and these belong to a device /// 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 /// asking to reach it. Both are suppressed while the other is up so the user is
/// never answering two modals at once. /// 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

@@ -122,14 +122,6 @@ struct AppPreferences: Equatable {
var themeMode: ThemeMode var themeMode: ThemeMode
var diagnosticsInstallId: String var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration 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 { struct AppPreferencesDefaults {
@@ -153,10 +145,7 @@ final class AppPreferencesRepository: ObservableObject {
static let themeMode = "theme_mode" static let themeMode = "theme_mode"
static let diagnosticsInstallId = "diagnostics_install_id" static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration" 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) { init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
self.defaults = defaults self.defaults = defaults
@@ -169,18 +158,12 @@ final class AppPreferencesRepository: ObservableObject {
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" 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( return AppPreferences(
username: username, username: username,
receiveFolder: folder, receiveFolder: folder,
themeMode: themeMode, themeMode: themeMode,
diagnosticsInstallId: installId, diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults), relayConfiguration: resolveRelayConfiguration(defaults)
grantLifetime: grantLifetime,
declinedPairingSuggestions: declined,
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
) )
} }
@@ -226,32 +209,6 @@ final class AppPreferencesRepository: ObservableObject {
setReceiveFolder(fallback.receiveFolder) setReceiveFolder(fallback.receiveFolder)
} }
func declinePairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
declined.insert(endpointId)
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
/// 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) { func setThemeMode(_ mode: ThemeMode) {
defaults.set(mode.rawValue, forKey: Key.themeMode) defaults.set(mode.rawValue, forKey: Key.themeMode)
reload() reload()

View File

@@ -47,42 +47,3 @@ protocol CoreGateway: AnyObject {
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() 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

@@ -74,13 +74,8 @@ enum ShareAccessPolicy: Equatable, Sendable {
} }
/// Where a picked selection is going. /// 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 { enum ShareDestination: Equatable, Sendable {
case invitation(accessPolicy: ShareAccessPolicy) case invitation(accessPolicy: ShareAccessPolicy)
case contact(endpointId: String)
} }
enum TransferDirection: Equatable, Sendable { enum TransferDirection: Equatable, Sendable {
@@ -179,10 +174,6 @@ enum CoreSignal: Equatable, Sendable {
case receiverHistoryChanged(transferId: UInt64) case receiverHistoryChanged(transferId: UInt64)
/// Transfer status/history changed enough to re-read the durable snapshot. /// Transfer status/history changed enough to re-read the durable snapshot.
case transfersChanged(transferId: UInt64) 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) // MARK: - Transfer helpers (ported from AppUiModels.kt)
@@ -201,112 +192,3 @@ extension TransferStatus {
self == .done || self == .failed || self == .cancelled 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,123 +293,6 @@ final class CoreRepository: ObservableObject, CoreGateway {
if let snapshot { self.applySnapshot(snapshot) } if let snapshot { self.applySnapshot(snapshot) }
} }
} }
// 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) // MARK: - Event sink handling (ported from CoreRepository.sink)
private func handle(event: CoreEvent) { private func handle(event: CoreEvent) {
@@ -419,14 +302,6 @@ final class CoreRepository: ObservableObject, CoreGateway {
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) } if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
state.events = events 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 } guard let transferId = model.transferId else { return }
switch model.phase { switch model.phase {
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId)) case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
@@ -645,60 +520,7 @@ 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

@@ -42,7 +42,7 @@ protocol FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<ContactSendOutcome, Error> ) async -> Result<Share, Error>
} }
extension FileSystemService { extension FileSystemService {

View File

@@ -1,185 +0,0 @@
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

@@ -1,451 +0,0 @@
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

@@ -1,344 +0,0 @@
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

@@ -1,73 +0,0 @@
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 { switch signal {
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId): case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
Task { await self.syncReceivers(transferId: transferId) } Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged, .contactsChanged, .offersChanged: case .approvalChanged, .transfersChanged:
break break
} }
} }

View File

@@ -96,8 +96,6 @@ final class SendModel: ObservableObject {
case .receiverHistoryChanged(let id), .approvalChanged(let id): case .receiverHistoryChanged(let id), .approvalChanged(let id):
if id == self.state.selectedTransferId { self.refreshReceivers(id) } if id == self.state.selectedTransferId { self.refreshReceivers(id) }
self.refreshReceiverStatuses(for: id) self.refreshReceiverStatuses(for: id)
case .contactsChanged, .offersChanged:
break
} }
} }
.store(in: &cancellables) .store(in: &cancellables)

View File

@@ -5,7 +5,6 @@ import SFSafeSymbols
/// with the composer and detail panels as native sheets and delete as an alert. /// with the composer and detail panels as native sheets and delete as an alert.
struct SendScreen: View { struct SendScreen: View {
@ObservedObject var model: SendModel @ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let windowClass: WindowClass let windowClass: WindowClass
/// Transfer pending an inline (list-level) delete confirmation. /// Transfer pending an inline (list-level) delete confirmation.
@@ -61,7 +60,7 @@ struct SendScreen: View {
onDismissed: model.shareSheetDidDismiss onDismissed: model.shareSheetDidDismiss
) { ) {
if let shareTarget { if let shareTarget {
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget) TransferSharePanel(model: model, transfer: shareTarget)
} }
} }
} }
@@ -94,7 +93,7 @@ struct SendScreen: View {
/// alert attached here so they present from the detail's own context (presenting /// 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). /// modals from the parent stack while a detail is pushed is unreliable on macOS).
private func detailView(for transfer: Transfer) -> some View { private func detailView(for transfer: Transfer) -> some View {
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events) TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
.adaptiveDrawer( .adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }), isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass, windowClass: windowClass,
@@ -102,7 +101,7 @@ struct SendScreen: View {
onDismissed: model.shareSheetDidDismiss onDismissed: model.shareSheetDidDismiss
) { ) {
if let panel = model.state.detailPanel { if let panel = model.state.detailPanel {
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel) DetailPanelContent(model: model, transfer: transfer, panel: panel)
} }
} }
.alert( .alert(

View File

@@ -6,7 +6,6 @@ import CoreImage.CIFilterBuiltins
struct TransferDetailsView: View { struct TransferDetailsView: View {
@ObservedObject var model: SendModel @ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer let transfer: Transfer
let events: [CoreEventModel] let events: [CoreEventModel]
@State private var showStopConfirmation = false @State private var showStopConfirmation = false
@@ -130,7 +129,6 @@ private struct DetailDestination: View {
struct DetailPanelContent: View { struct DetailPanelContent: View {
@ObservedObject var model: SendModel @ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer let transfer: Transfer
let panel: TransferDetailPanel let panel: TransferDetailPanel
@@ -148,7 +146,7 @@ struct DetailPanelContent: View {
onAccept: model.acceptReceiver onAccept: model.acceptReceiver
) )
case .share: case .share:
TransferSharePanel(model: model, contacts: contacts, transfer: transfer) TransferSharePanel(model: model, transfer: transfer)
} }
} }
} }
@@ -298,7 +296,6 @@ private struct ReceiverRow: View {
struct TransferSharePanel: View { struct TransferSharePanel: View {
@Environment(\.vniColors) private var colors @Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel @ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer let transfer: Transfer
var body: some View { var body: some View {
@@ -312,7 +309,7 @@ struct TransferSharePanel: View {
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket) ShareActionsView(model: model, transfer: transfer, ticket: ticket)
case .preparing: case .preparing:
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter) Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable: case .unavailable:

View File

@@ -20,25 +20,14 @@ protocol TransferShareActions: AnyObject {
struct ShareActionsView: View { struct ShareActionsView: View {
@Environment(\.vniColors) private var colors @Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel @ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer let transfer: Transfer
let ticket: String let ticket: String
@State private var actions: TransferShareActions = makePlatformShareActions() @State private var actions: TransferShareActions = makePlatformShareActions()
@State private var writingNfc = false @State private var writingNfc = false
@State private var choosingDevice = false
var body: some View { var body: some View {
VStack(spacing: 12) { 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 { if actions.nfcAvailability != .hidden {
SecondaryButton( SecondaryButton(
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc), title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
@@ -68,8 +57,5 @@ struct ShareActionsView: View {
}, enabled: actions.canUseNativeShare) }, enabled: actions.canUseNativeShare)
} }
.onDisappear { actions.cancelNfcWrite() } .onDisappear { actions.cancelNfcWrite() }
.sheet(isPresented: $choosingDevice) {
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
}
} }
} }

View File

@@ -8,10 +8,6 @@ enum SettingsSection: Hashable {
case appearance case appearance
case notifications case notifications
case network 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 storage
case about case about
case bugReport case bugReport
@@ -23,7 +19,6 @@ enum SettingsSection: Hashable {
case .appearance: return L10n.Appearance.title case .appearance: return L10n.Appearance.title
case .notifications: return L10n.Notifications.title case .notifications: return L10n.Notifications.title
case .network: return L10n.Settings.networkTitle case .network: return L10n.Settings.networkTitle
case .contacts, .contactDetail: return L10n.Contacts.title
case .storage: return L10n.Storage.title case .storage: return L10n.Storage.title
case .about: return L10n.About.title case .about: return L10n.About.title
case .bugReport: return L10n.About.bugReport case .bugReport: return L10n.About.bugReport
@@ -179,11 +174,6 @@ final class SettingsModel: ObservableObject {
loadDeviceInfo() 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) { func selectSection(_ section: SettingsSection) {
state.selectedSection = section state.selectedSection = section

View File

@@ -5,7 +5,6 @@ import SFSafeSymbols
/// navigation. The model stays the source of truth via a derived path binding. /// navigation. The model stays the source of truth via a derived path binding.
struct SettingsScreen: View { struct SettingsScreen: View {
@ObservedObject var model: SettingsModel @ObservedObject var model: SettingsModel
@ObservedObject var contacts: ContactsModel
let windowClass: WindowClass let windowClass: WindowClass
@State private var showBugReport = false @State private var showBugReport = false
@@ -15,8 +14,6 @@ struct SettingsScreen: View {
switch model.state.selectedSection { switch model.state.selectedSection {
case .overview: return [] case .overview: return []
case .bugReport: return [.about, .bugReport] case .bugReport: return [.about, .bugReport]
case .contactDetail(let endpointId):
return [.contacts, .contactDetail(endpointId: endpointId)]
case let section: return [section] case let section: return [section]
} }
}, },
@@ -57,15 +54,6 @@ struct SettingsScreen: View {
NavigationLink(value: SettingsSection.storage) { NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil) 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)) { Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) { NavigationLink(value: SettingsSection.network) {
@@ -92,17 +80,7 @@ struct SettingsScreen: View {
@ViewBuilder @ViewBuilder
private func sectionForm(_ section: SettingsSection) -> some View { private func sectionForm(_ section: SettingsSection) -> some View {
// Contacts brings its own Form and push destination, so it is not wrapped settingsSectionForm(section)
// 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 @ViewBuilder
@@ -157,9 +135,6 @@ private struct SettingsSectionContent: View {
NetworkSettings(model: model) NetworkSettings(model: model)
case .storage: case .storage:
StorageSettings(model: model) StorageSettings(model: model)
case .contacts, .contactDetail:
// Rendered by SettingsScreen itself, which owns the contacts model.
EmptyView()
case .about: case .about:
AboutSettings(model: model) AboutSettings(model: model)
case .bugReport: case .bugReport:

View File

@@ -60,23 +60,17 @@ struct IosFileSystemService: FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<ContactSendOutcome, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty) return .failure(InvitationError.shareEmpty)
} }
let sources = files.map { $0.toIosShareSource() } let sources = files.map { $0.toIosShareSource() }
switch destination { guard case .invitation(let accessPolicy) = destination else {
case .invitation(let accessPolicy): return .failure(InvitationError.unsupportedOperation)
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
)
} }
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
} }
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus { private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {

View File

@@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<ContactSendOutcome, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty) return .failure(InvitationError.shareEmpty)
} }
@@ -63,18 +63,12 @@ struct MacFileSystemService: FileSystemService {
let sources = files.map { let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
} }
switch destination { guard case .invitation(let accessPolicy) = destination else {
case .invitation(let accessPolicy): return .failure(InvitationError.unsupportedOperation)
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
)
} }
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
} }
} }
#endif #endif

View File

@@ -72,30 +72,6 @@ struct SendPickers: ViewModifier {
/// File picker for "send to this device", reusing the share picker's selection /// File picker for "send to this device", reusing the share picker's selection
/// handling so security-scoped bookmarks are captured the same way. /// 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 { enum PickerSupport {
static func receiveFolder(from url: URL) -> ReceiveFolder { static func receiveFolder(from url: URL) -> ReceiveFolder {
@@ -157,7 +133,4 @@ extension View {
modifier(SendPickers(model: model)) modifier(SendPickers(model: model))
} }
func contactSendPickers(model: ContactsModel) -> some View {
modifier(ContactSendPickers(model: model))
}
} }

View File

@@ -595,80 +595,6 @@ pub struct TicketInspection {
pub metadata: TransferMetadata, 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)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ReceiverRequest { pub struct ReceiverRequest {
pub id: String, pub id: String,

View File

@@ -180,7 +180,7 @@ impl ApprovalService {
) -> HandshakeResponse { ) -> HandshakeResponse {
if self if self
.repository .repository
.contacts() .blocked_devices()
.is_blocked(&remote_endpoint_id) .is_blocked(&remote_endpoint_id)
.await .await
.unwrap_or(true) .unwrap_or(true)

View File

@@ -0,0 +1,78 @@
//! Identity-wide deny list for saved-device and invitation traffic.
//!
//! Unreleased prototype contact / grant / held-offer tables are dropped on open
//! so they leave no compatibility commitment or orphaned authorization.
use anyhow::Result;
use sqlx::{Row, SqlitePool};
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
// Prototype artifacts from the unreleased device-history experiment.
for table in ["held_offers", "grants_held", "grants_issued", "contacts"] {
sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
.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(())
}
/// Durable deny records over the shared repository pool.
#[derive(Debug, Clone)]
pub(crate) struct BlockStore {
pool: SqlitePool,
}
impl BlockStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
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())
}
}

View File

@@ -1,627 +0,0 @@
//! 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),
})
}

View File

@@ -60,6 +60,7 @@ impl DeviceRelationshipService {
let _guard = peer_lock.lock().await; let _guard = peer_lock.lock().await;
let Some(row) = self.find_row(&peer_endpoint_id).await? else { let Some(row) = self.find_row(&peer_endpoint_id).await? else {
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
return Ok(ForgetOutcome { return Ok(ForgetOutcome {
had_relationship: false, had_relationship: false,
generation: None, generation: None,

View File

@@ -39,7 +39,7 @@ mod lifecycle;
#[cfg(test)] #[cfg(test)]
pub(crate) use lifecycle::GenerationTombstone; pub(crate) use lifecycle::GenerationTombstone;
use crate::contacts::ContactStore; use crate::blocked_devices::BlockStore;
use crypto::{ use crypto::{
encode_relationship_grant_secret, prove_relationship_grant, secret_from_material, encode_relationship_grant_secret, prove_relationship_grant, secret_from_material,
verify_relationship_grant, verify_relationship_grant,
@@ -171,13 +171,13 @@ impl DeviceRelationshipService {
Ok(()) Ok(())
} }
pub(super) fn contacts(&self) -> ContactStore { pub(super) fn blocked_devices(&self) -> BlockStore {
ContactStore::new(self.pool.clone()) BlockStore::new(self.pool.clone())
} }
pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool { pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool {
// Fail closed: a store error must not admit blocked traffic. // Fail closed: a store error must not admit blocked traffic.
self.contacts() self.blocked_devices()
.is_blocked(endpoint_id) .is_blocked(endpoint_id)
.await .await
.unwrap_or(true) .unwrap_or(true)

View File

@@ -50,10 +50,6 @@ enum EventPhase {
Delivery, Delivery,
/// Saved-device pairing eligibility and consent prompts. /// Saved-device pairing eligibility and consent prompts.
Pairing, Pairing,
/// Prototype contact lifecycle notifications.
Contacts,
/// Prototype contact offer prompts.
Offer,
/// Saved-device targeted-transfer pre-approval prompts. /// Saved-device targeted-transfer pre-approval prompts.
TargetedTransfer, TargetedTransfer,
} }
@@ -78,8 +74,6 @@ impl EventPhase {
"transfer" => Some(Self::Transfer), "transfer" => Some(Self::Transfer),
"delivery" => Some(Self::Delivery), "delivery" => Some(Self::Delivery),
"pairing" => Some(Self::Pairing), "pairing" => Some(Self::Pairing),
"contacts" => Some(Self::Contacts),
"offer" => Some(Self::Offer),
"targeted_transfer" => Some(Self::TargetedTransfer), "targeted_transfer" => Some(Self::TargetedTransfer),
_ => None, _ => None,
} }
@@ -104,8 +98,6 @@ impl EventPhase {
Self::Transfer => "transfer", Self::Transfer => "transfer",
Self::Delivery => "delivery", Self::Delivery => "delivery",
Self::Pairing => "pairing", Self::Pairing => "pairing",
Self::Contacts => "contacts",
Self::Offer => "offer",
Self::TargetedTransfer => "targeted_transfer", Self::TargetedTransfer => "targeted_transfer",
} }
} }

View File

@@ -1,24 +1,15 @@
//! Grants: the capability a device issues so a known peer may reach it. //! Grant identity and possession-proof primitives for saved-device relationships.
//! //!
//! A history entry is not "I remember this endpoint id", it is "this device //! The issuer is the only party that can validate a grant, which is what makes
//! issued me a capability". The issuer is the only party that can validate a //! both consent and revocation enforceable. This module is pure: no storage and
//! grant, which is what makes both consent and revocation enforceable: refusing //! no network. Relationship-bound MACs live in `device_relationship::crypto`.
//! 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 std::fmt;
use anyhow::{bail, Context, Result}; use anyhow::{Context, Result};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use serde::{Deserialize, Serialize}; 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_ID_LEN: usize = 16;
const GRANT_SECRET_LEN: usize = 32; const GRANT_SECRET_LEN: usize = 32;
const CHALLENGE_LEN: usize = 32; const CHALLENGE_LEN: usize = 32;
@@ -168,9 +159,6 @@ impl fmt::Debug for GrantProof {
pub(crate) enum GrantRejection { pub(crate) enum GrantRejection {
Unknown, Unknown,
Revoked, Revoked,
Expired,
WrongEndpoint,
BadProof,
} }
impl GrantRejection { impl GrantRejection {
@@ -178,207 +166,10 @@ impl GrantRejection {
match self { match self {
Self::Unknown => "unknown", Self::Unknown => "unknown",
Self::Revoked => "revoked", 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. /// Cryptographically secure random bytes.
/// ///
/// Panics if the OS entropy source fails. That is unrecoverable and must never /// Panics if the OS entropy source fails. That is unrecoverable and must never
@@ -388,13 +179,3 @@ fn random_bytes<const N: usize>() -> [u8; N] {
getrandom::fill(&mut bytes).expect("OS entropy source unavailable"); getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
bytes 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,7 +1,7 @@
mod access_policy; mod access_policy;
mod api; mod api;
mod approval; mod approval;
mod contacts; mod blocked_devices;
mod control_plane; mod control_plane;
mod device_relationship; mod device_relationship;
mod error; mod error;
@@ -10,9 +10,6 @@ mod filesystem;
mod grant; mod grant;
mod handshake; mod handshake;
mod logging; mod logging;
mod offer;
mod offer_inbox;
mod pairing;
mod pairing_eligibility; mod pairing_eligibility;
mod repository; mod repository;
mod runtime; mod runtime;
@@ -29,11 +26,10 @@ mod util;
pub use api::{ pub use api::{
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent, experimental_saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits,
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship,
DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary,
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
PendingPairing, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer,
TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata, TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata,

View File

@@ -1,335 +0,0 @@
//! 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

@@ -1,279 +0,0 @@
//! 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);
#[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,
decline_cooldown_ms: i64,
}
impl OfferInbox {
pub(crate) fn new(
event_hub: Arc<EventHub>,
max_pending: usize,
identity_cooldown_ms: u64,
) -> 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,
decline_cooldown_ms: identity_cooldown_ms as i64,
}
}
/// 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() + self.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

@@ -1,386 +0,0 @@
//! 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

@@ -252,14 +252,6 @@ impl PairingEligibilityService {
Ok(()) Ok(())
} }
pub(crate) async fn remove_all(&self) -> Result<(), VnidropError> {
let entries = self.repository.list_pairing_eligibility_records().await?;
for entry in entries {
self.delete_entry(&entry).await?;
}
Ok(())
}
/// Returns the matching record when the capability is valid; otherwise `None` /// Returns the matching record when the capability is valid; otherwise `None`
/// without emitting prompts or eligibility-removed events for the reject path. /// without emitting prompts or eligibility-removed events for the reject path.
#[allow( #[allow(

View File

@@ -19,7 +19,7 @@ use crate::{
CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind, CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind,
ReceiverRequest, StoredTransfer, ReceiverRequest, StoredTransfer,
}, },
contacts::ContactStore, blocked_devices::BlockStore,
error::VnidropError, error::VnidropError,
pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord}, pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
@@ -336,7 +336,7 @@ impl Repository {
.await?; .await?;
} }
crate::contacts::ensure_schema(&self.pool).await?; crate::blocked_devices::ensure_schema(&self.pool).await?;
crate::secure_secret::ensure_schema(&self.pool).await?; crate::secure_secret::ensure_schema(&self.pool).await?;
crate::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?; crate::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?;
crate::targeted_transfer::ensure_schema(&self.pool).await?; crate::targeted_transfer::ensure_schema(&self.pool).await?;
@@ -369,10 +369,9 @@ impl Repository {
Ok(()) Ok(())
} }
/// Device history, grants, and the block list. Shares this pool so the /// Identity-wide deny list for saved-device and invitation traffic.
/// tables migrate together with the rest of the schema. pub(crate) fn blocked_devices(&self) -> BlockStore {
pub(crate) fn contacts(&self) -> ContactStore { BlockStore::new(self.pool.clone())
ContactStore::new(self.pool.clone())
} }
pub(crate) fn sqlite_pool(&self) -> SqlitePool { pub(crate) fn sqlite_pool(&self) -> SqlitePool {

View File

@@ -1,868 +0,0 @@
//! 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_pairing_eligibilities(
&self,
) -> Result<Vec<crate::api::PairingEligibilitySummary>, crate::error::VnidropError> {
self.pairing_eligibility.list().await
}
pub(super) async fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.pairing_eligibility.decline(&peer_endpoint_id).await
}
pub(super) async fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> {
self.device_relationships
.request_pairing(peer_endpoint_id)
.await
}
pub(super) async fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, crate::error::VnidropError> {
self.device_relationships.list().await
}
pub(super) async fn list_saved_devices(
&self,
) -> Result<Vec<crate::api::SavedDevice>, crate::error::VnidropError> {
self.device_relationships.list_saved_devices().await
}
pub(super) async fn respond_to_device_pairing(
self: &Arc<Self>,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
if self
.repository
.contacts()
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(true)
{
return Ok(false);
}
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.await
}
pub(super) async fn forget_saved_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let outcome = self
.device_relationships
.forget(peer_endpoint_id.clone())
.await?;
// Targeted transfers for this relationship only.
// Invitation-domain shares are deliberately not cancelled here.
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"saved-device-forgotten",
json!({
"peer_endpoint_id": peer_endpoint_id,
"had_relationship": outcome.had_relationship,
}),
);
if outcome.had_relationship {
if let Some(generation) = outcome.generation {
self.device_relationships
.notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id)
.await;
}
}
Ok(())
}
pub(super) async fn block_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let now = now_ms();
self.repository
.contacts()
.block_endpoint(&peer_endpoint_id, now)
.await
.map_err(VnidropError::repository)?;
self.device_relationships
.revoke_for_block(&peer_endpoint_id)
.await?;
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.offers.discard_from(&peer_endpoint_id).await;
self.emit_endpoint(
"pairing",
"device-blocked",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
// Silence: blocked peers are not notified (design §8).
Ok(())
}
pub(super) async fn unblock_device(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.repository
.contacts()
.unblock_endpoint(&peer_endpoint_id)
.await
.map_err(VnidropError::repository)?;
// Unblock removes only the deny rule; grants/relationships stay gone.
Ok(())
}
pub(super) async fn list_blocked_devices(
&self,
) -> Result<Vec<String>, crate::error::VnidropError> {
self.repository
.contacts()
.list_blocked()
.await
.map_err(VnidropError::repository)
}
pub(super) async fn rotate_relationship_grant(
&self,
peer_endpoint_id: String,
) -> Result<u64, crate::error::VnidropError> {
self.device_relationships
.rotate_relationship_grant(peer_endpoint_id)
.await
}
#[cfg(test)]
pub(super) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.targeted_cancel_log
.lock()
.expect("targeted cancel log")
.clone()
}
#[cfg(test)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, crate::error::VnidropError> {
let material = crate::secure_secret::SecretMaterial::new(capability)?;
self.pairing_eligibility
.accept_presented_eligibility(&peer_endpoint_id, &session_id, &material)
.await
}
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.pairing_eligibility
.remove_for_peer(&endpoint_id)
.await
.map_err(anyhow::Error::from)?;
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.pairing_eligibility
.remove_all()
.await
.map_err(anyhow::Error::from)?;
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.pairing_eligibility
.remove_for_peer(&endpoint_id)
.await
.map_err(anyhow::Error::from)?;
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,11 +6,10 @@ use serde_json::json;
use super::{CoreInner, IdentityMode}; use super::{CoreInner, IdentityMode};
use crate::{ use crate::{
api::{ api::{
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
PairingEligibilitySummary, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, StoredTransfer, TicketInspection, TransferAccessMode,
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
}, },
error::VnidropError, error::VnidropError,
filesystem::platform_path, filesystem::platform_path,
@@ -634,148 +633,6 @@ impl VnidropCore {
self.block_on(self.inner.resume_targeted_transfer(id, output_dir)) self.block_on(self.inner.resume_targeted_transfer(id, output_dir))
} }
/// 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> { pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
self.block_on(self.inner.repository.list_transfers()) self.block_on(self.inner.repository.list_transfers())
.map_err(VnidropError::repository) .map_err(VnidropError::repository)

View File

@@ -54,13 +54,6 @@ impl CoreInner {
drop(active_shares); drop(active_shares);
self.unregister_transfer_hashes(transfer_id).await; self.unregister_transfer_hashes(transfer_id).await;
self.access_policy.remove_transfer(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.store.tags().delete(share_tag_name(&local_id)).await?;
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({})); self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
return Ok(()); return Ok(());

View File

@@ -6,21 +6,19 @@
//! - [`receive`] — ticket receive, download, export //! - [`receive`] — ticket receive, download, export
//! - [`lifecycle`] — cancel/delete/shutdown/status/access //! - [`lifecycle`] — cancel/delete/shutdown/status/access
//! - [`provider`] — blob provider events and per-connection send progress //! - [`provider`] — blob provider events and per-connection send progress
//! - [`contacts`] — device history: pairing, forgetting, blocking //! - [`saved_devices`] — experimental saved-device pairing, forget, block
//! - [`targeted`] — saved-device targeted transfers //! - [`targeted`] — saved-device targeted transfers
mod contacts;
mod delivery; mod delivery;
mod facade; mod facade;
mod lifecycle; mod lifecycle;
mod provider; mod provider;
mod receive; mod receive;
mod saved_devices;
mod share; mod share;
mod storage; mod storage;
mod targeted; mod targeted;
#[cfg(test)]
pub(crate) use self::contacts::should_poll;
pub use facade::VnidropCore; pub use facade::VnidropCore;
#[cfg(test)] #[cfg(test)]
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome}; pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
@@ -62,9 +60,6 @@ use crate::{
event_hub::EventHub, event_hub::EventHub,
handshake::HandshakeService, handshake::HandshakeService,
logging::init_logging, logging::init_logging,
offer::OfferService,
offer_inbox::OfferInbox,
pairing::PairingService,
pairing_eligibility::PairingEligibilityService, pairing_eligibility::PairingEligibilityService,
repository::Repository, repository::Repository,
secret::load_or_create_secret, secret::load_or_create_secret,
@@ -76,10 +71,6 @@ use crate::{
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RelayStatus { pub(crate) enum RelayStatus {
Disabled, Disabled,
@@ -109,13 +100,9 @@ pub(super) struct CoreInner {
pub(super) secret_custody: Option<Arc<crate::secure_secret::SecretCustody>>, pub(super) secret_custody: Option<Arc<crate::secure_secret::SecretCustody>>,
pub(super) event_hub: Arc<EventHub>, pub(super) event_hub: Arc<EventHub>,
pub(super) approval: ApprovalService, pub(super) approval: ApprovalService,
pub(super) pairing: PairingService,
pub(super) pairing_eligibility: PairingEligibilityService, pub(super) pairing_eligibility: PairingEligibilityService,
pub(super) device_relationships: Arc<DeviceRelationshipService>, pub(super) device_relationships: Arc<DeviceRelationshipService>,
pub(super) offers: OfferInbox,
pub(super) targeted_offers: TargetedOfferInbox, pub(super) targeted_offers: TargetedOfferInbox,
/// Endpoint → last poll time, for the rate limit above.
pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
pub(super) limits: CoreLimits, pub(super) limits: CoreLimits,
pub(super) relay_mode: CoreRelayMode, pub(super) relay_mode: CoreRelayMode,
pub(super) custom_relay_urls: Vec<RelayUrl>, pub(super) custom_relay_urls: Vec<RelayUrl>,
@@ -380,25 +367,6 @@ impl CoreInner {
Some(pairing_eligibility.clone()), Some(pairing_eligibility.clone()),
); );
let handshake = HandshakeService::new(approval.clone()); 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,
limits.identity_cooldown_ms,
);
let identity_cooldown = crate::control_plane::IdentityCooldown::new( let identity_cooldown = crate::control_plane::IdentityCooldown::new(
limits.identity_cooldown_ms, limits.identity_cooldown_ms,
limits.malformed_strike_limit, limits.malformed_strike_limit,
@@ -424,10 +392,6 @@ impl CoreInner {
let router = Router::builder(endpoint.clone()) let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs) .accept(iroh_blobs::ALPN, blobs)
.accept(HandshakeService::ALPN, handshake) .accept(HandshakeService::ALPN, handshake)
.accept(
OfferService::ALPN,
OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()),
)
.accept( .accept(
RelationshipProtocol::ALPN, RelationshipProtocol::ALPN,
RelationshipProtocol::new(device_relationships.clone()), RelationshipProtocol::new(device_relationships.clone()),
@@ -456,12 +420,9 @@ impl CoreInner {
secret_custody: secret_custody.clone(), secret_custody: secret_custody.clone(),
event_hub, event_hub,
approval, approval,
pairing,
pairing_eligibility, pairing_eligibility,
device_relationships, device_relationships,
offers,
targeted_offers, targeted_offers,
last_polled: TokioMutex::new(HashMap::new()),
relay_mode, relay_mode,
custom_relay_urls: relay_urls, custom_relay_urls: relay_urls,
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),

View File

@@ -0,0 +1,171 @@
//! Runtime operations for experimental saved devices and device relationships.
use std::sync::Arc;
use anyhow::Result;
use serde_json::json;
use super::CoreInner;
use crate::{error::VnidropError, util::now_ms};
impl CoreInner {
pub(super) async fn list_pairing_eligibilities(
&self,
) -> Result<Vec<crate::api::PairingEligibilitySummary>, crate::error::VnidropError> {
self.pairing_eligibility.list().await
}
pub(super) async fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.pairing_eligibility.decline(&peer_endpoint_id).await
}
pub(super) async fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> {
self.device_relationships
.request_pairing(peer_endpoint_id)
.await
}
pub(super) async fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, crate::error::VnidropError> {
self.device_relationships.list().await
}
pub(super) async fn list_saved_devices(
&self,
) -> Result<Vec<crate::api::SavedDevice>, crate::error::VnidropError> {
self.device_relationships.list_saved_devices().await
}
pub(super) async fn respond_to_device_pairing(
self: &Arc<Self>,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
if self
.repository
.blocked_devices()
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(true)
{
return Ok(false);
}
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.await
}
pub(super) async fn forget_saved_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let outcome = self
.device_relationships
.forget(peer_endpoint_id.clone())
.await?;
// Targeted transfers for this relationship only.
// Invitation-domain shares are deliberately not cancelled here.
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"saved-device-forgotten",
json!({
"peer_endpoint_id": peer_endpoint_id,
"had_relationship": outcome.had_relationship,
}),
);
if outcome.had_relationship {
if let Some(generation) = outcome.generation {
self.device_relationships
.notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id)
.await;
}
}
Ok(())
}
pub(super) async fn block_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let now = now_ms();
self.repository
.blocked_devices()
.block_endpoint(&peer_endpoint_id, now)
.await
.map_err(VnidropError::repository)?;
self.device_relationships
.revoke_for_block(&peer_endpoint_id)
.await?;
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"device-blocked",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
// Silence: blocked peers are not notified (design §8).
Ok(())
}
pub(super) async fn unblock_device(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.repository
.blocked_devices()
.unblock_endpoint(&peer_endpoint_id)
.await
.map_err(VnidropError::repository)?;
// Unblock removes only the deny rule; grants/relationships stay gone.
Ok(())
}
pub(super) async fn list_blocked_devices(
&self,
) -> Result<Vec<String>, crate::error::VnidropError> {
self.repository
.blocked_devices()
.list_blocked()
.await
.map_err(VnidropError::repository)
}
pub(super) async fn rotate_relationship_grant(
&self,
peer_endpoint_id: String,
) -> Result<u64, crate::error::VnidropError> {
self.device_relationships
.rotate_relationship_grant(peer_endpoint_id)
.await
}
#[cfg(test)]
pub(super) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.targeted_cancel_log
.lock()
.expect("targeted cancel log")
.clone()
}
#[cfg(test)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, crate::error::VnidropError> {
let material = crate::secure_secret::SecretMaterial::new(capability)?;
self.pairing_eligibility
.accept_presented_eligibility(&peer_endpoint_id, &session_id, &material)
.await
}
}

View File

@@ -1,9 +1,9 @@
#[path = "tests/access_policy.rs"] #[path = "tests/access_policy.rs"]
mod access_policy_tests; mod access_policy_tests;
#[path = "tests/contact_polling.rs"] #[path = "tests/api_surface.rs"]
mod contact_polling_tests; mod api_surface_tests;
#[path = "tests/contacts.rs"] #[path = "tests/blocked_devices.rs"]
mod contacts_tests; mod blocked_devices_tests;
#[path = "tests/control_plane.rs"] #[path = "tests/control_plane.rs"]
mod control_plane_tests; mod control_plane_tests;
#[path = "tests/device_relationship.rs"] #[path = "tests/device_relationship.rs"]

View File

@@ -0,0 +1,73 @@
//! Public API surface after prototype contact/offer removal.
#[test]
fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_points() {
let facade = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/runtime/facade.rs"
));
let api = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/api.rs"));
let lib = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs"));
for forbidden in [
"fn list_contacts(",
"fn send_to_contact(",
"fn poll_contacts_for_offers(",
"fn offer_transfer_to_contact(",
"fn list_held_offers(",
"fn list_pending_offers(",
"fn respond_to_offer(",
"fn list_pending_pairings(",
"fn allow_device_to_reach_me(",
"fn respond_to_pairing(",
"fn forget_contact(",
"fn forget_all_contacts(",
"fn block_contact(",
"fn unblock_contact(",
"fn list_blocked_contacts(",
"fn set_contact_label(",
"fn set_grant_lifetime(",
"struct ContactSummary",
"struct ContactSendResult",
"struct HeldOfferSummary",
"struct IncomingOffer",
"struct PendingPairing",
"enum GrantLifetimeSetting",
] {
assert!(
!facade.contains(forbidden),
"facade must not expose prototype entry point {forbidden}"
);
assert!(
!api.contains(forbidden),
"api.rs must not define prototype type {forbidden}"
);
assert!(
!lib.contains(forbidden),
"lib.rs must not re-export prototype symbol {forbidden}"
);
}
for required in [
"fn list_saved_devices(",
"fn list_device_relationships(",
"fn request_saved_device_pairing(",
"fn create_targeted_transfer(",
"fn list_pending_targeted_offers(",
"fn block_device(",
"fn forget_saved_device(",
"fn share_files(",
"fn receive(",
"experimental_saved_device_capabilities",
] {
assert!(
facade.contains(required) || api.contains(required) || lib.contains(required),
"public surface must keep {required}"
);
}
let caps = crate::experimental_saved_device_capabilities();
assert_eq!(caps.domain_contract_version, 1);
assert_eq!(caps.relationship_protocol_version, 1);
assert_eq!(caps.targeted_transfer_protocol_version, 1);
}

View File

@@ -0,0 +1,66 @@
use crate::{blocked_devices::BlockStore, repository::Repository};
async fn store(temp: &tempfile::TempDir) -> BlockStore {
let repository = Repository::open(temp.path()).await.unwrap();
repository.blocked_devices()
}
#[tokio::test]
async fn block_list_persists_and_unblocks() {
let temp = tempfile::tempdir().unwrap();
let blocks = store(&temp).await;
assert!(!blocks.is_blocked("peer-a").await.unwrap());
blocks.block_endpoint("peer-a", 100).await.unwrap();
assert!(blocks.is_blocked("peer-a").await.unwrap());
assert_eq!(
blocks.list_blocked().await.unwrap(),
vec!["peer-a".to_string()]
);
blocks.unblock_endpoint("peer-a").await.unwrap();
assert!(!blocks.is_blocked("peer-a").await.unwrap());
assert!(blocks.list_blocked().await.unwrap().is_empty());
}
#[tokio::test]
async fn opening_repository_drops_unreleased_prototype_tables() {
let temp = tempfile::tempdir().unwrap();
let db = temp.path().join("vnidrop.sqlite3");
{
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(&db)
.create_if_missing(true);
let pool = sqlx::SqlitePool::connect_with(options).await.unwrap();
for ddl in [
"CREATE TABLE contacts (endpoint_id TEXT PRIMARY KEY)",
"CREATE TABLE grants_issued (grant_id TEXT PRIMARY KEY, grant_secret TEXT NOT NULL)",
"CREATE TABLE grants_held (grant_id TEXT PRIMARY KEY, grant_secret TEXT NOT NULL)",
"CREATE TABLE held_offers (offer_id TEXT PRIMARY KEY, ticket TEXT NOT NULL)",
"CREATE TABLE blocked_endpoints (endpoint_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)",
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES ('keep-me', 1)",
"INSERT INTO held_offers (offer_id, ticket) VALUES ('orphan', 'ticket')",
] {
sqlx::query(ddl).execute(&pool).await.unwrap();
}
}
let repository = Repository::open(temp.path()).await.unwrap();
let pool = repository.sqlite_pool();
for table in ["contacts", "grants_issued", "grants_held", "held_offers"] {
let row = sqlx::query(&format!(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'"
))
.fetch_one(&pool)
.await
.unwrap();
let n: i64 = sqlx::Row::get(&row, "n");
assert_eq!(n, 0, "{table} must be dropped without migration");
}
assert!(repository
.blocked_devices()
.is_blocked("keep-me")
.await
.unwrap());
}

View File

@@ -1,30 +0,0 @@
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

@@ -1,386 +0,0 @@
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

@@ -524,13 +524,13 @@ fn reinstalled_peer_is_never_merged_by_name_or_metadata() {
// Same display-facing label on a different endpoint identity must not merge. // Same display-facing label on a different endpoint identity must not merge.
alice alice
.core .core
.set_contact_label(bob_id.clone(), Some("Kitchen Tablet".to_string())) .set_saved_device_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
.ok(); .unwrap();
reach_saved(&alice, &charlie, 90_041); reach_saved(&alice, &charlie, 90_041);
alice alice
.core .core
.set_contact_label(charlie_id.clone(), Some("Kitchen Tablet".to_string())) .set_saved_device_label(charlie_id.clone(), Some("Kitchen Tablet".to_string()))
.ok(); .unwrap();
let saved = alice.core.list_saved_devices().unwrap(); let saved = alice.core.list_saved_devices().unwrap();
assert_eq!(saved.len(), 2); assert_eq!(saved.len(), 2);

View File

@@ -1,226 +1,35 @@
use crate::grant::{ use crate::grant::{Challenge, GrantId, GrantRejection, GrantSecret};
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] #[test]
fn accepts_a_valid_proof_and_returns_the_renewed_deadline() { fn grant_identifiers_round_trip() {
let now = 1_700_000_000_000; let grant_id = GrantId::generate();
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!( assert_eq!(
accept_with(&grant, &Challenge::generate(), HOLDER, now), GrantId::decode(&grant_id.encode()).expect("id decodes"),
Err(GrantRejection::Revoked) grant_id
); );
}
#[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(); let secret = GrantSecret::generate();
assert_eq!(parse_secret(&secret.encode()).expect("decodes"), secret); assert_eq!(
} GrantSecret::decode(&secret.encode()).expect("secret decodes"),
secret
);
assert_eq!(format!("{secret:?}"), "GrantSecret(redacted)");
#[test] let challenge = Challenge::generate();
fn rejects_malformed_or_degenerate_stored_secrets() { assert_eq!(
assert!(parse_secret("not-hex").is_err()); Challenge::decode(&challenge.encode()).expect("challenge decodes"),
assert!(parse_secret("aabb").is_err(), "wrong length"); challenge
assert!(
parse_secret(&"00".repeat(32)).is_err(),
"an all-zero secret means corrupt storage, not a usable grant"
); );
} }
#[test] #[test]
fn secrets_are_redacted_in_debug_output() { fn grant_secret_decode_rejects_garbage() {
let secret = GrantSecret::generate(); assert!(GrantSecret::decode("not-hex").is_err());
let rendered = format!("{secret:?}"); assert!(GrantSecret::decode("aabb").is_err(), "wrong length");
assert!(!rendered.contains(&secret.encode()));
assert_eq!(rendered, "GrantSecret(redacted)");
} }
#[test] #[test]
fn generated_grants_are_unique() { fn grant_rejection_labels_are_stable() {
let now = 1_700_000_000_000; assert_eq!(GrantRejection::Unknown.as_str(), "unknown");
let first = issued(now); assert_eq!(GrantRejection::Revoked.as_str(), "revoked");
let second = issued(now);
assert_ne!(first.grant_id, second.grant_id);
assert_ne!(first.secret, second.secret);
} }

View File

@@ -399,7 +399,7 @@ fn decline_forget_block_and_replay_remove_eligibility_idempotently() {
wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id); wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id);
receiver receiver
.core .core
.forget_contact(sender2.core.status().endpoint_id.clone()) .forget_saved_device(sender2.core.status().endpoint_id.clone())
.unwrap(); .unwrap();
assert!(receiver assert!(receiver
.core .core
@@ -423,7 +423,7 @@ fn decline_forget_block_and_replay_remove_eligibility_idempotently() {
wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id); wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id);
receiver receiver
.core .core
.block_contact(sender3.core.status().endpoint_id.clone()) .block_device(sender3.core.status().endpoint_id.clone())
.unwrap(); .unwrap();
assert!(receiver assert!(receiver
.core .core
@@ -445,12 +445,16 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
let receiver_id = receiver.core.status().endpoint_id.clone(); let receiver_id = receiver.core.status().endpoint_id.clone();
let events_before = receiver.sink.events().len(); let events_before = receiver.sink.events().len();
// Missing eligibility: request produces no pending pairing prompt/event. // Missing eligibility: request produces no pending relationship prompt/event.
assert!(!receiver assert!(!receiver
.core .core
.request_saved_device_pairing(sender.core.status().endpoint_id.clone()) .request_saved_device_pairing(sender.core.status().endpoint_id.clone())
.unwrap()); .unwrap());
assert!(receiver.core.list_pending_pairings().is_empty()); assert!(receiver
.core
.list_device_relationships()
.unwrap()
.is_empty());
assert_eq!( assert_eq!(
receiver receiver
.sink .sink
@@ -483,8 +487,22 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
.core .core
.request_saved_device_pairing(receiver_id) .request_saved_device_pairing(receiver_id)
.unwrap()); .unwrap());
assert!(sender.core.list_pending_pairings().is_empty()); assert_eq!(
assert!(receiver.core.list_pending_pairings().is_empty()); sender
.core
.list_device_relationships()
.unwrap()
.iter()
.filter(|row| row.state == crate::DeviceRelationshipState::PendingOutgoing)
.count(),
1
);
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.iter()
.any(|row| row.state == crate::DeviceRelationshipState::PendingIncoming));
// Fabricated peer identity is rejected without growing pairing events. // Fabricated peer identity is rejected without growing pairing events.
let pairing_events_before = receiver let pairing_events_before = receiver
@@ -501,7 +519,12 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
vec![7u8; 32], vec![7u8; 32],
) )
.unwrap()); .unwrap());
assert!(receiver.core.list_pending_pairings().is_empty()); assert!(receiver
.core
.list_device_relationships()
.unwrap()
.iter()
.all(|row| row.remote_endpoint_id != "fabricated-endpoint"));
let pairing_events_after = receiver let pairing_events_after = receiver
.sink .sink
.events() .events()

View File

@@ -1,651 +0,0 @@
//! 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

@@ -1,252 +0,0 @@
//! 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

@@ -4618,250 +4618,6 @@
"ru": "Версия приложения" "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": { "pairing_request_title": {
"context": "Pairing prompt: title asking whether to remember a device that offered to be reachable.", "context": "Pairing prompt: title asking whether to remember a device that offered to be reachable.",
"translations": { "translations": {
@@ -5031,299 +4787,6 @@
"nl": "Weigeren", "nl": "Weigeren",
"ru": "Отклонить" "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} не ответил"
}
} }
} }
} }

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Teilen</string> <string name="transfer_share_title">Teilen</string>
<string name="value_unavailable">Nicht verfügbar</string> <string name="value_unavailable">Nicht verfügbar</string>
<string name="version_title">App-Version</string> <string name="version_title">App-Version</string>
<string name="contacts_title">Geräte</string>
<string name="contacts_subtitle">Geräte, mit denen Sie bereits Dateien ausgetauscht haben, können ohne neue Einladung Dateien empfangen.</string>
<string name="contacts_empty_title">Keine gespeicherten Geräte</string>
<string name="contacts_empty_body">Nach einer Übertragung können beide Geräte einander speichern.</string>
<string name="contacts_unreachable">Muss erneut gekoppelt werden</string>
<string name="contacts_unreachable_body">Dieses Gerät hat den Zugriff entzogen oder VniDrop neu installiert. Übertragen Sie erneut, um es wieder zu speichern.</string>
<string name="contacts_name_field">Name auf diesem Gerät</string>
<string name="contacts_name_hint">Nur Sie sehen diesen Namen. Das andere Gerät kann ihn nie ändern.</string>
<string name="contacts_forget">Gerät entfernen</string>
<string name="contacts_forget_body">Dieses Gerät kann Ihnen ohne neue Einladung keine Dateien mehr senden. Bereits empfangene Dateien bleiben erhalten.</string>
<string name="contacts_forget_all">Alle Geräte entfernen</string>
<string name="contacts_block">Gerät blockieren</string>
<string name="contacts_blocked_title">Blockierte Geräte</string>
<string name="contacts_unblock">Freigeben</string>
<string name="contacts_unblock_hint">Die Freigabe stellt den Zugriff nicht wieder her. Das Gerät muss erneut gekoppelt werden.</string>
<string name="contacts_send_to">Dateien senden</string>
<string name="contacts_last_transfer">Letzte Übertragung %1$s</string>
<string name="pairing_request_title">Dieses Gerät speichern?</string> <string name="pairing_request_title">Dieses Gerät speichern?</string>
<string name="pairing_request_body">%1$s bietet an, Dateien ohne neue Einladung von Ihnen zu empfangen.</string> <string name="pairing_request_body">%1$s bietet an, Dateien ohne neue Einladung von Ihnen zu empfangen.</string>
<string name="pairing_accept">Speichern</string> <string name="pairing_accept">Speichern</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string> <string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
<string name="offer_accept">Empfangen</string> <string name="offer_accept">Empfangen</string>
<string name="offer_decline">Ablehnen</string> <string name="offer_decline">Ablehnen</string>
<string name="contacts_grant_lifetime_title">Ungenutzte Geräte entfernen nach</string>
<string name="contacts_grant_lifetime_hint">Die Frist beginnt bei jeder Übertragung mit dem Gerät neu.</string>
<string name="contacts_grant_lifetime_never">Nie</string>
<string name="contacts_check_on_open">Nach wartenden Übertragungen suchen</string>
<string name="contacts_check_on_open_hint">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.</string>
<string name="contacts_check_now">Jetzt prüfen</string>
<string name="contacts_check_none">Nichts wartet</string>
<string name="contacts_offer_held">Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.</string>
<string name="contacts_waiting_title">Wartet auf Zustellung</string>
<string name="contacts_waiting_hint">Brechen Sie die Übertragung ab, um sie zurückzuziehen.</string>
<string name="contacts_send_to_device">An ein Gerät senden</string>
<string name="contacts_pick_device_title">Gerät auswählen</string>
<string name="contacts_pick_device_empty">Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.</string>
<string name="contacts_sent_to_device">%1$s hat die Übertragung angenommen</string>
<string name="contacts_declined_by_device">%1$s hat die Übertragung abgelehnt</string>
<string name="contacts_no_answer">%1$s hat nicht geantwortet</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d Datei</item> <item quantity="one">%1$d Datei</item>
<item quantity="other">%1$d Dateien</item> <item quantity="other">%1$d Dateien</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d Tag</item>
<item quantity="other">%1$d Tage</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Compartir</string> <string name="transfer_share_title">Compartir</string>
<string name="value_unavailable">No disponible</string> <string name="value_unavailable">No disponible</string>
<string name="version_title">Versión de la app</string> <string name="version_title">Versión de la app</string>
<string name="contacts_title">Dispositivos</string>
<string name="contacts_subtitle">Los dispositivos con los que ya has compartido pueden recibir archivos sin una nueva invitación.</string>
<string name="contacts_empty_title">Ningún dispositivo guardado</string>
<string name="contacts_empty_body">Tras una transferencia, ambos dispositivos pueden elegir recordarse mutuamente.</string>
<string name="contacts_unreachable">Requiere emparejar de nuevo</string>
<string name="contacts_unreachable_body">Este dispositivo retiró el acceso o reinstaló VniDrop. Realiza otra transferencia para volver a recordarlo.</string>
<string name="contacts_name_field">Nombre en este dispositivo</string>
<string name="contacts_name_hint">Solo tú ves este nombre. El otro dispositivo nunca puede cambiarlo.</string>
<string name="contacts_forget">Olvidar dispositivo</string>
<string name="contacts_forget_body">Este dispositivo ya no podrá enviarte archivos sin una nueva invitación. Los archivos ya recibidos se conservan.</string>
<string name="contacts_forget_all">Olvidar todos los dispositivos</string>
<string name="contacts_block">Bloquear dispositivo</string>
<string name="contacts_blocked_title">Dispositivos bloqueados</string>
<string name="contacts_unblock">Desbloquear</string>
<string name="contacts_unblock_hint">Desbloquear no restaura el acceso. Hay que emparejar el dispositivo de nuevo.</string>
<string name="contacts_send_to">Enviar archivos</string>
<string name="contacts_last_transfer">Última transferencia %1$s</string>
<string name="pairing_request_title">¿Recordar este dispositivo?</string> <string name="pairing_request_title">¿Recordar este dispositivo?</string>
<string name="pairing_request_body">%1$s te ofrece enviarle archivos sin una nueva invitación.</string> <string name="pairing_request_body">%1$s te ofrece enviarle archivos sin una nueva invitación.</string>
<string name="pairing_accept">Recordar</string> <string name="pairing_accept">Recordar</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s quiere enviarte «%2$s».</string> <string name="offer_body">%1$s quiere enviarte «%2$s».</string>
<string name="offer_accept">Recibir</string> <string name="offer_accept">Recibir</string>
<string name="offer_decline">Rechazar</string> <string name="offer_decline">Rechazar</string>
<string name="contacts_grant_lifetime_title">Olvidar dispositivos sin usar tras</string>
<string name="contacts_grant_lifetime_hint">La cuenta atrás se reinicia cada vez que transfieres con el dispositivo.</string>
<string name="contacts_grant_lifetime_never">Nunca</string>
<string name="contacts_check_on_open">Buscar transferencias en espera</string>
<string name="contacts_check_on_open_hint">Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación.</string>
<string name="contacts_check_now">Comprobar ahora</string>
<string name="contacts_check_none">Nada en espera</string>
<string name="contacts_offer_held">Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.</string>
<string name="contacts_waiting_title">Pendientes de entrega</string>
<string name="contacts_waiting_hint">Cancela la transferencia para retirarla.</string>
<string name="contacts_send_to_device">Enviar a un dispositivo</string>
<string name="contacts_pick_device_title">Elegir un dispositivo</string>
<string name="contacts_pick_device_empty">Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.</string>
<string name="contacts_sent_to_device">%1$s aceptó la transferencia</string>
<string name="contacts_declined_by_device">%1$s rechazó la transferencia</string>
<string name="contacts_no_answer">%1$s no respondió</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d archivo</item> <item quantity="one">%1$d archivo</item>
<item quantity="other">%1$d archivos</item> <item quantity="other">%1$d archivos</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d día</item>
<item quantity="other">%1$d días</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Partager</string> <string name="transfer_share_title">Partager</string>
<string name="value_unavailable">Non disponible</string> <string name="value_unavailable">Non disponible</string>
<string name="version_title">Version de lapp</string> <string name="version_title">Version de lapp</string>
<string name="contacts_title">Appareils</string>
<string name="contacts_subtitle">Les appareils avec lesquels vous avez déjà échangé peuvent recevoir des fichiers sans nouvelle invitation.</string>
<string name="contacts_empty_title">Aucun appareil enregistré</string>
<string name="contacts_empty_body">Après un transfert, les deux appareils peuvent choisir de se mémoriser mutuellement.</string>
<string name="contacts_unreachable">Nouvel appairage nécessaire</string>
<string name="contacts_unreachable_body">Cet appareil a retiré laccès ou a réinstallé VniDrop. Effectuez un nouveau transfert pour le mémoriser à nouveau.</string>
<string name="contacts_name_field">Nom sur cet appareil</string>
<string name="contacts_name_hint">Vous seul voyez ce nom. Lautre appareil ne peut jamais le modifier.</string>
<string name="contacts_forget">Oublier lappareil</string>
<string name="contacts_forget_body">Cet appareil ne pourra plus vous envoyer de fichiers sans nouvelle invitation. Les fichiers déjà reçus sont conservés.</string>
<string name="contacts_forget_all">Oublier tous les appareils</string>
<string name="contacts_block">Bloquer lappareil</string>
<string name="contacts_blocked_title">Appareils bloqués</string>
<string name="contacts_unblock">Débloquer</string>
<string name="contacts_unblock_hint">Le déblocage ne rétablit pas laccès. Lappareil doit être appairé à nouveau.</string>
<string name="contacts_send_to">Envoyer des fichiers</string>
<string name="contacts_last_transfer">Dernier transfert %1$s</string>
<string name="pairing_request_title">Mémoriser cet appareil ?</string> <string name="pairing_request_title">Mémoriser cet appareil ?</string>
<string name="pairing_request_body">%1$s propose de recevoir vos fichiers sans nouvelle invitation.</string> <string name="pairing_request_body">%1$s propose de recevoir vos fichiers sans nouvelle invitation.</string>
<string name="pairing_accept">Mémoriser</string> <string name="pairing_accept">Mémoriser</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string> <string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
<string name="offer_accept">Recevoir</string> <string name="offer_accept">Recevoir</string>
<string name="offer_decline">Refuser</string> <string name="offer_decline">Refuser</string>
<string name="contacts_grant_lifetime_title">Oublier les appareils inutilisés après</string>
<string name="contacts_grant_lifetime_hint">Le décompte redémarre à chaque transfert avec lappareil.</string>
<string name="contacts_grant_lifetime_never">Jamais</string>
<string name="contacts_check_on_open">Rechercher les transferts en attente</string>
<string name="contacts_check_on_open_hint">À 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.</string>
<string name="contacts_check_now">Vérifier maintenant</string>
<string name="contacts_check_none">Rien en attente</string>
<string name="contacts_offer_held">Cet appareil nest pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.</string>
<string name="contacts_waiting_title">En attente de remise</string>
<string name="contacts_waiting_hint">Annulez le transfert pour le retirer.</string>
<string name="contacts_send_to_device">Envoyer à un appareil</string>
<string name="contacts_pick_device_title">Choisir un appareil</string>
<string name="contacts_pick_device_empty">Aucun appareil nest joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.</string>
<string name="contacts_sent_to_device">%1$s a accepté le transfert</string>
<string name="contacts_declined_by_device">%1$s a refusé le transfert</string>
<string name="contacts_no_answer">%1$s na pas répondu</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d fichier</item> <item quantity="one">%1$d fichier</item>
<item quantity="other">%1$d fichiers</item> <item quantity="other">%1$d fichiers</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d jour</item>
<item quantity="other">%1$d jours</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Condividi</string> <string name="transfer_share_title">Condividi</string>
<string name="value_unavailable">Non disponibile</string> <string name="value_unavailable">Non disponibile</string>
<string name="version_title">Versione dellapp</string> <string name="version_title">Versione dellapp</string>
<string name="contacts_title">Dispositivi</string>
<string name="contacts_subtitle">I dispositivi con cui hai già scambiato file possono riceverne altri senza un nuovo invito.</string>
<string name="contacts_empty_title">Nessun dispositivo memorizzato</string>
<string name="contacts_empty_body">Dopo un trasferimento, entrambi i dispositivi possono scegliere di ricordarsi a vicenda.</string>
<string name="contacts_unreachable">Richiede un nuovo abbinamento</string>
<string name="contacts_unreachable_body">Questo dispositivo ha revocato laccesso o ha reinstallato VniDrop. Effettua un altro trasferimento per memorizzarlo di nuovo.</string>
<string name="contacts_name_field">Nome su questo dispositivo</string>
<string name="contacts_name_hint">Solo tu vedi questo nome. Laltro dispositivo non può mai modificarlo.</string>
<string name="contacts_forget">Dimentica dispositivo</string>
<string name="contacts_forget_body">Questo dispositivo non potrà più inviarti file senza un nuovo invito. I file già ricevuti vengono conservati.</string>
<string name="contacts_forget_all">Dimentica tutti i dispositivi</string>
<string name="contacts_block">Blocca dispositivo</string>
<string name="contacts_blocked_title">Dispositivi bloccati</string>
<string name="contacts_unblock">Sblocca</string>
<string name="contacts_unblock_hint">Sbloccare non ripristina laccesso. Il dispositivo deve essere abbinato di nuovo.</string>
<string name="contacts_send_to">Invia file</string>
<string name="contacts_last_transfer">Ultimo trasferimento %1$s</string>
<string name="pairing_request_title">Ricordare questo dispositivo?</string> <string name="pairing_request_title">Ricordare questo dispositivo?</string>
<string name="pairing_request_body">%1$s ti consente di inviargli file senza un nuovo invito.</string> <string name="pairing_request_body">%1$s ti consente di inviargli file senza un nuovo invito.</string>
<string name="pairing_accept">Ricorda</string> <string name="pairing_accept">Ricorda</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s vuole inviarti «%2$s».</string> <string name="offer_body">%1$s vuole inviarti «%2$s».</string>
<string name="offer_accept">Ricevi</string> <string name="offer_accept">Ricevi</string>
<string name="offer_decline">Rifiuta</string> <string name="offer_decline">Rifiuta</string>
<string name="contacts_grant_lifetime_title">Dimentica i dispositivi inutilizzati dopo</string>
<string name="contacts_grant_lifetime_hint">Il conteggio riparte a ogni trasferimento con il dispositivo.</string>
<string name="contacts_grant_lifetime_never">Mai</string>
<string name="contacts_check_on_open">Cerca trasferimenti in attesa</string>
<string name="contacts_check_on_open_hint">Allapertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri lapp.</string>
<string name="contacts_check_now">Controlla ora</string>
<string name="contacts_check_none">Nulla in attesa</string>
<string name="contacts_offer_held">Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.</string>
<string name="contacts_waiting_title">In attesa di consegna</string>
<string name="contacts_waiting_hint">Annulla il trasferimento per ritirarlo.</string>
<string name="contacts_send_to_device">Invia a un dispositivo</string>
<string name="contacts_pick_device_title">Scegli un dispositivo</string>
<string name="contacts_pick_device_empty">Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.</string>
<string name="contacts_sent_to_device">%1$s ha accettato il trasferimento</string>
<string name="contacts_declined_by_device">%1$s ha rifiutato il trasferimento</string>
<string name="contacts_no_answer">%1$s non ha risposto</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>
<item quantity="other">%1$d file</item> <item quantity="other">%1$d file</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d giorno</item>
<item quantity="other">%1$d giorni</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Delen</string> <string name="transfer_share_title">Delen</string>
<string name="value_unavailable">Niet beschikbaar</string> <string name="value_unavailable">Niet beschikbaar</string>
<string name="version_title">App-versie</string> <string name="version_title">App-versie</string>
<string name="contacts_title">Apparaten</string>
<string name="contacts_subtitle">Apparaten waarmee je al hebt overgedragen, kunnen bestanden ontvangen zonder nieuwe uitnodiging.</string>
<string name="contacts_empty_title">Geen onthouden apparaten</string>
<string name="contacts_empty_body">Na een overdracht kunnen beide apparaten elkaar onthouden.</string>
<string name="contacts_unreachable">Opnieuw koppelen vereist</string>
<string name="contacts_unreachable_body">Dit apparaat heeft de toegang ingetrokken of VniDrop opnieuw geïnstalleerd. Draag opnieuw over om het weer te onthouden.</string>
<string name="contacts_name_field">Naam op dit apparaat</string>
<string name="contacts_name_hint">Alleen jij ziet deze naam. Het andere apparaat kan die nooit wijzigen.</string>
<string name="contacts_forget">Apparaat vergeten</string>
<string name="contacts_forget_body">Dit apparaat kan je zonder nieuwe uitnodiging geen bestanden meer sturen. Reeds ontvangen bestanden blijven behouden.</string>
<string name="contacts_forget_all">Alle apparaten vergeten</string>
<string name="contacts_block">Apparaat blokkeren</string>
<string name="contacts_blocked_title">Geblokkeerde apparaten</string>
<string name="contacts_unblock">Deblokkeren</string>
<string name="contacts_unblock_hint">Deblokkeren herstelt de toegang niet. Het apparaat moet opnieuw worden gekoppeld.</string>
<string name="contacts_send_to">Bestanden sturen</string>
<string name="contacts_last_transfer">Laatste overdracht %1$s</string>
<string name="pairing_request_title">Dit apparaat onthouden?</string> <string name="pairing_request_title">Dit apparaat onthouden?</string>
<string name="pairing_request_body">%1$s biedt aan bestanden van je te ontvangen zonder nieuwe uitnodiging.</string> <string name="pairing_request_body">%1$s biedt aan bestanden van je te ontvangen zonder nieuwe uitnodiging.</string>
<string name="pairing_accept">Onthouden</string> <string name="pairing_accept">Onthouden</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s wil je “%2$s” sturen.</string> <string name="offer_body">%1$s wil je “%2$s” sturen.</string>
<string name="offer_accept">Ontvangen</string> <string name="offer_accept">Ontvangen</string>
<string name="offer_decline">Weigeren</string> <string name="offer_decline">Weigeren</string>
<string name="contacts_grant_lifetime_title">Ongebruikte apparaten vergeten na</string>
<string name="contacts_grant_lifetime_hint">De teller start opnieuw bij elke overdracht met het apparaat.</string>
<string name="contacts_grant_lifetime_never">Nooit</string>
<string name="contacts_check_on_open">Controleren op wachtende overdrachten</string>
<string name="contacts_check_on_open_hint">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.</string>
<string name="contacts_check_now">Nu controleren</string>
<string name="contacts_check_none">Niets in de wacht</string>
<string name="contacts_offer_held">Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.</string>
<string name="contacts_waiting_title">Wacht op bezorging</string>
<string name="contacts_waiting_hint">Annuleer de overdracht om die in te trekken.</string>
<string name="contacts_send_to_device">Naar een apparaat sturen</string>
<string name="contacts_pick_device_title">Kies een apparaat</string>
<string name="contacts_pick_device_empty">Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.</string>
<string name="contacts_sent_to_device">%1$s heeft de overdracht geaccepteerd</string>
<string name="contacts_declined_by_device">%1$s heeft de overdracht geweigerd</string>
<string name="contacts_no_answer">%1$s heeft niet geantwoord</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d bestand</item> <item quantity="one">%1$d bestand</item>
<item quantity="other">%1$d bestanden</item> <item quantity="other">%1$d bestanden</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d dag</item>
<item quantity="other">%1$d dagen</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Udostępnij</string> <string name="transfer_share_title">Udostępnij</string>
<string name="value_unavailable">Niedostępne</string> <string name="value_unavailable">Niedostępne</string>
<string name="version_title">Wersja aplikacji</string> <string name="version_title">Wersja aplikacji</string>
<string name="contacts_title">Urządzenia</string>
<string name="contacts_subtitle">Urządzenia, z którymi już przesyłano pliki, mogą je odbierać bez nowego zaproszenia.</string>
<string name="contacts_empty_title">Brak zapamiętanych urządzeń</string>
<string name="contacts_empty_body">Po przesłaniu plików oba urządzenia mogą zapamiętać się nawzajem.</string>
<string name="contacts_unreachable">Wymaga ponownego sparowania</string>
<string name="contacts_unreachable_body">To urządzenie cofnęło dostęp lub ponownie zainstalowało VniDrop. Wykonaj kolejne przesłanie, aby zapamiętać je ponownie.</string>
<string name="contacts_name_field">Nazwa na tym urządzeniu</string>
<string name="contacts_name_hint">Tylko Ty widzisz tę nazwę. Drugie urządzenie nigdy jej nie zmieni.</string>
<string name="contacts_forget">Zapomnij urządzenie</string>
<string name="contacts_forget_body">To urządzenie nie będzie mogło wysyłać Ci plików bez nowego zaproszenia. Już odebrane pliki pozostaną.</string>
<string name="contacts_forget_all">Zapomnij wszystkie urządzenia</string>
<string name="contacts_block">Zablokuj urządzenie</string>
<string name="contacts_blocked_title">Zablokowane urządzenia</string>
<string name="contacts_unblock">Odblokuj</string>
<string name="contacts_unblock_hint">Odblokowanie nie przywraca dostępu. Urządzenie trzeba sparować ponownie.</string>
<string name="contacts_send_to">Wyślij pliki</string>
<string name="contacts_last_transfer">Ostatnie przesłanie %1$s</string>
<string name="pairing_request_title">Zapamiętać to urządzenie?</string> <string name="pairing_request_title">Zapamiętać to urządzenie?</string>
<string name="pairing_request_body">%1$s umożliwia wysyłanie plików bez nowego zaproszenia.</string> <string name="pairing_request_body">%1$s umożliwia wysyłanie plików bez nowego zaproszenia.</string>
<string name="pairing_accept">Zapamiętaj</string> <string name="pairing_accept">Zapamiętaj</string>
@@ -334,32 +317,10 @@
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string> <string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
<string name="offer_accept">Odbierz</string> <string name="offer_accept">Odbierz</string>
<string name="offer_decline">Odrzuć</string> <string name="offer_decline">Odrzuć</string>
<string name="contacts_grant_lifetime_title">Zapomnij nieużywane urządzenia po</string>
<string name="contacts_grant_lifetime_hint">Odliczanie zaczyna się od nowa przy każdym przesłaniu.</string>
<string name="contacts_grant_lifetime_never">Nigdy</string>
<string name="contacts_check_on_open">Sprawdzaj oczekujące przesyłki</string>
<string name="contacts_check_on_open_hint">Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację.</string>
<string name="contacts_check_now">Sprawdź teraz</string>
<string name="contacts_check_none">Nic nie czeka</string>
<string name="contacts_offer_held">To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.</string>
<string name="contacts_waiting_title">Oczekuje na dostarczenie</string>
<string name="contacts_waiting_hint">Anuluj przesyłkę, aby ją wycofać.</string>
<string name="contacts_send_to_device">Wyślij do urządzenia</string>
<string name="contacts_pick_device_title">Wybierz urządzenie</string>
<string name="contacts_pick_device_empty">Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.</string>
<string name="contacts_sent_to_device">%1$s zaakceptowało przesyłkę</string>
<string name="contacts_declined_by_device">%1$s odrzuciło przesyłkę</string>
<string name="contacts_no_answer">%1$s nie odpowiedziało</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d plik</item> <item quantity="one">%1$d plik</item>
<item quantity="few">%1$d pliki</item> <item quantity="few">%1$d pliki</item>
<item quantity="many">%1$d plików</item> <item quantity="many">%1$d plików</item>
<item quantity="other">%1$d pliku</item> <item quantity="other">%1$d pliku</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d dzień</item>
<item quantity="few">%1$d dni</item>
<item quantity="many">%1$d dni</item>
<item quantity="other">%1$d dnia</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Partilhar</string> <string name="transfer_share_title">Partilhar</string>
<string name="value_unavailable">Indisponível</string> <string name="value_unavailable">Indisponível</string>
<string name="version_title">Versão da app</string> <string name="version_title">Versão da app</string>
<string name="contacts_title">Dispositivos</string>
<string name="contacts_subtitle">Os dispositivos com os quais já transferiu podem receber ficheiros sem um novo convite.</string>
<string name="contacts_empty_title">Nenhum dispositivo guardado</string>
<string name="contacts_empty_body">Após uma transferência, ambos os dispositivos podem optar por lembrar-se um do outro.</string>
<string name="contacts_unreachable">É preciso emparelhar novamente</string>
<string name="contacts_unreachable_body">Este dispositivo retirou o acesso ou reinstalou o VniDrop. Faça outra transferência para o voltar a guardar.</string>
<string name="contacts_name_field">Nome neste dispositivo</string>
<string name="contacts_name_hint">Só você vê este nome. O outro dispositivo nunca o pode alterar.</string>
<string name="contacts_forget">Esquecer dispositivo</string>
<string name="contacts_forget_body">Este dispositivo deixará de lhe poder enviar ficheiros sem um novo convite. Os ficheiros já recebidos são mantidos.</string>
<string name="contacts_forget_all">Esquecer todos os dispositivos</string>
<string name="contacts_block">Bloquear dispositivo</string>
<string name="contacts_blocked_title">Dispositivos bloqueados</string>
<string name="contacts_unblock">Desbloquear</string>
<string name="contacts_unblock_hint">Desbloquear não restaura o acesso. O dispositivo tem de ser emparelhado novamente.</string>
<string name="contacts_send_to">Enviar ficheiros</string>
<string name="contacts_last_transfer">Última transferência %1$s</string>
<string name="pairing_request_title">Lembrar este dispositivo?</string> <string name="pairing_request_title">Lembrar este dispositivo?</string>
<string name="pairing_request_body">%1$s ofereceu-se para receber ficheiros seus sem um novo convite.</string> <string name="pairing_request_body">%1$s ofereceu-se para receber ficheiros seus sem um novo convite.</string>
<string name="pairing_accept">Lembrar</string> <string name="pairing_accept">Lembrar</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string> <string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
<string name="offer_accept">Receber</string> <string name="offer_accept">Receber</string>
<string name="offer_decline">Recusar</string> <string name="offer_decline">Recusar</string>
<string name="contacts_grant_lifetime_title">Esquecer dispositivos não usados após</string>
<string name="contacts_grant_lifetime_hint">A contagem reinicia sempre que transfere com o dispositivo.</string>
<string name="contacts_grant_lifetime_never">Nunca</string>
<string name="contacts_check_on_open">Procurar transferências em espera</string>
<string name="contacts_check_on_open_hint">Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação.</string>
<string name="contacts_check_now">Verificar agora</string>
<string name="contacts_check_none">Nada em espera</string>
<string name="contacts_offer_held">Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.</string>
<string name="contacts_waiting_title">A aguardar entrega</string>
<string name="contacts_waiting_hint">Cancele a transferência para a retirar.</string>
<string name="contacts_send_to_device">Enviar para um dispositivo</string>
<string name="contacts_pick_device_title">Escolher um dispositivo</string>
<string name="contacts_pick_device_empty">Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.</string>
<string name="contacts_sent_to_device">%1$s aceitou a transferência</string>
<string name="contacts_declined_by_device">%1$s recusou a transferência</string>
<string name="contacts_no_answer">%1$s não respondeu</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d ficheiro</item> <item quantity="one">%1$d ficheiro</item>
<item quantity="other">%1$d ficheiros</item> <item quantity="other">%1$d ficheiros</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d dia</item>
<item quantity="other">%1$d dias</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Поделиться</string> <string name="transfer_share_title">Поделиться</string>
<string name="value_unavailable">Недоступно</string> <string name="value_unavailable">Недоступно</string>
<string name="version_title">Версия приложения</string> <string name="version_title">Версия приложения</string>
<string name="contacts_title">Устройства</string>
<string name="contacts_subtitle">Устройства, с которыми вы уже обменивались файлами, могут получать их без нового приглашения.</string>
<string name="contacts_empty_title">Нет сохранённых устройств</string>
<string name="contacts_empty_body">После передачи оба устройства могут запомнить друг друга.</string>
<string name="contacts_unreachable">Требуется повторное сопряжение</string>
<string name="contacts_unreachable_body">Это устройство отозвало доступ или переустановило VniDrop. Выполните новую передачу, чтобы снова его запомнить.</string>
<string name="contacts_name_field">Имя на этом устройстве</string>
<string name="contacts_name_hint">Это имя видите только вы. Другое устройство не может его изменить.</string>
<string name="contacts_forget">Забыть устройство</string>
<string name="contacts_forget_body">Это устройство больше не сможет отправлять вам файлы без нового приглашения. Уже полученные файлы сохранятся.</string>
<string name="contacts_forget_all">Забыть все устройства</string>
<string name="contacts_block">Заблокировать устройство</string>
<string name="contacts_blocked_title">Заблокированные устройства</string>
<string name="contacts_unblock">Разблокировать</string>
<string name="contacts_unblock_hint">Разблокировка не восстанавливает доступ. Устройство нужно сопрячь заново.</string>
<string name="contacts_send_to">Отправить файлы</string>
<string name="contacts_last_transfer">Последняя передача %1$s</string>
<string name="pairing_request_title">Запомнить это устройство?</string> <string name="pairing_request_title">Запомнить это устройство?</string>
<string name="pairing_request_body">%1$s разрешает отправлять файлы без нового приглашения.</string> <string name="pairing_request_body">%1$s разрешает отправлять файлы без нового приглашения.</string>
<string name="pairing_accept">Запомнить</string> <string name="pairing_accept">Запомнить</string>
@@ -334,32 +317,10 @@
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string> <string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
<string name="offer_accept">Получить</string> <string name="offer_accept">Получить</string>
<string name="offer_decline">Отклонить</string> <string name="offer_decline">Отклонить</string>
<string name="contacts_grant_lifetime_title">Забывать неиспользуемые устройства через</string>
<string name="contacts_grant_lifetime_hint">Отсчёт начинается заново при каждой передаче с устройством.</string>
<string name="contacts_grant_lifetime_never">Никогда</string>
<string name="contacts_check_on_open">Проверять ожидающие передачи</string>
<string name="contacts_check_on_open_hint">При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение.</string>
<string name="contacts_check_now">Проверить сейчас</string>
<string name="contacts_check_none">Ничего не ожидает</string>
<string name="contacts_offer_held">Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop.</string>
<string name="contacts_waiting_title">Ожидает доставки</string>
<string name="contacts_waiting_hint">Отмените передачу, чтобы отозвать её.</string>
<string name="contacts_send_to_device">Отправить на устройство</string>
<string name="contacts_pick_device_title">Выберите устройство</string>
<string name="contacts_pick_device_empty">Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи.</string>
<string name="contacts_sent_to_device">%1$s принял передачу</string>
<string name="contacts_declined_by_device">%1$s отклонил передачу</string>
<string name="contacts_no_answer">%1$s не ответил</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d файл</item> <item quantity="one">%1$d файл</item>
<item quantity="few">%1$d файла</item> <item quantity="few">%1$d файла</item>
<item quantity="many">%1$d файлов</item> <item quantity="many">%1$d файлов</item>
<item quantity="other">%1$d файла</item> <item quantity="other">%1$d файла</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d день</item>
<item quantity="few">%1$d дня</item>
<item quantity="many">%1$d дней</item>
<item quantity="other">%1$d дня</item>
</plurals>
</resources> </resources>

View File

@@ -306,23 +306,6 @@
<string name="transfer_share_title">Share</string> <string name="transfer_share_title">Share</string>
<string name="value_unavailable">Not available</string> <string name="value_unavailable">Not available</string>
<string name="version_title">App version</string> <string name="version_title">App version</string>
<string name="contacts_title">Devices</string>
<string name="contacts_subtitle">Devices you have transferred with can receive files without a new invitation.</string>
<string name="contacts_empty_title">No remembered devices</string>
<string name="contacts_empty_body">After a transfer, both devices can choose to remember each other.</string>
<string name="contacts_unreachable">Needs pairing again</string>
<string name="contacts_unreachable_body">This device withdrew access, or reinstalled VniDrop. Transfer to it once more to remember it again.</string>
<string name="contacts_name_field">Name on this device</string>
<string name="contacts_name_hint">Only you see this name. The other device can never change it.</string>
<string name="contacts_forget">Forget device</string>
<string name="contacts_forget_body">This device will no longer be able to send you files without a new invitation. Files already received are kept.</string>
<string name="contacts_forget_all">Forget all devices</string>
<string name="contacts_block">Block device</string>
<string name="contacts_blocked_title">Blocked devices</string>
<string name="contacts_unblock">Unblock</string>
<string name="contacts_unblock_hint">Unblocking does not restore access. The device has to be paired again.</string>
<string name="contacts_send_to">Send files</string>
<string name="contacts_last_transfer">Last transfer %1$s</string>
<string name="pairing_request_title">Remember this device?</string> <string name="pairing_request_title">Remember this device?</string>
<string name="pairing_request_body">%1$s offered to let you send it files without a new invitation.</string> <string name="pairing_request_body">%1$s offered to let you send it files without a new invitation.</string>
<string name="pairing_accept">Remember</string> <string name="pairing_accept">Remember</string>
@@ -334,28 +317,8 @@
<string name="offer_body">%1$s wants to send you “%2$s”.</string> <string name="offer_body">%1$s wants to send you “%2$s”.</string>
<string name="offer_accept">Receive</string> <string name="offer_accept">Receive</string>
<string name="offer_decline">Decline</string> <string name="offer_decline">Decline</string>
<string name="contacts_grant_lifetime_title">Forget unused devices after</string>
<string name="contacts_grant_lifetime_hint">The countdown restarts every time you transfer with the device.</string>
<string name="contacts_grant_lifetime_never">Never</string>
<string name="contacts_check_on_open">Check for waiting transfers</string>
<string name="contacts_check_on_open_hint">When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app.</string>
<string name="contacts_check_now">Check now</string>
<string name="contacts_check_none">Nothing waiting</string>
<string name="contacts_offer_held">That device is not open. The transfer will be delivered the next time it opens VniDrop.</string>
<string name="contacts_waiting_title">Waiting to be delivered</string>
<string name="contacts_waiting_hint">Cancel the transfer to withdraw it.</string>
<string name="contacts_send_to_device">Send to a device</string>
<string name="contacts_pick_device_title">Choose a device</string>
<string name="contacts_pick_device_empty">No device can be reached right now. Remembered devices appear here after a transfer.</string>
<string name="contacts_sent_to_device">%1$s accepted the transfer</string>
<string name="contacts_declined_by_device">%1$s declined the transfer</string>
<string name="contacts_no_answer">%1$s did not answer</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="other">%1$d files</item> <item quantity="other">%1$d files</item>
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>
</plurals> </plurals>
<plurals name="contacts_grant_lifetime_days">
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
</resources> </resources>