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.
This commit is contained in:
2026-08-06 18:21:44 +02:00
parent dc23e87c56
commit 268fbf161d
11 changed files with 218 additions and 17 deletions

View File

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

View File

@@ -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<Share, Error> {
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
private(set) var shareDestinations: [ShareDestination] = []
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
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
)
}
}
}

View File

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

View File

@@ -72,6 +72,16 @@ enum ShareAccessPolicy: Equatable, Sendable {
case anyoneWithTransfer
}
/// Where a picked selection is going.
///
/// A contact destination deliberately carries no access policy: the core forces
/// approval-required for offers, so exposing the choice here would imply a
/// setting that does not exist.
enum ShareDestination: Equatable, Sendable {
case invitation(accessPolicy: ShareAccessPolicy)
case contact(endpointId: String)
}
enum TransferDirection: Equatable, Sendable {
case send
case receive

View File

@@ -33,12 +33,15 @@ protocol FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
/// Releases only app-owned picker copies; never deletes original user sources.
func discardPickedFiles(_ files: [PickedShareFile]) async
/// Imports a picked selection, either as an invitation or straight to a
/// remembered device. One entry point so the platform's security-scoped
/// access handling covers both.
func sharePickedFiles(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
destination: ShareDestination
) async -> Result<Share, Error>
}

View File

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

View File

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

View File

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

View File

@@ -59,15 +59,23 @@ struct IosFileSystemService: FileSystemService {
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
destination: ShareDestination
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
switch destination {
case .invitation(let accessPolicy):
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
case .contact(let endpointId):
return await repository.sendToContact(
endpointId: endpointId, sources: sources,
transferName: transferName, senderName: senderName
)
}
}
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {

View File

@@ -39,7 +39,7 @@ struct MacFileSystemService: FileSystemService {
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
destination: ShareDestination
) async -> Result<Share, Error> {
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

View File

@@ -70,6 +70,33 @@ struct SendPickers: ViewModifier {
}
}
/// File picker for "send to this device", reusing the share picker's selection
/// handling so security-scoped bookmarks are captured the same way.
struct ContactSendPickers: ViewModifier {
@ObservedObject var model: ContactsModel
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: $model.pendingFilePick,
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) }
if files.isEmpty {
model.onFilePickFailed("The selected document could not be opened")
} else {
Task { await model.onFilesPicked(files) }
}
case .failure(let error):
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
}
}
}
}
enum PickerSupport {
static func receiveFolder(from url: URL) -> ReceiveFolder {
#if os(iOS)
@@ -129,4 +156,8 @@ extension View {
func sendPickers(model: SendModel) -> some View {
modifier(SendPickers(model: model))
}
func contactSendPickers(model: ContactsModel) -> some View {
modifier(ContactSendPickers(model: model))
}
}