From 268fbf161de948ab78de413995e3af6920d14abb Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:21:44 +0200 Subject: [PATCH] feat(apple): send files to a remembered device Adds the Send files action to the device detail, routing the picked selection through sendToContact. Rather than a second picker path, sharePickedFiles now takes a ShareDestination, so the macOS security-scoped access handling covers both routes. A contact destination carries no access policy, matching the core's rule that an offer share is never public. --- apple/Tests/ContactsModelTests.swift | 68 ++++++++++++++++++- apple/Tests/Fakes.swift | 16 ++++- apple/VniDrop/App/AppGraph.swift | 3 +- apple/VniDrop/Core/CoreModels.swift | 10 +++ apple/VniDrop/Core/FileSystemService.swift | 5 +- .../Features/Contacts/ContactsModel.swift | 53 ++++++++++++++- .../Features/Contacts/ContactsScreen.swift | 15 +++- apple/VniDrop/Features/Send/SendModel.swift | 2 +- .../Platform/FileSystemService+iOS.swift | 16 +++-- .../Platform/FileSystemService+macOS.swift | 16 +++-- apple/VniDrop/Platform/PlatformPickers.swift | 31 +++++++++ 11 files changed, 218 insertions(+), 17 deletions(-) diff --git a/apple/Tests/ContactsModelTests.swift b/apple/Tests/ContactsModelTests.swift index eb63d9d..22cee36 100644 --- a/apple/Tests/ContactsModelTests.swift +++ b/apple/Tests/ContactsModelTests.swift @@ -22,7 +22,8 @@ final class ContactsModelTests: XCTestCase { let model = ContactsModel( repository: gateway, messages: UiMessageController(), - preferences: preferences + preferences: preferences, + fileSystemService: FakeFileSystemService() ) return (model, preferences) } @@ -213,6 +214,68 @@ final class ContactsModelTests: XCTestCase { 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( + Share(transferId: 1, ticket: "vnd1:x", transferName: "doc", contentHash: "h", fileCount: 1, totalSize: 2) + ) + + 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) + } + func testUnreachableContactIsSurfacedForRepairing() async { let gateway = FakeCoreGateway() gateway.contactsResult = .success([contact("peer", canSend: false)]) @@ -247,7 +310,8 @@ final class PairingSuggestionTests: XCTestCase { let model = ContactsModel( repository: gateway, messages: UiMessageController(), - preferences: preferences + preferences: preferences, + fileSystemService: FakeFileSystemService() ) return (model, preferences) } diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index c610183..5fb8798 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -166,8 +166,20 @@ final class FakeFileSystemService: FileSystemService { func defaultReceiveFolder() -> ReceiveFolder { folder } func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable } func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } - func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result { - await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy) + private(set) var shareDestinations: [ShareDestination] = [] + + func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result { + shareDestinations.append(destination) + switch destination { + case .invitation(let accessPolicy): + return await repository.shareSources( + [], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) + case .contact(let endpointId): + return await repository.sendToContact( + endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName + ) + } } } diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift index 927dea5..b018ecb 100644 --- a/apple/VniDrop/App/AppGraph.swift +++ b/apple/VniDrop/App/AppGraph.swift @@ -31,7 +31,8 @@ final class AppGraph: ObservableObject { self.contactsModel = ContactsModel( repository: coreRepository, messages: messages, - preferences: preferencesRepository + preferences: preferencesRepository, + fileSystemService: dependencies.fileSystemService ) self.approvalCoordinator = ApprovalCoordinator( repository: coreRepository, diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 567d2d2..89a25cd 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -72,6 +72,16 @@ enum ShareAccessPolicy: Equatable, Sendable { case anyoneWithTransfer } +/// Where a picked selection is going. +/// +/// A contact destination deliberately carries no access policy: the core forces +/// approval-required for offers, so exposing the choice here would imply a +/// setting that does not exist. +enum ShareDestination: Equatable, Sendable { + case invitation(accessPolicy: ShareAccessPolicy) + case contact(endpointId: String) +} + enum TransferDirection: Equatable, Sendable { case send case receive diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index 401d388..d6e1af7 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -33,12 +33,15 @@ protocol FileSystemService { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result /// Releases only app-owned picker copies; never deletes original user sources. func discardPickedFiles(_ files: [PickedShareFile]) async + /// Imports a picked selection, either as an invitation or straight to a + /// remembered device. One entry point so the platform's security-scoped + /// access handling covers both. func sharePickedFiles( repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, - accessPolicy: ShareAccessPolicy + destination: ShareDestination ) async -> Result } diff --git a/apple/VniDrop/Features/Contacts/ContactsModel.swift b/apple/VniDrop/Features/Contacts/ContactsModel.swift index 89391f2..2fc0d15 100644 --- a/apple/VniDrop/Features/Contacts/ContactsModel.swift +++ b/apple/VniDrop/Features/Contacts/ContactsModel.swift @@ -52,19 +52,28 @@ struct ContactsState: Equatable { 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 + preferences: AppPreferencesRepository, + fileSystemService: FileSystemService ) { self.repository = repository self.messages = messages self.preferences = preferences + self.fileSystemService = fileSystemService state.grantLifetime = preferences.preferences.grantLifetime repository.signals @@ -247,6 +256,48 @@ final class ContactsModel: ObservableObject { 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: + messages.tryShow(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success)) + await refresh() + case .failure(let error): + messages.error(error) + } + } + // MARK: - Management func setLabel(endpointId: String, label: String) async { diff --git a/apple/VniDrop/Features/Contacts/ContactsScreen.swift b/apple/VniDrop/Features/Contacts/ContactsScreen.swift index 870d9d2..6ecf3b1 100644 --- a/apple/VniDrop/Features/Contacts/ContactsScreen.swift +++ b/apple/VniDrop/Features/Contacts/ContactsScreen.swift @@ -220,7 +220,19 @@ struct ContactDetailScreen: View { .textSelection(.enabled) } - if !contact.canSend { + 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), @@ -247,6 +259,7 @@ struct ContactDetailScreen: View { } .formStyle(.grouped) .navigationTitle(Text(contact?.displayName ?? "")) + .contactSendPickers(model: model) .onAppear { label = contact?.localLabel ?? "" } .onDisappear { commitLabel() } .confirmationDialog( diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 3e20f6b..4ac328d 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -353,7 +353,7 @@ final class SendModel: ObservableObject { files: current.selectedFiles, transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines), senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines), - accessPolicy: current.accessPolicy + destination: .invitation(accessPolicy: current.accessPolicy) ) switch result { case .success(let share): diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index 5827dc9..5ebf468 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -59,15 +59,23 @@ struct IosFileSystemService: FileSystemService { files: [PickedShareFile], transferName: String, senderName: String, - accessPolicy: ShareAccessPolicy + destination: ShareDestination ) async -> Result { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) } let sources = files.map { $0.toIosShareSource() } - return await repository.shareSources( - sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy - ) + switch destination { + case .invitation(let accessPolicy): + return await repository.shareSources( + sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) + case .contact(let endpointId): + return await repository.sendToContact( + endpointId: endpointId, sources: sources, + transferName: transferName, senderName: senderName + ) + } } private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus { diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index 9bf6135..b84eb5f 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -39,7 +39,7 @@ struct MacFileSystemService: FileSystemService { files: [PickedShareFile], transferName: String, senderName: String, - accessPolicy: ShareAccessPolicy + destination: ShareDestination ) async -> Result { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) @@ -63,9 +63,17 @@ struct MacFileSystemService: FileSystemService { let sources = files.map { ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) } - return await repository.shareSources( - sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy - ) + switch destination { + case .invitation(let accessPolicy): + return await repository.shareSources( + sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) + case .contact(let endpointId): + return await repository.sendToContact( + endpointId: endpointId, sources: sources, + transferName: transferName, senderName: senderName + ) + } } } #endif diff --git a/apple/VniDrop/Platform/PlatformPickers.swift b/apple/VniDrop/Platform/PlatformPickers.swift index 184b45c..69589c5 100644 --- a/apple/VniDrop/Platform/PlatformPickers.swift +++ b/apple/VniDrop/Platform/PlatformPickers.swift @@ -70,6 +70,33 @@ struct SendPickers: ViewModifier { } } +/// File picker for "send to this device", reusing the share picker's selection +/// handling so security-scoped bookmarks are captured the same way. +struct ContactSendPickers: ViewModifier { + @ObservedObject var model: ContactsModel + + func body(content: Content) -> some View { + content + .fileImporter( + isPresented: $model.pendingFilePick, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) } + if files.isEmpty { + model.onFilePickFailed("The selected document could not be opened") + } else { + Task { await model.onFilesPicked(files) } + } + case .failure(let error): + if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) } + } + } + } +} + enum PickerSupport { static func receiveFolder(from url: URL) -> ReceiveFolder { #if os(iOS) @@ -129,4 +156,8 @@ extension View { func sendPickers(model: SendModel) -> some View { modifier(SendPickers(model: model)) } + + func contactSendPickers(model: ContactsModel) -> some View { + modifier(ContactSendPickers(model: model)) + } }