diff --git a/DESIGN-DEVICE-HISTORY.md b/DESIGN-DEVICE-HISTORY.md index 2bb0b47..29273eb 100644 --- a/DESIGN-DEVICE-HISTORY.md +++ b/DESIGN-DEVICE-HISTORY.md @@ -1,10 +1,11 @@ # 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 -currently on `feat/device-history` is an unreleased prototype. Its database and -wire formats are not compatibility commitments and may be replaced. +The unreleased contact/held-offer/polling prototype has been removed. The +implementation on this branch is the versioned saved-device, device-relationship, +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 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. - No regression in invitation-based multi-recipient transfers. -The unreleased `feat/device-history` schema, held offers, polling behavior, -expiring grants, `Contact` terminology, Apple-only feature UI, and ordinary- -share offer authorization are prototype artifacts. They may be removed without -a migration. Useful low-level cryptographic, repository, protocol, and test -patterns may be retained only after they are checked against this design. +The unreleased `feat/device-history` contact schema, held offers, polling +behavior, expiring grants, `Contact` terminology, Apple-only feature UI, and +ordinary-share offer authorization were prototype artifacts and have been +removed without a compatibility migration. Useful low-level cryptographic, +repository, protocol, and test patterns were retained only after they were +checked against this design. --- diff --git a/apple/Tests/ContactsModelTests.swift b/apple/Tests/ContactsModelTests.swift deleted file mode 100644 index 51680b4..0000000 --- a/apple/Tests/ContactsModelTests.swift +++ /dev/null @@ -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) - } -} diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index 11a9388..5c3f515 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -81,98 +81,6 @@ final class FakeCoreGateway: CoreGateway { return responseResult } func refresh() async -> Result { .success(()) } - - // MARK: Device history - - var contactsResult: Result<[DeviceContact], Error> = .success([]) - var pairings: [PendingPairingModel] = [] - var offers: [IncomingOfferModel] = [] - var respondToPairingResult: Result = .success(true) - /// Ticket handed back when an offer is accepted; nil models a declined one. - var offerTicket: String? = "vnd1:offered" - var sendToContactResult: Result = .failure(TestError.unimplemented) - var heldOffersResult: Result<[HeldOfferModel], Error> = .success([]) - var pollResult: Result = .success(0) - private(set) var pollCount = 0 - var forgetContactResult: Result = .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 { - allowedDevices.append((endpointId, displayName)) - return .success(()) - } - func respondToPairing(endpointId: String, accepted: Bool) async -> Result { - 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 { - sentToContacts.append(endpointId) - return sendToContactResult - } - private(set) var offeredTransfers: [(transferId: UInt64, endpointId: String)] = [] - var offerTransferResult: Result = .failure(TestError.unimplemented) - - func offerTransferToContact( - transferId: UInt64, - endpointId: String - ) async -> Result { - offeredTransfers.append((transferId, endpointId)) - return offerTransferResult - } - func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult } - func pollContactsForOffers() async -> Result { - pollCount += 1 - return pollResult - } - func forgetContact(endpointId: String) async -> Result { - forgottenContacts.append(endpointId) - return forgetContactResult - } - func forgetAllContacts() async -> Result { - forgetAllCount += 1 - return .success(0) - } - func blockContact(endpointId: String) async -> Result { - blockedContactIds.append(endpointId) - return .success(()) - } - func unblockContact(endpointId: String) async -> Result { - unblockedContactIds.append(endpointId) - return .success(()) - } - func blockedContacts() async -> Result<[String], Error> { blockedResult } - func setContactLabel(endpointId: String, label: String?) async -> Result { - contactLabels.append((endpointId, label)) - return .success(()) - } - func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { grantLifetimes.append(lifetime) } } /// Minimal `FileSystemService` fake — a writable path receive folder, no reveal. @@ -186,19 +94,14 @@ final class FakeFileSystemService: FileSystemService { func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } private(set) var shareDestinations: [ShareDestination] = [] - func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result { + func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result { shareDestinations.append(destination) - switch destination { - case .invitation(let accessPolicy): - return await repository.shareSources( - [], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy - ) - .map { ContactSendOutcome(share: $0, delivered: true) } - case .contact(let endpointId): - return await repository.sendToContact( - endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName - ) + guard case .invitation(let accessPolicy) = destination else { + return .failure(TestError.unimplemented) } + return await repository.shareSources( + [], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) } } diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift index b018ecb..bc41554 100644 --- a/apple/VniDrop/App/AppGraph.swift +++ b/apple/VniDrop/App/AppGraph.swift @@ -12,7 +12,6 @@ final class AppGraph: ObservableObject { let preferencesRepository: AppPreferencesRepository let filePreviewRepository: FilePreviewRepository let approvalCoordinator: ApprovalCoordinator - let contactsModel: ContactsModel let transferNotificationCoordinator: TransferNotificationCoordinator let backgroundActivity: BackgroundActivityController @@ -28,12 +27,6 @@ final class AppGraph: ObservableObject { themeMode: .system ) ) - self.contactsModel = ContactsModel( - repository: coreRepository, - messages: messages, - preferences: preferencesRepository, - fileSystemService: dependencies.fileSystemService - ) self.approvalCoordinator = ApprovalCoordinator( repository: coreRepository, notifications: dependencies.notificationService, diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index a1de9ca..485cb84 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -60,11 +60,6 @@ struct RootView: View { approvals: graph.approvalCoordinator, sendModel: sendModel ) - ContactPromptLayer( - contacts: graph.contactsModel, - receiveModel: receiveModel, - approvals: graph.approvalCoordinator - ) // Top-most so the toast is never covered by the approval overlay's // full-bleed clear layer. Observes the live `graph.messages` directly. SnackbarHost(controller: graph.messages) @@ -93,9 +88,6 @@ struct RootView: View { // unfocused/occluded (common on macOS) live events may not have // rendered, leaving progress/status stale. Task { _ = await graph.coreRepository.refresh() } - // Opt-in and foreground-only: collecting transfers held for this - // device also tells every contact that the app was opened. - Task { await graph.contactsModel.checkForOffersOnForeground() } case .background: graph.visibility.setForeground(false) // Hold the process open for iOS's grace window so an active @@ -173,10 +165,10 @@ struct RootView: View { @ViewBuilder private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View { 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 .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 /// asking to reach it. Both are suppressed while the other is up so the user is /// never answering two modals at once. -private struct ContactPromptLayer: View { - @ObservedObject var contacts: ContactsModel - let receiveModel: ReceiveModel - @ObservedObject var approvals: ApprovalCoordinator - - @State private var showPrompt = false - - var body: some View { - ContactPromptHost( - isPresented: $showPrompt, - state: contacts.state, - onPairingResponse: { endpointId, accepted in - Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) } - }, - onOfferResponse: { offerId, accepted in - Task { - // The ticket is released only on acceptance; the receive then - // runs through the ordinary path so the platform picks the - // destination. - if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) { - receiveModel.receiveOffered(ticket: ticket) - } - } - }, - onSuggestionResponse: { suggestion, accepted in - if accepted { - Task { await contacts.acceptSuggestion(suggestion) } - } else { - contacts.declineSuggestion(suggestion) - } - } - ) - .onChange(of: promptKey) { _, key in - showPrompt = key != nil - } - } - - /// One identity for "is there something to answer", so an offer replacing a - /// pairing prompt re-presents rather than silently swapping content. - private var promptKey: String? { - guard approvals.state.current == nil else { return nil } - if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" } - if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" } - if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" } - return nil - } -} diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index bef80db..e597645 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -122,14 +122,6 @@ struct AppPreferences: Equatable { var themeMode: ThemeMode var diagnosticsInstallId: String var relayConfiguration: RelayConfiguration - /// Idle lifetime applied to grants this device issues from now on. - var grantLifetime: GrantLifetimeOption - /// Devices the user declined to remember. Persisted so a repeat transfer - /// with the same device does not re-ask forever. - var declinedPairingSuggestions: Set - /// 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 { @@ -153,10 +145,7 @@ final class AppPreferencesRepository: ObservableObject { static let themeMode = "theme_mode" static let diagnosticsInstallId = "diagnostics_install_id" static let relayConfiguration = "relay_configuration" - static let grantLifetime = "grant_lifetime" - static let declinedPairingSuggestions = "declined_pairing_suggestions" - static let checkForOffersOnOpen = "check_for_offers_on_open" - } + } init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { self.defaults = defaults @@ -169,18 +158,12 @@ final class AppPreferencesRepository: ObservableObject { let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" - let grantLifetime = defaults.string(forKey: Key.grantLifetime) - .flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90 - let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? []) return AppPreferences( username: username, receiveFolder: folder, themeMode: themeMode, diagnosticsInstallId: installId, - relayConfiguration: resolveRelayConfiguration(defaults), - grantLifetime: grantLifetime, - declinedPairingSuggestions: declined, - checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen) + relayConfiguration: resolveRelayConfiguration(defaults) ) } @@ -226,32 +209,6 @@ final class AppPreferencesRepository: ObservableObject { 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) { defaults.set(mode.rawValue, forKey: Key.themeMode) reload() diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index f7ee8e8..5c57763 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -47,42 +47,3 @@ protocol CoreGateway: AnyObject { func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result func refresh() async -> Result - - // 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 - /// Accept or decline a device's offer to be remembered. - func respondToPairing(endpointId: String, accepted: Bool) async -> Result - /// 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 - /// Offer an existing share to a remembered device, alongside its QR code. - func offerTransferToContact( - transferId: UInt64, - endpointId: String - ) async -> Result - /// 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 - func forgetContact(endpointId: String) async -> Result - func forgetAllContacts() async -> Result - func blockContact(endpointId: String) async -> Result - func unblockContact(endpointId: String) async -> Result - func blockedContacts() async -> Result<[String], Error> - func setContactLabel(endpointId: String, label: String?) async -> Result - func setGrantLifetime(_ lifetime: GrantLifetimeOption) async -} diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 33c412b..a95ea51 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -74,13 +74,8 @@ enum ShareAccessPolicy: Equatable, Sendable { } /// Where a picked selection is going. -/// -/// A contact destination deliberately carries no access policy: the core forces -/// approval-required for offers, so exposing the choice here would imply a -/// setting that does not exist. enum ShareDestination: Equatable, Sendable { case invitation(accessPolicy: ShareAccessPolicy) - case contact(endpointId: String) } enum TransferDirection: Equatable, Sendable { @@ -179,10 +174,6 @@ enum CoreSignal: Equatable, Sendable { case receiverHistoryChanged(transferId: UInt64) /// Transfer status/history changed enough to re-read the durable snapshot. case transfersChanged(transferId: UInt64) - /// Device history changed: a contact was added, forgotten, or blocked. - case contactsChanged - /// An incoming offer arrived or was answered. - case offersChanged } // MARK: - Transfer helpers (ported from AppUiModels.kt) @@ -201,112 +192,3 @@ extension TransferStatus { self == .done || self == .failed || self == .cancelled } } - -// MARK: - Device history - -/// A device the user has chosen to remember. -/// -/// `localLabel` is the user's own name for the device and is authoritative for -/// display; `remoteDisplayName` is whatever the device last called itself and is -/// untrusted. The endpoint id is the only real identity. -struct DeviceContact: Equatable, Identifiable, Sendable { - let endpointId: String - let localLabel: String? - let remoteDisplayName: String? - let lastTransferAt: Int64? - let createdAt: Int64 - /// Whether a live grant is held. False once the peer revoked, the grant - /// lapsed, or the peer reinstalled and lost its identity. - let canSend: Bool - - var id: String { endpointId } - - /// Name to show, preferring the local label the peer cannot influence. - var displayName: String { - if let localLabel, !localLabel.isEmpty { return localLabel } - if let remoteDisplayName, !remoteDisplayName.isEmpty { return remoteDisplayName } - return String(localized: L10n.Approval.nearbyDevice) - } - - /// Short prefix of the endpoint id, for telling apart devices claiming the - /// same name. - var shortFingerprint: String { String(endpointId.prefix(8)) } -} - -/// A device offering to be remembered, awaiting this user's decision. -struct PendingPairingModel: Equatable, Identifiable, Sendable { - let endpointId: String - let displayName: String? - let receivedAt: Int64 - - var id: String { endpointId } - - var resolvedName: String { - guard let displayName, !displayName.isEmpty else { - return String(localized: L10n.Approval.nearbyDevice) - } - return displayName - } -} - -/// A transfer a remembered device is offering. Carries no ticket: that is a -/// capability and the core releases it only once the user accepts. -struct IncomingOfferModel: Equatable, Identifiable, Sendable { - let offerId: String - let fromEndpointId: String - let senderDisplayName: String? - let transferName: String - let fileCount: UInt64 - let totalBytes: UInt64 - let receivedAt: Int64 - - var id: String { offerId } - - var resolvedSenderName: String { - guard let senderDisplayName, !senderDisplayName.isEmpty else { - return String(localized: L10n.Approval.nearbyDevice) - } - return senderDisplayName - } -} - -/// A transfer waiting for its target device to come back online. -struct HeldOfferModel: Equatable, Identifiable, Sendable { - let offerId: String - let endpointId: String - let transferId: UInt64 - let transferName: String - let fileCount: UInt64 - let totalBytes: UInt64 - let createdAt: Int64 - - var id: String { offerId } -} - -/// Outcome of sending straight to a remembered device. -struct ContactSendOutcome: Equatable, Sendable { - let share: Share - /// False when the device was not running: the transfer is held locally and - /// collected the next time that device opens the app. - let delivered: Bool -} - -/// How long a remembered device stays reachable while unused. The countdown -/// restarts on every transfer. -enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable { - case days30 - case days90 - case days365 - case never - - var id: String { rawValue } - - var days: Int? { - switch self { - case .days30: return 30 - case .days90: return 90 - case .days365: return 365 - case .never: return nil - } - } -} diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index fa4effc..2093b45 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -293,123 +293,6 @@ final class CoreRepository: ObservableObject, CoreGateway { 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 { - await runCore { - try self.requireCore().allowDeviceToReachMe(endpointId: endpointId, displayName: displayName) - } - } - - func respondToPairing(endpointId: String, accepted: Bool) async -> Result { - 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 { - 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 { - 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 { - await runCore { try self.requireCore().pollContactsForOffers() } - } - - func forgetContact(endpointId: String) async -> Result { - await runCore { try self.requireCore().forgetContact(endpointId: endpointId) } - } - - func forgetAllContacts() async -> Result { - await runCore { try self.requireCore().forgetAllContacts() } - } - - func blockContact(endpointId: String) async -> Result { - await runCore { try self.requireCore().blockContact(endpointId: endpointId) } - } - - func unblockContact(endpointId: String) async -> Result { - 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 { - await runCore { - try self.requireCore().setContactLabel(endpointId: endpointId, label: label) - } - } - - func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { - _ = await runCore { try self.requireCore().setGrantLifetime(lifetime: lifetime.toNative()) } - } - // MARK: - Event sink handling (ported from CoreRepository.sink) private func handle(event: CoreEvent) { @@ -419,14 +302,6 @@ final class CoreRepository: ObservableObject, CoreGateway { if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) } state.events = events - // Contacts and offers are endpoint-scoped: they carry no transfer id, so - // they are dispatched before the transfer-scoped handling below. - switch model.phase { - case "contacts": signalsSubject.send(.contactsChanged) - case "offer": signalsSubject.send(.offersChanged) - default: break - } - guard let transferId = model.transferId else { return } switch model.phase { case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId)) @@ -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 - } - } -} diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index 35d3f58..d6e1af7 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -42,7 +42,7 @@ protocol FileSystemService { transferName: String, senderName: String, destination: ShareDestination - ) async -> Result + ) async -> Result } extension FileSystemService { diff --git a/apple/VniDrop/Features/Contacts/ContactPrompts.swift b/apple/VniDrop/Features/Contacts/ContactPrompts.swift deleted file mode 100644 index 09fc280..0000000 --- a/apple/VniDrop/Features/Contacts/ContactPrompts.swift +++ /dev/null @@ -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) - } -} diff --git a/apple/VniDrop/Features/Contacts/ContactsModel.swift b/apple/VniDrop/Features/Contacts/ContactsModel.swift deleted file mode 100644 index 5f2a147..0000000 --- a/apple/VniDrop/Features/Contacts/ContactsModel.swift +++ /dev/null @@ -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 = [] - var busyOfferIds: Set = [] - 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() - - 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) } - } -} diff --git a/apple/VniDrop/Features/Contacts/ContactsScreen.swift b/apple/VniDrop/Features/Contacts/ContactsScreen.swift deleted file mode 100644 index 535f0fb..0000000 --- a/apple/VniDrop/Features/Contacts/ContactsScreen.swift +++ /dev/null @@ -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) } - } -} diff --git a/apple/VniDrop/Features/Contacts/DevicePickerSheet.swift b/apple/VniDrop/Features/Contacts/DevicePickerSheet.swift deleted file mode 100644 index 1d439f4..0000000 --- a/apple/VniDrop/Features/Contacts/DevicePickerSheet.swift +++ /dev/null @@ -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 - } -} diff --git a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift index da10bd2..22e5b62 100644 --- a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift +++ b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift @@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject { switch signal { case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId): Task { await self.syncReceivers(transferId: transferId) } - case .approvalChanged, .contactsChanged, .offersChanged: + case .approvalChanged, .transfersChanged: break } } diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index b5b5279..10400a3 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -96,8 +96,6 @@ final class SendModel: ObservableObject { case .receiverHistoryChanged(let id), .approvalChanged(let id): if id == self.state.selectedTransferId { self.refreshReceivers(id) } self.refreshReceiverStatuses(for: id) - case .contactsChanged, .offersChanged: - break } } .store(in: &cancellables) diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index b9bb251..f596ed5 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -5,7 +5,6 @@ import SFSafeSymbols /// with the composer and detail panels as native sheets and delete as an alert. struct SendScreen: View { @ObservedObject var model: SendModel - @ObservedObject var contacts: ContactsModel let windowClass: WindowClass /// Transfer pending an inline (list-level) delete confirmation. @@ -61,7 +60,7 @@ struct SendScreen: View { onDismissed: model.shareSheetDidDismiss ) { 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 /// modals from the parent stack while a detail is pushed is unreliable on macOS). private func detailView(for transfer: Transfer) -> some View { - TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events) + TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events) .adaptiveDrawer( isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }), windowClass: windowClass, @@ -102,7 +101,7 @@ struct SendScreen: View { onDismissed: model.shareSheetDidDismiss ) { if let panel = model.state.detailPanel { - DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel) + DetailPanelContent(model: model, transfer: transfer, panel: panel) } } .alert( diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index a655858..a65dd10 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -6,7 +6,6 @@ import CoreImage.CIFilterBuiltins struct TransferDetailsView: View { @ObservedObject var model: SendModel - @ObservedObject var contacts: ContactsModel let transfer: Transfer let events: [CoreEventModel] @State private var showStopConfirmation = false @@ -130,7 +129,6 @@ private struct DetailDestination: View { struct DetailPanelContent: View { @ObservedObject var model: SendModel - @ObservedObject var contacts: ContactsModel let transfer: Transfer let panel: TransferDetailPanel @@ -148,7 +146,7 @@ struct DetailPanelContent: View { onAccept: model.acceptReceiver ) 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 { @Environment(\.vniColors) private var colors @ObservedObject var model: SendModel - @ObservedObject var contacts: ContactsModel let transfer: Transfer var body: some View { @@ -312,7 +309,7 @@ struct TransferSharePanel: View { .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) .frame(maxWidth: .infinity) } - ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket) + ShareActionsView(model: model, transfer: transfer, ticket: ticket) case .preparing: Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter) case .unavailable: diff --git a/apple/VniDrop/Features/Send/TransferShareActions.swift b/apple/VniDrop/Features/Send/TransferShareActions.swift index 7733942..325f342 100644 --- a/apple/VniDrop/Features/Send/TransferShareActions.swift +++ b/apple/VniDrop/Features/Send/TransferShareActions.swift @@ -20,25 +20,14 @@ protocol TransferShareActions: AnyObject { struct ShareActionsView: View { @Environment(\.vniColors) private var colors @ObservedObject var model: SendModel - @ObservedObject var contacts: ContactsModel let transfer: Transfer let ticket: String @State private var actions: TransferShareActions = makePlatformShareActions() @State private var writingNfc = false - @State private var choosingDevice = false var body: some View { VStack(spacing: 12) { - // Sending straight to a remembered device is another way to deliver - // this same invitation, so it belongs with the other delivery - // methods rather than in a separate flow. - if contacts.state.contacts.contains(where: \.canSend) { - SecondaryButton( - title: String(localized: L10n.Contacts.sendToDevice), - action: { choosingDevice = true } - ) - } if actions.nfcAvailability != .hidden { SecondaryButton( title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc), @@ -68,8 +57,5 @@ struct ShareActionsView: View { }, enabled: actions.canUseNativeShare) } .onDisappear { actions.cancelNfcWrite() } - .sheet(isPresented: $choosingDevice) { - DevicePickerSheet(model: contacts, transferId: transfer.transferId) - } } } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 7053a08..0c0e39f 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -8,10 +8,6 @@ enum SettingsSection: Hashable { case appearance case notifications case network - case contacts - /// One device's detail. Part of this enum because the Settings stack has a - /// typed path: a link carrying any other value type cannot push onto it. - case contactDetail(endpointId: String) case storage case about case bugReport @@ -23,7 +19,6 @@ enum SettingsSection: Hashable { case .appearance: return L10n.Appearance.title case .notifications: return L10n.Notifications.title case .network: return L10n.Settings.networkTitle - case .contacts, .contactDetail: return L10n.Contacts.title case .storage: return L10n.Storage.title case .about: return L10n.About.title case .bugReport: return L10n.About.bugReport @@ -179,11 +174,6 @@ final class SettingsModel: ObservableObject { loadDeviceInfo() } - /// Surfaces "nothing waiting" from the contacts screen, which has no - /// message controller of its own. - func reportNothingWaiting() { - messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info)) - } func selectSection(_ section: SettingsSection) { state.selectedSection = section diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index 6796771..7c91faa 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -5,7 +5,6 @@ import SFSafeSymbols /// navigation. The model stays the source of truth via a derived path binding. struct SettingsScreen: View { @ObservedObject var model: SettingsModel - @ObservedObject var contacts: ContactsModel let windowClass: WindowClass @State private var showBugReport = false @@ -15,8 +14,6 @@ struct SettingsScreen: View { switch model.state.selectedSection { case .overview: return [] case .bugReport: return [.about, .bugReport] - case .contactDetail(let endpointId): - return [.contacts, .contactDetail(endpointId: endpointId)] case let section: return [section] } }, @@ -57,15 +54,6 @@ struct SettingsScreen: View { NavigationLink(value: SettingsSection.storage) { SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil) } - NavigationLink(value: SettingsSection.contacts) { - SettingsRow( - icon: .macbookAndIphone, - title: String(localized: L10n.Contacts.title), - value: contacts.state.contacts.isEmpty - ? nil - : String(contacts.state.contacts.count) - ) - } } Section(String(localized: L10n.Settings.advancedTitle)) { NavigationLink(value: SettingsSection.network) { @@ -92,17 +80,7 @@ struct SettingsScreen: View { @ViewBuilder private func sectionForm(_ section: SettingsSection) -> some View { - // Contacts brings its own Form and push destination, so it is not wrapped - // in the shared section chrome. - if case .contactDetail(let endpointId) = section { - ContactDetailScreen(model: contacts, endpointId: endpointId) - } else if section == .contacts { - ContactsScreen(model: contacts) { - model.reportNothingWaiting() - } - } else { - settingsSectionForm(section) - } + settingsSectionForm(section) } @ViewBuilder @@ -157,9 +135,6 @@ private struct SettingsSectionContent: View { NetworkSettings(model: model) case .storage: StorageSettings(model: model) - case .contacts, .contactDetail: - // Rendered by SettingsScreen itself, which owns the contacts model. - EmptyView() case .about: AboutSettings(model: model) case .bugReport: diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index efadde5..31b8834 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -60,23 +60,17 @@ struct IosFileSystemService: FileSystemService { transferName: String, senderName: String, destination: ShareDestination - ) async -> Result { + ) async -> Result { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) } let sources = files.map { $0.toIosShareSource() } - switch destination { - case .invitation(let accessPolicy): - return await repository.shareSources( - sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy - ) - .map { ContactSendOutcome(share: $0, delivered: true) } - case .contact(let endpointId): - return await repository.sendToContact( - endpointId: endpointId, sources: sources, - transferName: transferName, senderName: senderName - ) + guard case .invitation(let accessPolicy) = destination else { + return .failure(InvitationError.unsupportedOperation) } + return await repository.shareSources( + sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) } private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus { diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index 26aea47..e445d47 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService { transferName: String, senderName: String, destination: ShareDestination - ) async -> Result { + ) async -> Result { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) } @@ -63,18 +63,12 @@ struct MacFileSystemService: FileSystemService { let sources = files.map { ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) } - switch destination { - case .invitation(let accessPolicy): - return await repository.shareSources( - sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy - ) - .map { ContactSendOutcome(share: $0, delivered: true) } - case .contact(let endpointId): - return await repository.sendToContact( - endpointId: endpointId, sources: sources, - transferName: transferName, senderName: senderName - ) + guard case .invitation(let accessPolicy) = destination else { + return .failure(InvitationError.unsupportedOperation) } + return await repository.shareSources( + sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) } } #endif diff --git a/apple/VniDrop/Platform/PlatformPickers.swift b/apple/VniDrop/Platform/PlatformPickers.swift index 69589c5..a6bdca0 100644 --- a/apple/VniDrop/Platform/PlatformPickers.swift +++ b/apple/VniDrop/Platform/PlatformPickers.swift @@ -72,30 +72,6 @@ struct SendPickers: ViewModifier { /// File picker for "send to this device", reusing the share picker's selection /// handling so security-scoped bookmarks are captured the same way. -struct ContactSendPickers: ViewModifier { - @ObservedObject var model: ContactsModel - - func body(content: Content) -> some View { - content - .fileImporter( - isPresented: $model.pendingFilePick, - allowedContentTypes: [.item], - allowsMultipleSelection: true - ) { result in - switch result { - case .success(let urls): - let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) } - if files.isEmpty { - model.onFilePickFailed("The selected document could not be opened") - } else { - Task { await model.onFilesPicked(files) } - } - case .failure(let error): - if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) } - } - } - } -} enum PickerSupport { static func receiveFolder(from url: URL) -> ReceiveFolder { @@ -157,7 +133,4 @@ extension View { modifier(SendPickers(model: model)) } - func contactSendPickers(model: ContactsModel) -> some View { - modifier(ContactSendPickers(model: model)) - } } diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index b68e9c9..b47aa95 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -595,80 +595,6 @@ pub struct TicketInspection { pub metadata: TransferMetadata, } -/// A device the user has chosen to remember. -/// -/// Deliberately carries no grant material: capabilities never cross the UniFFI -/// boundary, only the fact that one exists (`can_send`). -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct ContactSummary { - pub endpoint_id: String, - /// Set locally by the user. Authoritative for display. - pub local_label: Option, - /// Last name the device claimed. Untrusted; never promoted to the label. - pub remote_display_name: Option, - pub last_transfer_at: Option, - 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, - 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, - pub received_at: i64, -} - -/// How long a grant survives without use, renewed on every accepted proof. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] -pub enum GrantLifetimeSetting { - Days30, - #[default] - Days90, - Days365, - Never, -} - #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct ReceiverRequest { pub id: String, diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index 8407e5e..f9b8346 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -180,7 +180,7 @@ impl ApprovalService { ) -> HandshakeResponse { if self .repository - .contacts() + .blocked_devices() .is_blocked(&remote_endpoint_id) .await .unwrap_or(true) diff --git a/crates/vnidrop/src/blocked_devices.rs b/crates/vnidrop/src/blocked_devices.rs new file mode 100644 index 0000000..d043664 --- /dev/null +++ b/crates/vnidrop/src/blocked_devices.rs @@ -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 { + 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::(0) == 1) + } + + pub(crate) async fn list_blocked(&self) -> Result> { + 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()) + } +} diff --git a/crates/vnidrop/src/contacts.rs b/crates/vnidrop/src/contacts.rs deleted file mode 100644 index c91c073..0000000 --- a/crates/vnidrop/src/contacts.rs +++ /dev/null @@ -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, - /// Last name the remote sent. Untrusted display data. - pub(crate) remote_display_name: Option, - /// 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, - pub(crate) created_at: i64, - pub(crate) last_transfer_at: Option, -} - -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, - 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> { - 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> { - 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> { - 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> { - 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> { - 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, - ) -> 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> { - 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> { - 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::(0).as_str())) - .collect() - } - - async fn all_issued_grant_ids(&self) -> Result> { - let rows = sqlx::query("SELECT grant_id FROM grants_issued") - .fetch_all(&self.pool) - .await?; - rows.into_iter() - .map(|row| GrantId::decode(row.get::(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> { - 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 { - 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::(0) == 1) - } - - pub(crate) async fn list_blocked(&self) -> Result> { - 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> { - 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> { - 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 { - 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::(2) as u64, - ticket: row.get(3), - transfer_name: row.get(4), - sender_display_name: row.get(5), - file_count: row.get::(6) as u64, - total_bytes: row.get::(7) as u64, - created_at: row.get(8), - } -} - -fn row_to_issued_grant(row: sqlx::sqlite::SqliteRow) -> Result { - let grant_id = GrantId::decode(row.get::(0).as_str())?; - let secret = parse_secret(row.get::(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 { - let grant_id = GrantId::decode(row.get::(0).as_str())?; - let secret = parse_secret(row.get::(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), - }) -} diff --git a/crates/vnidrop/src/device_relationship/lifecycle.rs b/crates/vnidrop/src/device_relationship/lifecycle.rs index 57cfcd7..25fe4fc 100644 --- a/crates/vnidrop/src/device_relationship/lifecycle.rs +++ b/crates/vnidrop/src/device_relationship/lifecycle.rs @@ -60,6 +60,7 @@ impl DeviceRelationshipService { let _guard = peer_lock.lock().await; let Some(row) = self.find_row(&peer_endpoint_id).await? else { + self.eligibility.remove_for_peer(&peer_endpoint_id).await?; return Ok(ForgetOutcome { had_relationship: false, generation: None, diff --git a/crates/vnidrop/src/device_relationship/mod.rs b/crates/vnidrop/src/device_relationship/mod.rs index d728b6b..96eb63c 100644 --- a/crates/vnidrop/src/device_relationship/mod.rs +++ b/crates/vnidrop/src/device_relationship/mod.rs @@ -39,7 +39,7 @@ mod lifecycle; #[cfg(test)] pub(crate) use lifecycle::GenerationTombstone; -use crate::contacts::ContactStore; +use crate::blocked_devices::BlockStore; use crypto::{ encode_relationship_grant_secret, prove_relationship_grant, secret_from_material, verify_relationship_grant, @@ -171,13 +171,13 @@ impl DeviceRelationshipService { Ok(()) } - pub(super) fn contacts(&self) -> ContactStore { - ContactStore::new(self.pool.clone()) + pub(super) fn blocked_devices(&self) -> BlockStore { + BlockStore::new(self.pool.clone()) } pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool { // Fail closed: a store error must not admit blocked traffic. - self.contacts() + self.blocked_devices() .is_blocked(endpoint_id) .await .unwrap_or(true) diff --git a/crates/vnidrop/src/event_hub.rs b/crates/vnidrop/src/event_hub.rs index dc26ce5..2297b43 100644 --- a/crates/vnidrop/src/event_hub.rs +++ b/crates/vnidrop/src/event_hub.rs @@ -50,10 +50,6 @@ enum EventPhase { Delivery, /// Saved-device pairing eligibility and consent prompts. Pairing, - /// Prototype contact lifecycle notifications. - Contacts, - /// Prototype contact offer prompts. - Offer, /// Saved-device targeted-transfer pre-approval prompts. TargetedTransfer, } @@ -78,8 +74,6 @@ impl EventPhase { "transfer" => Some(Self::Transfer), "delivery" => Some(Self::Delivery), "pairing" => Some(Self::Pairing), - "contacts" => Some(Self::Contacts), - "offer" => Some(Self::Offer), "targeted_transfer" => Some(Self::TargetedTransfer), _ => None, } @@ -104,8 +98,6 @@ impl EventPhase { Self::Transfer => "transfer", Self::Delivery => "delivery", Self::Pairing => "pairing", - Self::Contacts => "contacts", - Self::Offer => "offer", Self::TargetedTransfer => "targeted_transfer", } } diff --git a/crates/vnidrop/src/grant.rs b/crates/vnidrop/src/grant.rs index 80165f1..8c3e513 100644 --- a/crates/vnidrop/src/grant.rs +++ b/crates/vnidrop/src/grant.rs @@ -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 -//! issued me a capability". The issuer is the only party that can validate a -//! grant, which is what makes both consent and revocation enforceable: refusing -//! to issue leaves the peer with nothing usable, and deleting the issued record -//! ends the relationship without the peer's cooperation. -//! -//! This module is pure: no storage, no network, no clock of its own. Callers -//! supply `now_ms` so expiry and renewal stay testable. +//! The issuer is the only party that can validate a grant, which is what makes +//! both consent and revocation enforceable. This module is pure: no storage and +//! no network. Relationship-bound MACs live in `device_relationship::crypto`. use std::fmt; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use data_encoding::HEXLOWER; use serde::{Deserialize, Serialize}; -/// Domain separator for the possession proof. Changing this invalidates every -/// outstanding grant, so it is versioned rather than edited. -const PROOF_CONTEXT: &[u8] = b"vnidrop-grant-v1"; - const GRANT_ID_LEN: usize = 16; const GRANT_SECRET_LEN: usize = 32; const CHALLENGE_LEN: usize = 32; @@ -168,9 +159,6 @@ impl fmt::Debug for GrantProof { pub(crate) enum GrantRejection { Unknown, Revoked, - Expired, - WrongEndpoint, - BadProof, } impl GrantRejection { @@ -178,207 +166,10 @@ impl GrantRejection { match self { Self::Unknown => "unknown", Self::Revoked => "revoked", - Self::Expired => "expired", - Self::WrongEndpoint => "wrong-endpoint", - Self::BadProof => "bad-proof", } } } -/// A grant as held by the party that issued it. This is the authoritative -/// record: `grants_held` on the peer is only a copy for display. -#[derive(Debug, Clone)] -pub(crate) struct IssuedGrant { - pub(crate) grant_id: GrantId, - pub(crate) secret: GrantSecret, - /// The grant is usable only by this endpoint, so it cannot be lent onward. - pub(crate) issued_to_endpoint_id: String, - pub(crate) created_at: i64, - /// Idle expiry, pushed forward on every accepted proof. `None` never expires. - pub(crate) expires_at: Option, - pub(crate) revoked_at: Option, -} - -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, 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, -} - -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 { - 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 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, bytes: &[u8]) { - buffer.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - buffer.extend_from_slice(bytes); -} - -fn constant_time_eq(left: &[u8; PROOF_LEN], right: &[u8; PROOF_LEN]) -> bool { - // blake3::Hash compares in constant time; reuse it rather than hand-rolling. - blake3::Hash::from_bytes(*left) == blake3::Hash::from_bytes(*right) -} - /// Cryptographically secure random bytes. /// /// Panics if the OS entropy source fails. That is unrecoverable and must never @@ -388,13 +179,3 @@ fn random_bytes() -> [u8; N] { getrandom::fill(&mut bytes).expect("OS entropy source unavailable"); bytes } - -/// Parse a stored grant secret, rejecting anything malformed rather than -/// silently producing a grant that can never validate. -pub(crate) fn parse_secret(value: &str) -> Result { - let secret = GrantSecret::decode(value)?; - if secret.0.iter().all(|byte| *byte == 0) { - bail!("refusing an all-zero grant secret"); - } - Ok(secret) -} diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 2e2cbc8..7777bf0 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -1,7 +1,7 @@ mod access_policy; mod api; mod approval; -mod contacts; +mod blocked_devices; mod control_plane; mod device_relationship; mod error; @@ -10,9 +10,6 @@ mod filesystem; mod grant; mod handshake; mod logging; -mod offer; -mod offer_inbox; -mod pairing; mod pairing_eligibility; mod repository; mod runtime; @@ -29,11 +26,10 @@ mod util; pub use api::{ clear_inactive_transfer_cache, default_core_limits, default_core_network_config, - experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent, - CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, - DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, - GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary, - PendingPairing, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, + experimental_saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits, + CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship, + DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary, + PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata, diff --git a/crates/vnidrop/src/offer.rs b/crates/vnidrop/src/offer.rs deleted file mode 100644 index 7c0ec4d..0000000 --- a/crates/vnidrop/src/offer.rs +++ /dev/null @@ -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::(&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, -} - -impl OfferClient { - pub(crate) async fn poll_offers(&self) -> Result { - self.inner.rpc(PollOffers).await - } - - pub(crate) async fn request_challenge(&self) -> Result { - Ok(self.inner.rpc(RequestChallenge).await?.challenge) - } - - pub(crate) async fn submit_offer( - &self, - offer: SubmitOffer, - ) -> Result { - self.inner.rpc(offer).await - } - - pub(crate) async fn deliver_grant( - &self, - grant: DeliverGrant, - ) -> Result { - self.inner.rpc(grant).await - } - - pub(crate) async fn revoke_grant( - &self, - revocation: RevokeGrant, - ) -> Result { - 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, - /// Untrusted display data, shown only after the user consents. - pub(crate) display_name: Option, -} - -// 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, - 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, -} - -/// 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, - pub(crate) file_count: u64, - pub(crate) total_bytes: u64, -} - -#[rpc_requests(message = OfferMessage)] -#[derive(Debug, Serialize, Deserialize)] -enum OfferProtocol { - #[rpc(tx=oneshot::Sender)] - RequestChallenge(RequestChallenge), - #[rpc(tx=oneshot::Sender)] - DeliverGrant(DeliverGrant), - #[rpc(tx=oneshot::Sender)] - RevokeGrant(RevokeGrant), - #[rpc(tx=oneshot::Sender)] - SubmitOffer(SubmitOffer), - #[rpc(tx=oneshot::Sender)] - PollOffers(PollOffers), -} diff --git a/crates/vnidrop/src/offer_inbox.rs b/crates/vnidrop/src/offer_inbox.rs deleted file mode 100644 index 320b819..0000000 --- a/crates/vnidrop/src/offer_inbox.rs +++ /dev/null @@ -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, - 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, -} - -#[derive(Clone)] -pub(crate) struct OfferInbox { - event_hub: Arc, - pending: Arc>>, - waiters: Arc>>, - /// Endpoint → time before which new offers are refused. - cooldowns: Arc>>, - max_pending: usize, - decline_cooldown_ms: i64, -} - -impl OfferInbox { - pub(crate) fn new( - event_hub: Arc, - 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, - 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, - 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 { - 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 { - 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 = { - 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; - } - } -} diff --git a/crates/vnidrop/src/pairing.rs b/crates/vnidrop/src/pairing.rs deleted file mode 100644 index b793d3d..0000000 --- a/crates/vnidrop/src/pairing.rs +++ /dev/null @@ -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, - pub(crate) received_at: i64, - grant: HeldGrant, -} - -#[derive(Clone)] -pub(crate) struct PairingService { - contacts: ContactStore, - event_hub: Arc, - /// Keyed by peer endpoint id: one outstanding offer per peer, so a peer - /// cannot flood the prompt queue by reconnecting. - pending: Arc>>, - max_pending: usize, - max_metadata_bytes: u64, - lifetime: Arc>, -} - -impl PairingService { - pub(crate) fn new( - contacts: ContactStore, - event_hub: Arc, - 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 { - 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 { - 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 { - 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 { - 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 = 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, 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); - } -} diff --git a/crates/vnidrop/src/pairing_eligibility.rs b/crates/vnidrop/src/pairing_eligibility.rs index 1ce7bdd..c24a892 100644 --- a/crates/vnidrop/src/pairing_eligibility.rs +++ b/crates/vnidrop/src/pairing_eligibility.rs @@ -252,14 +252,6 @@ impl PairingEligibilityService { 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` /// without emitting prompts or eligibility-removed events for the reject path. #[allow( diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index 708c25e..117813f 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -19,7 +19,7 @@ use crate::{ CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer, }, - contacts::ContactStore, + blocked_devices::BlockStore, error::VnidropError, pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, @@ -336,7 +336,7 @@ impl Repository { .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::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?; crate::targeted_transfer::ensure_schema(&self.pool).await?; @@ -369,10 +369,9 @@ impl Repository { Ok(()) } - /// Device history, grants, and the block list. Shares this pool so the - /// tables migrate together with the rest of the schema. - pub(crate) fn contacts(&self) -> ContactStore { - ContactStore::new(self.pool.clone()) + /// Identity-wide deny list for saved-device and invitation traffic. + pub(crate) fn blocked_devices(&self) -> BlockStore { + BlockStore::new(self.pool.clone()) } pub(crate) fn sqlite_pool(&self) -> SqlitePool { diff --git a/crates/vnidrop/src/runtime/contacts.rs b/crates/vnidrop/src/runtime/contacts.rs deleted file mode 100644 index 2b48c15..0000000 --- a/crates/vnidrop/src/runtime/contacts.rs +++ /dev/null @@ -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, 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, 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 { - self.device_relationships - .request_pairing(peer_endpoint_id) - .await - } - - pub(super) async fn list_device_relationships( - &self, - ) -> Result, crate::error::VnidropError> { - self.device_relationships.list().await - } - - pub(super) async fn list_saved_devices( - &self, - ) -> Result, crate::error::VnidropError> { - self.device_relationships.list_saved_devices().await - } - - pub(super) async fn respond_to_device_pairing( - self: &Arc, - peer_endpoint_id: String, - accepted: bool, - ) -> Result { - 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, - 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, - 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, 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 { - self.device_relationships - .rotate_relationship_grant(peer_endpoint_id) - .await - } - - #[cfg(test)] - pub(super) fn targeted_cancel_log_for_test(&self) -> Vec { - 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, - ) -> Result { - 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> { - 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 { - 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, - endpoint_id: String, - display_name: Option, - ) -> 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, - endpoint_id: String, - sources: Vec, - mut metadata: ShareMetadataInput, - ) -> Result { - 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, - transfer_id: u64, - endpoint_id: String, - ) -> Result { - 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, - endpoint_id: String, - grant: HeldGrant, - share: ShareResult, - sender_name: Option<&str>, - ) -> Result { - 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, - 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) -> Result { - 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, - endpoint_id: &str, - grant: &HeldGrant, - share: &ShareResult, - sender_name: Option<&str>, - ) -> Result { - 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> { - 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 { - 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 { - self.offers.respond(&offer_id, accepted).await - } - - pub(super) async fn respond_to_pairing( - &self, - endpoint_id: String, - accepted: bool, - ) -> Result { - 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, 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) -> Result { - 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, 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> { - 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, - ) -> 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, endpoint_id: String, revoked: Vec) { - 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 { - 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::() 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; - } - } -} diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index d767185..579b9ef 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -6,11 +6,10 @@ use serde_json::json; use super::{CoreInner, IdentityMode}; use crate::{ api::{ - ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, - CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, - PairingEligibilitySummary, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2, - ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, - ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, + CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage, + PairingEligibilitySummary, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, + ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, + StoredTransfer, TicketInspection, TransferAccessMode, }, error::VnidropError, filesystem::platform_path, @@ -634,148 +633,6 @@ impl VnidropCore { self.block_on(self.inner.resume_targeted_transfer(id, output_dir)) } - /// Devices the user has chosen to remember. - pub fn list_contacts(&self) -> Result, 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, - metadata: ShareMetadataInput, - ) -> Result { - 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 { - 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 { - 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, 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 { - 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 { - 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 { - 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, - ) -> 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 { - 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 { - 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, VnidropError> { - self.block_on(self.inner.list_blocked_contacts()) - .map_err(VnidropError::repository) - } - - pub fn set_contact_label( - &self, - endpoint_id: String, - label: Option, - ) -> 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, VnidropError> { self.block_on(self.inner.repository.list_transfers()) .map_err(VnidropError::repository) diff --git a/crates/vnidrop/src/runtime/lifecycle.rs b/crates/vnidrop/src/runtime/lifecycle.rs index aee363e..ca83922 100644 --- a/crates/vnidrop/src/runtime/lifecycle.rs +++ b/crates/vnidrop/src/runtime/lifecycle.rs @@ -54,13 +54,6 @@ impl CoreInner { drop(active_shares); self.unregister_transfer_hashes(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await; - // An offer waiting for pickup would hand out a ticket for content - // this device no longer serves. - let _ = self - .repository - .contacts() - .delete_held_offers_for_transfer(transfer_id) - .await; self.store.tags().delete(share_tag_name(&local_id)).await?; self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({})); return Ok(()); diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 4e503bf..e198eb5 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -6,21 +6,19 @@ //! - [`receive`] — ticket receive, download, export //! - [`lifecycle`] — cancel/delete/shutdown/status/access //! - [`provider`] — blob provider events and per-connection send progress -//! - [`contacts`] — device history: pairing, forgetting, blocking +//! - [`saved_devices`] — experimental saved-device pairing, forget, block //! - [`targeted`] — saved-device targeted transfers -mod contacts; mod delivery; mod facade; mod lifecycle; mod provider; mod receive; +mod saved_devices; mod share; mod storage; mod targeted; -#[cfg(test)] -pub(crate) use self::contacts::should_poll; pub use facade::VnidropCore; #[cfg(test)] pub(crate) use provider::{consume_request_updates, RequestStreamOutcome}; @@ -62,9 +60,6 @@ use crate::{ event_hub::EventHub, handshake::HandshakeService, logging::init_logging, - offer::OfferService, - offer_inbox::OfferInbox, - pairing::PairingService, pairing_eligibility::PairingEligibilityService, repository::Repository, secret::load_or_create_secret, @@ -76,10 +71,6 @@ use crate::{ const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Minimum gap between polls of the same device, so switching in and out of the -/// app does not announce presence to every contact repeatedly. -pub(super) const POLL_MIN_INTERVAL_MS: i64 = 5 * 60 * 1_000; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RelayStatus { Disabled, @@ -109,13 +100,9 @@ pub(super) struct CoreInner { pub(super) secret_custody: Option>, pub(super) event_hub: Arc, pub(super) approval: ApprovalService, - pub(super) pairing: PairingService, pub(super) pairing_eligibility: PairingEligibilityService, pub(super) device_relationships: Arc, - pub(super) offers: OfferInbox, pub(super) targeted_offers: TargetedOfferInbox, - /// Endpoint → last poll time, for the rate limit above. - pub(super) last_polled: TokioMutex>, pub(super) limits: CoreLimits, pub(super) relay_mode: CoreRelayMode, pub(super) custom_relay_urls: Vec, @@ -380,25 +367,6 @@ impl CoreInner { Some(pairing_eligibility.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( limits.identity_cooldown_ms, limits.malformed_strike_limit, @@ -424,10 +392,6 @@ impl CoreInner { let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) .accept(HandshakeService::ALPN, handshake) - .accept( - OfferService::ALPN, - OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()), - ) .accept( RelationshipProtocol::ALPN, RelationshipProtocol::new(device_relationships.clone()), @@ -456,12 +420,9 @@ impl CoreInner { secret_custody: secret_custody.clone(), event_hub, approval, - pairing, pairing_eligibility, device_relationships, - offers, targeted_offers, - last_polled: TokioMutex::new(HashMap::new()), relay_mode, custom_relay_urls: relay_urls, transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), diff --git a/crates/vnidrop/src/runtime/saved_devices.rs b/crates/vnidrop/src/runtime/saved_devices.rs new file mode 100644 index 0000000..56039a4 --- /dev/null +++ b/crates/vnidrop/src/runtime/saved_devices.rs @@ -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, 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 { + self.device_relationships + .request_pairing(peer_endpoint_id) + .await + } + + pub(super) async fn list_device_relationships( + &self, + ) -> Result, crate::error::VnidropError> { + self.device_relationships.list().await + } + + pub(super) async fn list_saved_devices( + &self, + ) -> Result, crate::error::VnidropError> { + self.device_relationships.list_saved_devices().await + } + + pub(super) async fn respond_to_device_pairing( + self: &Arc, + peer_endpoint_id: String, + accepted: bool, + ) -> Result { + 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, + 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, + 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, 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 { + self.device_relationships + .rotate_relationship_grant(peer_endpoint_id) + .await + } + + #[cfg(test)] + pub(super) fn targeted_cancel_log_for_test(&self) -> Vec { + 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, + ) -> Result { + let material = crate::secure_secret::SecretMaterial::new(capability)?; + self.pairing_eligibility + .accept_presented_eligibility(&peer_endpoint_id, &session_id, &material) + .await + } +} diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 176b48d..6eabc25 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -1,9 +1,9 @@ #[path = "tests/access_policy.rs"] mod access_policy_tests; -#[path = "tests/contact_polling.rs"] -mod contact_polling_tests; -#[path = "tests/contacts.rs"] -mod contacts_tests; +#[path = "tests/api_surface.rs"] +mod api_surface_tests; +#[path = "tests/blocked_devices.rs"] +mod blocked_devices_tests; #[path = "tests/control_plane.rs"] mod control_plane_tests; #[path = "tests/device_relationship.rs"] diff --git a/crates/vnidrop/src/tests/api_surface.rs b/crates/vnidrop/src/tests/api_surface.rs new file mode 100644 index 0000000..43d494d --- /dev/null +++ b/crates/vnidrop/src/tests/api_surface.rs @@ -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); +} diff --git a/crates/vnidrop/src/tests/blocked_devices.rs b/crates/vnidrop/src/tests/blocked_devices.rs new file mode 100644 index 0000000..5e27145 --- /dev/null +++ b/crates/vnidrop/src/tests/blocked_devices.rs @@ -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()); +} diff --git a/crates/vnidrop/src/tests/contact_polling.rs b/crates/vnidrop/src/tests/contact_polling.rs deleted file mode 100644 index 344f68d..0000000 --- a/crates/vnidrop/src/tests/contact_polling.rs +++ /dev/null @@ -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)); -} diff --git a/crates/vnidrop/src/tests/contacts.rs b/crates/vnidrop/src/tests/contacts.rs deleted file mode 100644 index b0af327..0000000 --- a/crates/vnidrop/src/tests/contacts.rs +++ /dev/null @@ -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()); -} diff --git a/crates/vnidrop/src/tests/device_relationship.rs b/crates/vnidrop/src/tests/device_relationship.rs index 8089d7a..6c91c6f 100644 --- a/crates/vnidrop/src/tests/device_relationship.rs +++ b/crates/vnidrop/src/tests/device_relationship.rs @@ -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. alice .core - .set_contact_label(bob_id.clone(), Some("Kitchen Tablet".to_string())) - .ok(); + .set_saved_device_label(bob_id.clone(), Some("Kitchen Tablet".to_string())) + .unwrap(); reach_saved(&alice, &charlie, 90_041); alice .core - .set_contact_label(charlie_id.clone(), Some("Kitchen Tablet".to_string())) - .ok(); + .set_saved_device_label(charlie_id.clone(), Some("Kitchen Tablet".to_string())) + .unwrap(); let saved = alice.core.list_saved_devices().unwrap(); assert_eq!(saved.len(), 2); diff --git a/crates/vnidrop/src/tests/grant.rs b/crates/vnidrop/src/tests/grant.rs index b982052..4decca0 100644 --- a/crates/vnidrop/src/tests/grant.rs +++ b/crates/vnidrop/src/tests/grant.rs @@ -1,226 +1,35 @@ -use crate::grant::{ - parse_secret, prove, Challenge, GrantId, GrantLifetime, GrantRejection, GrantSecret, - IssuedGrant, -}; - -const ISSUER: &str = "issuer-endpoint"; -const HOLDER: &str = "holder-endpoint"; -const DAY_MS: i64 = 24 * 60 * 60 * 1_000; - -fn issued(now_ms: i64) -> IssuedGrant { - IssuedGrant::mint(HOLDER.to_string(), now_ms, GrantLifetime::default()) -} - -fn accept_with( - grant: &IssuedGrant, - challenge: &Challenge, - remote_endpoint_id: &str, - now_ms: i64, -) -> Result, 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(), - ) -} +use crate::grant::{Challenge, GrantId, GrantRejection, GrantSecret}; #[test] -fn accepts_a_valid_proof_and_returns_the_renewed_deadline() { - let now = 1_700_000_000_000; - let grant = issued(now); - let challenge = Challenge::generate(); - - let renewed = accept_with(&grant, &challenge, HOLDER, now + DAY_MS).expect("proof accepted"); - - assert_eq!(renewed, Some(now + DAY_MS + 90 * DAY_MS)); -} - -#[test] -fn renewal_extends_past_the_original_expiry() { - let now = 1_700_000_000_000; - let grant = issued(now); - let original = grant.expires_at.expect("default lifetime expires"); - - // Used one day before lapsing: the new deadline must be later than the old. - let use_at = original - DAY_MS; - let renewed = accept_with(&grant, &Challenge::generate(), HOLDER, use_at) - .expect("proof accepted") - .expect("renewed deadline"); - - assert!(renewed > original); -} - -#[test] -fn rejects_a_proof_bound_to_a_different_challenge() { - let now = 1_700_000_000_000; - let grant = issued(now); - let captured = Challenge::from_bytes([7u8; 32]); - let proof = prove(grant.grant_id, &grant.secret, &captured, ISSUER, HOLDER); - - // Replaying a captured proof against a fresh challenge must fail. - let outcome = grant.accept( - &proof, - &Challenge::from_bytes([9u8; 32]), - ISSUER, - HOLDER, - now, - GrantLifetime::default(), - ); - - assert_eq!(outcome, Err(GrantRejection::BadProof)); -} - -#[test] -fn rejects_a_proof_from_an_endpoint_the_grant_was_not_issued_to() { - let now = 1_700_000_000_000; - let grant = issued(now); - - let outcome = accept_with(&grant, &Challenge::generate(), "someone-else", now); - - assert_eq!(outcome, Err(GrantRejection::WrongEndpoint)); -} - -#[test] -fn rejects_a_proof_replayed_against_a_different_issuer() { - let now = 1_700_000_000_000; - let grant = issued(now); - let challenge = Challenge::generate(); - let proof = prove( - grant.grant_id, - &grant.secret, - &challenge, - "other-issuer", - HOLDER, - ); - - let outcome = grant.accept( - &proof, - &challenge, - ISSUER, - HOLDER, - now, - GrantLifetime::default(), - ); - - assert_eq!(outcome, Err(GrantRejection::BadProof)); -} - -#[test] -fn rejects_a_revoked_grant_distinguishably() { - let now = 1_700_000_000_000; - let mut grant = issued(now); - grant.revoked_at = Some(now); - - // Revocation is reported as such so the peer can drop the dead entry. +fn grant_identifiers_round_trip() { + let grant_id = GrantId::generate(); assert_eq!( - accept_with(&grant, &Challenge::generate(), HOLDER, now), - Err(GrantRejection::Revoked) + GrantId::decode(&grant_id.encode()).expect("id decodes"), + 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(); - 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] -fn rejects_malformed_or_degenerate_stored_secrets() { - assert!(parse_secret("not-hex").is_err()); - assert!(parse_secret("aabb").is_err(), "wrong length"); - assert!( - parse_secret(&"00".repeat(32)).is_err(), - "an all-zero secret means corrupt storage, not a usable grant" + let challenge = Challenge::generate(); + assert_eq!( + Challenge::decode(&challenge.encode()).expect("challenge decodes"), + challenge ); } #[test] -fn secrets_are_redacted_in_debug_output() { - let secret = GrantSecret::generate(); - let rendered = format!("{secret:?}"); - - assert!(!rendered.contains(&secret.encode())); - assert_eq!(rendered, "GrantSecret(redacted)"); +fn grant_secret_decode_rejects_garbage() { + assert!(GrantSecret::decode("not-hex").is_err()); + assert!(GrantSecret::decode("aabb").is_err(), "wrong length"); } #[test] -fn generated_grants_are_unique() { - let now = 1_700_000_000_000; - let first = issued(now); - let second = issued(now); - - assert_ne!(first.grant_id, second.grant_id); - assert_ne!(first.secret, second.secret); +fn grant_rejection_labels_are_stable() { + assert_eq!(GrantRejection::Unknown.as_str(), "unknown"); + assert_eq!(GrantRejection::Revoked.as_str(), "revoked"); } diff --git a/crates/vnidrop/src/tests/pairing_eligibility.rs b/crates/vnidrop/src/tests/pairing_eligibility.rs index 18860e8..02b7aa6 100644 --- a/crates/vnidrop/src/tests/pairing_eligibility.rs +++ b/crates/vnidrop/src/tests/pairing_eligibility.rs @@ -399,7 +399,7 @@ fn decline_forget_block_and_replay_remove_eligibility_idempotently() { wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id); receiver .core - .forget_contact(sender2.core.status().endpoint_id.clone()) + .forget_saved_device(sender2.core.status().endpoint_id.clone()) .unwrap(); assert!(receiver .core @@ -423,7 +423,7 @@ fn decline_forget_block_and_replay_remove_eligibility_idempotently() { wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id); receiver .core - .block_contact(sender3.core.status().endpoint_id.clone()) + .block_device(sender3.core.status().endpoint_id.clone()) .unwrap(); assert!(receiver .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 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 .core .request_saved_device_pairing(sender.core.status().endpoint_id.clone()) .unwrap()); - assert!(receiver.core.list_pending_pairings().is_empty()); + assert!(receiver + .core + .list_device_relationships() + .unwrap() + .is_empty()); assert_eq!( receiver .sink @@ -483,8 +487,22 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() { .core .request_saved_device_pairing(receiver_id) .unwrap()); - assert!(sender.core.list_pending_pairings().is_empty()); - assert!(receiver.core.list_pending_pairings().is_empty()); + assert_eq!( + 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. let pairing_events_before = receiver @@ -501,7 +519,12 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() { vec![7u8; 32], ) .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 .sink .events() diff --git a/crates/vnidrop/tests/offer.rs b/crates/vnidrop/tests/offer.rs deleted file mode 100644 index 95076f6..0000000 --- a/crates/vnidrop/tests/offer.rs +++ /dev/null @@ -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 { - 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, - to: String, - path: &Path, - transfer_id: u64, -) -> std::thread::JoinHandle> { - 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, -} - -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()); -} diff --git a/crates/vnidrop/tests/pairing.rs b/crates/vnidrop/tests/pairing.rs deleted file mode 100644 index 745ecef..0000000 --- a/crates/vnidrop/tests/pairing.rs +++ /dev/null @@ -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")); -} diff --git a/localization/strings.json b/localization/strings.json index 7dcf4f8..91d15b1 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -4618,250 +4618,6 @@ "ru": "Версия приложения" } }, - "contacts_title": { - "context": "Devices screen: title of the list of remembered devices.", - "translations": { - "en": "Devices", - "fr": "Appareils", - "es": "Dispositivos", - "it": "Dispositivi", - "de": "Geräte", - "pt": "Dispositivos", - "pl": "Urządzenia", - "nl": "Apparaten", - "ru": "Устройства" - } - }, - "contacts_subtitle": { - "context": "Devices screen: one-line explanation under the title.", - "translations": { - "en": "Devices you have transferred with can receive files without a new invitation.", - "fr": "Les appareils avec lesquels vous avez déjà échangé peuvent recevoir des fichiers sans nouvelle invitation.", - "es": "Los dispositivos con los que ya has compartido pueden recibir archivos sin una nueva invitación.", - "it": "I dispositivi con cui hai già scambiato file possono riceverne altri senza un nuovo invito.", - "de": "Geräte, mit denen Sie bereits Dateien ausgetauscht haben, können ohne neue Einladung Dateien empfangen.", - "pt": "Os dispositivos com os quais já transferiu podem receber ficheiros sem um novo convite.", - "pl": "Urządzenia, z którymi już przesyłano pliki, mogą je odbierać bez nowego zaproszenia.", - "nl": "Apparaten waarmee je al hebt overgedragen, kunnen bestanden ontvangen zonder nieuwe uitnodiging.", - "ru": "Устройства, с которыми вы уже обменивались файлами, могут получать их без нового приглашения." - } - }, - "contacts_empty_title": { - "context": "Devices screen: empty-state title when no device has been remembered yet.", - "translations": { - "en": "No remembered devices", - "fr": "Aucun appareil enregistré", - "es": "Ningún dispositivo guardado", - "it": "Nessun dispositivo memorizzato", - "de": "Keine gespeicherten Geräte", - "pt": "Nenhum dispositivo guardado", - "pl": "Brak zapamiętanych urządzeń", - "nl": "Geen onthouden apparaten", - "ru": "Нет сохранённых устройств" - } - }, - "contacts_empty_body": { - "context": "Devices screen: empty-state explanation of how a device gets remembered.", - "translations": { - "en": "After a transfer, both devices can choose to remember each other.", - "fr": "Après un transfert, les deux appareils peuvent choisir de se mémoriser mutuellement.", - "es": "Tras una transferencia, ambos dispositivos pueden elegir recordarse mutuamente.", - "it": "Dopo un trasferimento, entrambi i dispositivi possono scegliere di ricordarsi a vicenda.", - "de": "Nach einer Übertragung können beide Geräte einander speichern.", - "pt": "Após uma transferência, ambos os dispositivos podem optar por lembrar-se um do outro.", - "pl": "Po przesłaniu plików oba urządzenia mogą zapamiętać się nawzajem.", - "nl": "Na een overdracht kunnen beide apparaten elkaar onthouden.", - "ru": "После передачи оба устройства могут запомнить друг друга." - } - }, - "contacts_unreachable": { - "context": "Devices screen: badge on a device that can no longer be sent to.", - "translations": { - "en": "Needs pairing again", - "fr": "Nouvel appairage nécessaire", - "es": "Requiere emparejar de nuevo", - "it": "Richiede un nuovo abbinamento", - "de": "Muss erneut gekoppelt werden", - "pt": "É preciso emparelhar novamente", - "pl": "Wymaga ponownego sparowania", - "nl": "Opnieuw koppelen vereist", - "ru": "Требуется повторное сопряжение" - } - }, - "contacts_unreachable_body": { - "context": "Device detail: explains why a remembered device can no longer be reached.", - "translations": { - "en": "This device withdrew access, or reinstalled VniDrop. Transfer to it once more to remember it again.", - "fr": "Cet appareil a retiré l’accès ou a réinstallé VniDrop. Effectuez un nouveau transfert pour le mémoriser à nouveau.", - "es": "Este dispositivo retiró el acceso o reinstaló VniDrop. Realiza otra transferencia para volver a recordarlo.", - "it": "Questo dispositivo ha revocato l’accesso o ha reinstallato VniDrop. Effettua un altro trasferimento per memorizzarlo di nuovo.", - "de": "Dieses Gerät hat den Zugriff entzogen oder VniDrop neu installiert. Übertragen Sie erneut, um es wieder zu speichern.", - "pt": "Este dispositivo retirou o acesso ou reinstalou o VniDrop. Faça outra transferência para o voltar a guardar.", - "pl": "To urządzenie cofnęło dostęp lub ponownie zainstalowało VniDrop. Wykonaj kolejne przesłanie, aby zapamiętać je ponownie.", - "nl": "Dit apparaat heeft de toegang ingetrokken of VniDrop opnieuw geïnstalleerd. Draag opnieuw over om het weer te onthouden.", - "ru": "Это устройство отозвало доступ или переустановило VniDrop. Выполните новую передачу, чтобы снова его запомнить." - } - }, - "contacts_name_field": { - "context": "Device detail: text field label for the name the local user gives a device.", - "translations": { - "en": "Name on this device", - "fr": "Nom sur cet appareil", - "es": "Nombre en este dispositivo", - "it": "Nome su questo dispositivo", - "de": "Name auf diesem Gerät", - "pt": "Nome neste dispositivo", - "pl": "Nazwa na tym urządzeniu", - "nl": "Naam op dit apparaat", - "ru": "Имя на этом устройстве" - } - }, - "contacts_name_hint": { - "context": "Device detail: note that the local name is never changed by the other device.", - "translations": { - "en": "Only you see this name. The other device can never change it.", - "fr": "Vous seul voyez ce nom. L’autre appareil ne peut jamais le modifier.", - "es": "Solo tú ves este nombre. El otro dispositivo nunca puede cambiarlo.", - "it": "Solo tu vedi questo nome. L’altro dispositivo non può mai modificarlo.", - "de": "Nur Sie sehen diesen Namen. Das andere Gerät kann ihn nie ändern.", - "pt": "Só você vê este nome. O outro dispositivo nunca o pode alterar.", - "pl": "Tylko Ty widzisz tę nazwę. Drugie urządzenie nigdy jej nie zmieni.", - "nl": "Alleen jij ziet deze naam. Het andere apparaat kan die nooit wijzigen.", - "ru": "Это имя видите только вы. Другое устройство не может его изменить." - } - }, - "contacts_forget": { - "context": "Device detail: button that removes a device and revokes its access.", - "translations": { - "en": "Forget device", - "fr": "Oublier l’appareil", - "es": "Olvidar dispositivo", - "it": "Dimentica dispositivo", - "de": "Gerät entfernen", - "pt": "Esquecer dispositivo", - "pl": "Zapomnij urządzenie", - "nl": "Apparaat vergeten", - "ru": "Забыть устройство" - } - }, - "contacts_forget_body": { - "context": "Device detail: confirmation explaining what forgetting a device does.", - "translations": { - "en": "This device will no longer be able to send you files without a new invitation. Files already received are kept.", - "fr": "Cet appareil ne pourra plus vous envoyer de fichiers sans nouvelle invitation. Les fichiers déjà reçus sont conservés.", - "es": "Este dispositivo ya no podrá enviarte archivos sin una nueva invitación. Los archivos ya recibidos se conservan.", - "it": "Questo dispositivo non potrà più inviarti file senza un nuovo invito. I file già ricevuti vengono conservati.", - "de": "Dieses Gerät kann Ihnen ohne neue Einladung keine Dateien mehr senden. Bereits empfangene Dateien bleiben erhalten.", - "pt": "Este dispositivo deixará de lhe poder enviar ficheiros sem um novo convite. Os ficheiros já recebidos são mantidos.", - "pl": "To urządzenie nie będzie mogło wysyłać Ci plików bez nowego zaproszenia. Już odebrane pliki pozostaną.", - "nl": "Dit apparaat kan je zonder nieuwe uitnodiging geen bestanden meer sturen. Reeds ontvangen bestanden blijven behouden.", - "ru": "Это устройство больше не сможет отправлять вам файлы без нового приглашения. Уже полученные файлы сохранятся." - } - }, - "contacts_forget_all": { - "context": "Devices screen: button that forgets every remembered device at once.", - "translations": { - "en": "Forget all devices", - "fr": "Oublier tous les appareils", - "es": "Olvidar todos los dispositivos", - "it": "Dimentica tutti i dispositivi", - "de": "Alle Geräte entfernen", - "pt": "Esquecer todos os dispositivos", - "pl": "Zapomnij wszystkie urządzenia", - "nl": "Alle apparaten vergeten", - "ru": "Забыть все устройства" - } - }, - "contacts_block": { - "context": "Device detail: button that blocks a device outright.", - "translations": { - "en": "Block device", - "fr": "Bloquer l’appareil", - "es": "Bloquear dispositivo", - "it": "Blocca dispositivo", - "de": "Gerät blockieren", - "pt": "Bloquear dispositivo", - "pl": "Zablokuj urządzenie", - "nl": "Apparaat blokkeren", - "ru": "Заблокировать устройство" - } - }, - "contacts_blocked_title": { - "context": "Devices screen: section listing blocked devices.", - "translations": { - "en": "Blocked devices", - "fr": "Appareils bloqués", - "es": "Dispositivos bloqueados", - "it": "Dispositivi bloccati", - "de": "Blockierte Geräte", - "pt": "Dispositivos bloqueados", - "pl": "Zablokowane urządzenia", - "nl": "Geblokkeerde apparaten", - "ru": "Заблокированные устройства" - } - }, - "contacts_unblock": { - "context": "Devices screen: button that removes a device from the block list.", - "translations": { - "en": "Unblock", - "fr": "Débloquer", - "es": "Desbloquear", - "it": "Sblocca", - "de": "Freigeben", - "pt": "Desbloquear", - "pl": "Odblokuj", - "nl": "Deblokkeren", - "ru": "Разблокировать" - } - }, - "contacts_unblock_hint": { - "context": "Devices screen: note that unblocking does not restore the previous access.", - "translations": { - "en": "Unblocking does not restore access. The device has to be paired again.", - "fr": "Le déblocage ne rétablit pas l’accès. L’appareil doit être appairé à nouveau.", - "es": "Desbloquear no restaura el acceso. Hay que emparejar el dispositivo de nuevo.", - "it": "Sbloccare non ripristina l’accesso. Il dispositivo deve essere abbinato di nuovo.", - "de": "Die Freigabe stellt den Zugriff nicht wieder her. Das Gerät muss erneut gekoppelt werden.", - "pt": "Desbloquear não restaura o acesso. O dispositivo tem de ser emparelhado novamente.", - "pl": "Odblokowanie nie przywraca dostępu. Urządzenie trzeba sparować ponownie.", - "nl": "Deblokkeren herstelt de toegang niet. Het apparaat moet opnieuw worden gekoppeld.", - "ru": "Разблокировка не восстанавливает доступ. Устройство нужно сопрячь заново." - } - }, - "contacts_send_to": { - "context": "Device detail: button that starts choosing files to send to this device.", - "translations": { - "en": "Send files", - "fr": "Envoyer des fichiers", - "es": "Enviar archivos", - "it": "Invia file", - "de": "Dateien senden", - "pt": "Enviar ficheiros", - "pl": "Wyślij pliki", - "nl": "Bestanden sturen", - "ru": "Отправить файлы" - } - }, - "contacts_last_transfer": { - "context": "Devices screen: subtitle showing when the last transfer with a device happened. {date} = formatted date.", - "args": [ - { - "name": "date", - "type": "string" - } - ], - "translations": { - "en": "Last transfer {date}", - "fr": "Dernier transfert {date}", - "es": "Última transferencia {date}", - "it": "Ultimo trasferimento {date}", - "de": "Letzte Übertragung {date}", - "pt": "Última transferência {date}", - "pl": "Ostatnie przesłanie {date}", - "nl": "Laatste overdracht {date}", - "ru": "Последняя передача {date}" - } - }, "pairing_request_title": { "context": "Pairing prompt: title asking whether to remember a device that offered to be reachable.", "translations": { @@ -5031,299 +4787,6 @@ "nl": "Weigeren", "ru": "Отклонить" } - }, - "contacts_grant_lifetime_title": { - "context": "Devices settings: how long a remembered device stays reachable while unused.", - "translations": { - "en": "Forget unused devices after", - "fr": "Oublier les appareils inutilisés après", - "es": "Olvidar dispositivos sin usar tras", - "it": "Dimentica i dispositivi inutilizzati dopo", - "de": "Ungenutzte Geräte entfernen nach", - "pt": "Esquecer dispositivos não usados após", - "pl": "Zapomnij nieużywane urządzenia po", - "nl": "Ongebruikte apparaten vergeten na", - "ru": "Забывать неиспользуемые устройства через" - } - }, - "contacts_grant_lifetime_hint": { - "context": "Devices settings: clarifies that the countdown restarts on each transfer.", - "translations": { - "en": "The countdown restarts every time you transfer with the device.", - "fr": "Le décompte redémarre à chaque transfert avec l’appareil.", - "es": "La cuenta atrás se reinicia cada vez que transfieres con el dispositivo.", - "it": "Il conteggio riparte a ogni trasferimento con il dispositivo.", - "de": "Die Frist beginnt bei jeder Übertragung mit dem Gerät neu.", - "pt": "A contagem reinicia sempre que transfere com o dispositivo.", - "pl": "Odliczanie zaczyna się od nowa przy każdym przesłaniu.", - "nl": "De teller start opnieuw bij elke overdracht met het apparaat.", - "ru": "Отсчёт начинается заново при каждой передаче с устройством." - } - }, - "contacts_grant_lifetime_never": { - "context": "Devices settings: option to never forget an unused device.", - "translations": { - "en": "Never", - "fr": "Jamais", - "es": "Nunca", - "it": "Mai", - "de": "Nie", - "pt": "Nunca", - "pl": "Nigdy", - "nl": "Nooit", - "ru": "Никогда" - } - }, - "contacts_grant_lifetime_days": { - "context": "Devices settings: option label for a number of days. {count} = days.", - "args": [ - { - "name": "count", - "type": "int" - } - ], - "plural": { - "en": { - "one": "{count} day", - "other": "{count} days" - }, - "fr": { - "one": "{count} jour", - "other": "{count} jours" - }, - "es": { - "one": "{count} día", - "other": "{count} días" - }, - "it": { - "one": "{count} giorno", - "other": "{count} giorni" - }, - "de": { - "one": "{count} Tag", - "other": "{count} Tage" - }, - "pt": { - "one": "{count} dia", - "other": "{count} dias" - }, - "pl": { - "one": "{count} dzień", - "few": "{count} dni", - "many": "{count} dni", - "other": "{count} dnia" - }, - "nl": { - "one": "{count} dag", - "other": "{count} dagen" - }, - "ru": { - "one": "{count} день", - "few": "{count} дня", - "many": "{count} дней", - "other": "{count} дня" - } - } - }, - "contacts_check_on_open": { - "context": "Devices settings: toggle to check remembered devices for waiting transfers when the app opens.", - "translations": { - "en": "Check for waiting transfers", - "fr": "Rechercher les transferts en attente", - "es": "Buscar transferencias en espera", - "it": "Cerca trasferimenti in attesa", - "de": "Nach wartenden Übertragungen suchen", - "pt": "Procurar transferências em espera", - "pl": "Sprawdzaj oczekujące przesyłki", - "nl": "Controleren op wachtende overdrachten", - "ru": "Проверять ожидающие передачи" - } - }, - "contacts_check_on_open_hint": { - "context": "Devices settings: warns that checking reveals to remembered devices when the app is opened.", - "translations": { - "en": "When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app.", - "fr": "À l’ouverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir s’ils ont quelque chose pour vous. Cela leur indique quand vous ouvrez l’application.", - "es": "Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación.", - "it": "All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app.", - "de": "Beim Öffnen von VniDrop werden Ihre gespeicherten Geräte gefragt, ob sie etwas für Sie haben. Dadurch erfahren sie, wann Sie die App geöffnet haben.", - "pt": "Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação.", - "pl": "Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację.", - "nl": "Bij het openen van VniDrop wordt aan je onthouden apparaten gevraagd of ze iets voor je hebben. Zij weten daardoor wanneer je de app opende.", - "ru": "При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение." - } - }, - "contacts_check_now": { - "context": "Devices screen: button that checks remembered devices for waiting transfers right now.", - "translations": { - "en": "Check now", - "fr": "Vérifier maintenant", - "es": "Comprobar ahora", - "it": "Controlla ora", - "de": "Jetzt prüfen", - "pt": "Verificar agora", - "pl": "Sprawdź teraz", - "nl": "Nu controleren", - "ru": "Проверить сейчас" - } - }, - "contacts_check_none": { - "context": "Devices screen: result message when no device had anything waiting.", - "translations": { - "en": "Nothing waiting", - "fr": "Rien en attente", - "es": "Nada en espera", - "it": "Nulla in attesa", - "de": "Nichts wartet", - "pt": "Nada em espera", - "pl": "Nic nie czeka", - "nl": "Niets in de wacht", - "ru": "Ничего не ожидает" - } - }, - "contacts_offer_held": { - "context": "Shown after sending to a device that was not running: the transfer waits for it to open the app.", - "translations": { - "en": "That device is not open. The transfer will be delivered the next time it opens VniDrop.", - "fr": "Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.", - "es": "Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.", - "it": "Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.", - "de": "Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.", - "pt": "Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.", - "pl": "To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.", - "nl": "Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.", - "ru": "Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop." - } - }, - "contacts_waiting_title": { - "context": "Devices screen: section listing transfers waiting for their target device to come online.", - "translations": { - "en": "Waiting to be delivered", - "fr": "En attente de remise", - "es": "Pendientes de entrega", - "it": "In attesa di consegna", - "de": "Wartet auf Zustellung", - "pt": "A aguardar entrega", - "pl": "Oczekuje na dostarczenie", - "nl": "Wacht op bezorging", - "ru": "Ожидает доставки" - } - }, - "contacts_waiting_hint": { - "context": "Devices screen: explains that a waiting transfer is withdrawn by cancelling it.", - "translations": { - "en": "Cancel the transfer to withdraw it.", - "fr": "Annulez le transfert pour le retirer.", - "es": "Cancela la transferencia para retirarla.", - "it": "Annulla il trasferimento per ritirarlo.", - "de": "Brechen Sie die Übertragung ab, um sie zurückzuziehen.", - "pt": "Cancele a transferência para a retirar.", - "pl": "Anuluj przesyłkę, aby ją wycofać.", - "nl": "Annuleer de overdracht om die in te trekken.", - "ru": "Отмените передачу, чтобы отозвать её." - } - }, - "contacts_send_to_device": { - "context": "Transfer share panel: action that sends this transfer straight to a remembered device.", - "translations": { - "en": "Send to a device", - "fr": "Envoyer à un appareil", - "es": "Enviar a un dispositivo", - "it": "Invia a un dispositivo", - "de": "An ein Gerät senden", - "pt": "Enviar para um dispositivo", - "pl": "Wyślij do urządzenia", - "nl": "Naar een apparaat sturen", - "ru": "Отправить на устройство" - } - }, - "contacts_pick_device_title": { - "context": "Device picker sheet: title when choosing which remembered device to send a transfer to.", - "translations": { - "en": "Choose a device", - "fr": "Choisir un appareil", - "es": "Elegir un dispositivo", - "it": "Scegli un dispositivo", - "de": "Gerät auswählen", - "pt": "Escolher um dispositivo", - "pl": "Wybierz urządzenie", - "nl": "Kies een apparaat", - "ru": "Выберите устройство" - } - }, - "contacts_pick_device_empty": { - "context": "Device picker sheet: shown when no remembered device can currently be sent to.", - "translations": { - "en": "No device can be reached right now. Remembered devices appear here after a transfer.", - "fr": "Aucun appareil n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.", - "es": "Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.", - "it": "Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.", - "de": "Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.", - "pt": "Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.", - "pl": "Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.", - "nl": "Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.", - "ru": "Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи." - } - }, - "contacts_sent_to_device": { - "context": "Confirmation after a transfer was accepted by the device it was sent to. {device} = device name.", - "args": [ - { - "name": "device", - "type": "string" - } - ], - "translations": { - "en": "{device} accepted the transfer", - "fr": "{device} a accepté le transfert", - "es": "{device} aceptó la transferencia", - "it": "{device} ha accettato il trasferimento", - "de": "{device} hat die Übertragung angenommen", - "pt": "{device} aceitou a transferência", - "pl": "{device} zaakceptowało przesyłkę", - "nl": "{device} heeft de overdracht geaccepteerd", - "ru": "{device} принял передачу" - } - }, - "contacts_declined_by_device": { - "context": "Shown when the person on the other device declined an offered transfer. {device} = device name.", - "args": [ - { - "name": "device", - "type": "string" - } - ], - "translations": { - "en": "{device} declined the transfer", - "fr": "{device} a refusé le transfert", - "es": "{device} rechazó la transferencia", - "it": "{device} ha rifiutato il trasferimento", - "de": "{device} hat die Übertragung abgelehnt", - "pt": "{device} recusou a transferência", - "pl": "{device} odrzuciło przesyłkę", - "nl": "{device} heeft de overdracht geweigerd", - "ru": "{device} отклонил передачу" - } - }, - "contacts_no_answer": { - "context": "Shown when an offered transfer got no answer on the other device before timing out. {device} = device name.", - "args": [ - { - "name": "device", - "type": "string" - } - ], - "translations": { - "en": "{device} did not answer", - "fr": "{device} n’a pas répondu", - "es": "{device} no respondió", - "it": "{device} non ha risposto", - "de": "{device} hat nicht geantwortet", - "pt": "{device} não respondeu", - "pl": "{device} nie odpowiedziało", - "nl": "{device} heeft niet geantwoord", - "ru": "{device} не ответил" - } } } } diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 198c6b5..87b7277 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -306,23 +306,6 @@ Teilen Nicht verfügbar App-Version - Geräte - Geräte, mit denen Sie bereits Dateien ausgetauscht haben, können ohne neue Einladung Dateien empfangen. - Keine gespeicherten Geräte - Nach einer Übertragung können beide Geräte einander speichern. - Muss erneut gekoppelt werden - Dieses Gerät hat den Zugriff entzogen oder VniDrop neu installiert. Übertragen Sie erneut, um es wieder zu speichern. - Name auf diesem Gerät - Nur Sie sehen diesen Namen. Das andere Gerät kann ihn nie ändern. - Gerät entfernen - Dieses Gerät kann Ihnen ohne neue Einladung keine Dateien mehr senden. Bereits empfangene Dateien bleiben erhalten. - Alle Geräte entfernen - Gerät blockieren - Blockierte Geräte - Freigeben - Die Freigabe stellt den Zugriff nicht wieder her. Das Gerät muss erneut gekoppelt werden. - Dateien senden - Letzte Übertragung %1$s Dieses Gerät speichern? %1$s bietet an, Dateien ohne neue Einladung von Ihnen zu empfangen. Speichern @@ -334,28 +317,8 @@ %1$s möchte Ihnen „%2$s“ senden. Empfangen Ablehnen - Ungenutzte Geräte entfernen nach - Die Frist beginnt bei jeder Übertragung mit dem Gerät neu. - Nie - Nach wartenden Übertragungen suchen - 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. - Jetzt prüfen - Nichts wartet - Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt. - Wartet auf Zustellung - Brechen Sie die Übertragung ab, um sie zurückzuziehen. - An ein Gerät senden - Gerät auswählen - Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung. - %1$s hat die Übertragung angenommen - %1$s hat die Übertragung abgelehnt - %1$s hat nicht geantwortet %1$d Datei %1$d Dateien - - %1$d Tag - %1$d Tage - diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 110836e..3aa9e22 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -306,23 +306,6 @@ Compartir No disponible Versión de la app - Dispositivos - Los dispositivos con los que ya has compartido pueden recibir archivos sin una nueva invitación. - Ningún dispositivo guardado - Tras una transferencia, ambos dispositivos pueden elegir recordarse mutuamente. - Requiere emparejar de nuevo - Este dispositivo retiró el acceso o reinstaló VniDrop. Realiza otra transferencia para volver a recordarlo. - Nombre en este dispositivo - Solo tú ves este nombre. El otro dispositivo nunca puede cambiarlo. - Olvidar dispositivo - Este dispositivo ya no podrá enviarte archivos sin una nueva invitación. Los archivos ya recibidos se conservan. - Olvidar todos los dispositivos - Bloquear dispositivo - Dispositivos bloqueados - Desbloquear - Desbloquear no restaura el acceso. Hay que emparejar el dispositivo de nuevo. - Enviar archivos - Última transferencia %1$s ¿Recordar este dispositivo? %1$s te ofrece enviarle archivos sin una nueva invitación. Recordar @@ -334,28 +317,8 @@ %1$s quiere enviarte «%2$s». Recibir Rechazar - Olvidar dispositivos sin usar tras - La cuenta atrás se reinicia cada vez que transfieres con el dispositivo. - Nunca - Buscar transferencias en espera - Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación. - Comprobar ahora - Nada en espera - Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop. - Pendientes de entrega - Cancela la transferencia para retirarla. - Enviar a un dispositivo - Elegir un dispositivo - Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia. - %1$s aceptó la transferencia - %1$s rechazó la transferencia - %1$s no respondió %1$d archivo %1$d archivos - - %1$d día - %1$d días - diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 43aae34..a1cb5aa 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -306,23 +306,6 @@ Partager Non disponible Version de l’app - Appareils - Les appareils avec lesquels vous avez déjà échangé peuvent recevoir des fichiers sans nouvelle invitation. - Aucun appareil enregistré - Après un transfert, les deux appareils peuvent choisir de se mémoriser mutuellement. - Nouvel appairage nécessaire - Cet appareil a retiré l’accès ou a réinstallé VniDrop. Effectuez un nouveau transfert pour le mémoriser à nouveau. - Nom sur cet appareil - Vous seul voyez ce nom. L’autre appareil ne peut jamais le modifier. - Oublier l’appareil - Cet appareil ne pourra plus vous envoyer de fichiers sans nouvelle invitation. Les fichiers déjà reçus sont conservés. - Oublier tous les appareils - Bloquer l’appareil - Appareils bloqués - Débloquer - Le déblocage ne rétablit pas l’accès. L’appareil doit être appairé à nouveau. - Envoyer des fichiers - Dernier transfert %1$s Mémoriser cet appareil ? %1$s propose de recevoir vos fichiers sans nouvelle invitation. Mémoriser @@ -334,28 +317,8 @@ %1$s souhaite vous envoyer « %2$s ». Recevoir Refuser - Oublier les appareils inutilisés après - Le décompte redémarre à chaque transfert avec l’appareil. - Jamais - Rechercher les transferts en attente - À l’ouverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir s’ils ont quelque chose pour vous. Cela leur indique quand vous ouvrez l’application. - Vérifier maintenant - Rien en attente - Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop. - En attente de remise - Annulez le transfert pour le retirer. - Envoyer à un appareil - Choisir un appareil - Aucun appareil n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert. - %1$s a accepté le transfert - %1$s a refusé le transfert - %1$s n’a pas répondu %1$d fichier %1$d fichiers - - %1$d jour - %1$d jours - diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index f1e9983..2875ff1 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -306,23 +306,6 @@ Condividi Non disponibile Versione dell’app - Dispositivi - I dispositivi con cui hai già scambiato file possono riceverne altri senza un nuovo invito. - Nessun dispositivo memorizzato - Dopo un trasferimento, entrambi i dispositivi possono scegliere di ricordarsi a vicenda. - Richiede un nuovo abbinamento - Questo dispositivo ha revocato l’accesso o ha reinstallato VniDrop. Effettua un altro trasferimento per memorizzarlo di nuovo. - Nome su questo dispositivo - Solo tu vedi questo nome. L’altro dispositivo non può mai modificarlo. - Dimentica dispositivo - Questo dispositivo non potrà più inviarti file senza un nuovo invito. I file già ricevuti vengono conservati. - Dimentica tutti i dispositivi - Blocca dispositivo - Dispositivi bloccati - Sblocca - Sbloccare non ripristina l’accesso. Il dispositivo deve essere abbinato di nuovo. - Invia file - Ultimo trasferimento %1$s Ricordare questo dispositivo? %1$s ti consente di inviargli file senza un nuovo invito. Ricorda @@ -334,28 +317,8 @@ %1$s vuole inviarti «%2$s». Ricevi Rifiuta - Dimentica i dispositivi inutilizzati dopo - Il conteggio riparte a ogni trasferimento con il dispositivo. - Mai - Cerca trasferimenti in attesa - All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app. - Controlla ora - Nulla in attesa - Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop. - In attesa di consegna - Annulla il trasferimento per ritirarlo. - Invia a un dispositivo - Scegli un dispositivo - Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento. - %1$s ha accettato il trasferimento - %1$s ha rifiutato il trasferimento - %1$s non ha risposto %1$d file %1$d file - - %1$d giorno - %1$d giorni - diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c5a3dc9..99bc999 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -306,23 +306,6 @@ Delen Niet beschikbaar App-versie - Apparaten - Apparaten waarmee je al hebt overgedragen, kunnen bestanden ontvangen zonder nieuwe uitnodiging. - Geen onthouden apparaten - Na een overdracht kunnen beide apparaten elkaar onthouden. - Opnieuw koppelen vereist - Dit apparaat heeft de toegang ingetrokken of VniDrop opnieuw geïnstalleerd. Draag opnieuw over om het weer te onthouden. - Naam op dit apparaat - Alleen jij ziet deze naam. Het andere apparaat kan die nooit wijzigen. - Apparaat vergeten - Dit apparaat kan je zonder nieuwe uitnodiging geen bestanden meer sturen. Reeds ontvangen bestanden blijven behouden. - Alle apparaten vergeten - Apparaat blokkeren - Geblokkeerde apparaten - Deblokkeren - Deblokkeren herstelt de toegang niet. Het apparaat moet opnieuw worden gekoppeld. - Bestanden sturen - Laatste overdracht %1$s Dit apparaat onthouden? %1$s biedt aan bestanden van je te ontvangen zonder nieuwe uitnodiging. Onthouden @@ -334,28 +317,8 @@ %1$s wil je “%2$s” sturen. Ontvangen Weigeren - Ongebruikte apparaten vergeten na - De teller start opnieuw bij elke overdracht met het apparaat. - Nooit - Controleren op wachtende overdrachten - 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. - Nu controleren - Niets in de wacht - Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent. - Wacht op bezorging - Annuleer de overdracht om die in te trekken. - Naar een apparaat sturen - Kies een apparaat - Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht. - %1$s heeft de overdracht geaccepteerd - %1$s heeft de overdracht geweigerd - %1$s heeft niet geantwoord %1$d bestand %1$d bestanden - - %1$d dag - %1$d dagen - diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 7cd110c..b598cb1 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -306,23 +306,6 @@ Udostępnij Niedostępne Wersja aplikacji - Urządzenia - Urządzenia, z którymi już przesyłano pliki, mogą je odbierać bez nowego zaproszenia. - Brak zapamiętanych urządzeń - Po przesłaniu plików oba urządzenia mogą zapamiętać się nawzajem. - Wymaga ponownego sparowania - To urządzenie cofnęło dostęp lub ponownie zainstalowało VniDrop. Wykonaj kolejne przesłanie, aby zapamiętać je ponownie. - Nazwa na tym urządzeniu - Tylko Ty widzisz tę nazwę. Drugie urządzenie nigdy jej nie zmieni. - Zapomnij urządzenie - To urządzenie nie będzie mogło wysyłać Ci plików bez nowego zaproszenia. Już odebrane pliki pozostaną. - Zapomnij wszystkie urządzenia - Zablokuj urządzenie - Zablokowane urządzenia - Odblokuj - Odblokowanie nie przywraca dostępu. Urządzenie trzeba sparować ponownie. - Wyślij pliki - Ostatnie przesłanie %1$s Zapamiętać to urządzenie? %1$s umożliwia wysyłanie plików bez nowego zaproszenia. Zapamiętaj @@ -334,32 +317,10 @@ %1$s chce wysłać Ci „%2$s”. Odbierz Odrzuć - Zapomnij nieużywane urządzenia po - Odliczanie zaczyna się od nowa przy każdym przesłaniu. - Nigdy - Sprawdzaj oczekujące przesyłki - Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację. - Sprawdź teraz - Nic nie czeka - To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop. - Oczekuje na dostarczenie - Anuluj przesyłkę, aby ją wycofać. - Wyślij do urządzenia - Wybierz urządzenie - Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu. - %1$s zaakceptowało przesyłkę - %1$s odrzuciło przesyłkę - %1$s nie odpowiedziało %1$d plik %1$d pliki %1$d plików %1$d pliku - - %1$d dzień - %1$d dni - %1$d dni - %1$d dnia - diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 908d63b..879e58b 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -306,23 +306,6 @@ Partilhar Indisponível Versão da app - Dispositivos - Os dispositivos com os quais já transferiu podem receber ficheiros sem um novo convite. - Nenhum dispositivo guardado - Após uma transferência, ambos os dispositivos podem optar por lembrar-se um do outro. - É preciso emparelhar novamente - Este dispositivo retirou o acesso ou reinstalou o VniDrop. Faça outra transferência para o voltar a guardar. - Nome neste dispositivo - Só você vê este nome. O outro dispositivo nunca o pode alterar. - Esquecer dispositivo - Este dispositivo deixará de lhe poder enviar ficheiros sem um novo convite. Os ficheiros já recebidos são mantidos. - Esquecer todos os dispositivos - Bloquear dispositivo - Dispositivos bloqueados - Desbloquear - Desbloquear não restaura o acesso. O dispositivo tem de ser emparelhado novamente. - Enviar ficheiros - Última transferência %1$s Lembrar este dispositivo? %1$s ofereceu-se para receber ficheiros seus sem um novo convite. Lembrar @@ -334,28 +317,8 @@ %1$s quer enviar-lhe “%2$s”. Receber Recusar - Esquecer dispositivos não usados após - A contagem reinicia sempre que transfere com o dispositivo. - Nunca - Procurar transferências em espera - Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação. - Verificar agora - Nada em espera - Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop. - A aguardar entrega - Cancele a transferência para a retirar. - Enviar para um dispositivo - Escolher um dispositivo - Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência. - %1$s aceitou a transferência - %1$s recusou a transferência - %1$s não respondeu %1$d ficheiro %1$d ficheiros - - %1$d dia - %1$d dias - diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 0037800..0b3ce11 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -306,23 +306,6 @@ Поделиться Недоступно Версия приложения - Устройства - Устройства, с которыми вы уже обменивались файлами, могут получать их без нового приглашения. - Нет сохранённых устройств - После передачи оба устройства могут запомнить друг друга. - Требуется повторное сопряжение - Это устройство отозвало доступ или переустановило VniDrop. Выполните новую передачу, чтобы снова его запомнить. - Имя на этом устройстве - Это имя видите только вы. Другое устройство не может его изменить. - Забыть устройство - Это устройство больше не сможет отправлять вам файлы без нового приглашения. Уже полученные файлы сохранятся. - Забыть все устройства - Заблокировать устройство - Заблокированные устройства - Разблокировать - Разблокировка не восстанавливает доступ. Устройство нужно сопрячь заново. - Отправить файлы - Последняя передача %1$s Запомнить это устройство? %1$s разрешает отправлять файлы без нового приглашения. Запомнить @@ -334,32 +317,10 @@ %1$s хочет отправить вам «%2$s». Получить Отклонить - Забывать неиспользуемые устройства через - Отсчёт начинается заново при каждой передаче с устройством. - Никогда - Проверять ожидающие передачи - При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение. - Проверить сейчас - Ничего не ожидает - Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop. - Ожидает доставки - Отмените передачу, чтобы отозвать её. - Отправить на устройство - Выберите устройство - Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи. - %1$s принял передачу - %1$s отклонил передачу - %1$s не ответил %1$d файл %1$d файла %1$d файлов %1$d файла - - %1$d день - %1$d дня - %1$d дней - %1$d дня - diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 666614c..7fcdf25 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -306,23 +306,6 @@ Share Not available App version - Devices - Devices you have transferred with can receive files without a new invitation. - No remembered devices - After a transfer, both devices can choose to remember each other. - Needs pairing again - This device withdrew access, or reinstalled VniDrop. Transfer to it once more to remember it again. - Name on this device - Only you see this name. The other device can never change it. - Forget device - This device will no longer be able to send you files without a new invitation. Files already received are kept. - Forget all devices - Block device - Blocked devices - Unblock - Unblocking does not restore access. The device has to be paired again. - Send files - Last transfer %1$s Remember this device? %1$s offered to let you send it files without a new invitation. Remember @@ -334,28 +317,8 @@ %1$s wants to send you “%2$s”. Receive Decline - Forget unused devices after - The countdown restarts every time you transfer with the device. - Never - Check for waiting transfers - When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app. - Check now - Nothing waiting - That device is not open. The transfer will be delivered the next time it opens VniDrop. - Waiting to be delivered - Cancel the transfer to withdraw it. - Send to a device - Choose a device - No device can be reached right now. Remembered devices appear here after a transfer. - %1$s accepted the transfer - %1$s declined the transfer - %1$s did not answer %1$d files %1$d file - - %1$d day - %1$d days -