mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
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:
@@ -22,7 +22,8 @@ final class ContactsModelTests: XCTestCase {
|
|||||||
let model = ContactsModel(
|
let model = ContactsModel(
|
||||||
repository: gateway,
|
repository: gateway,
|
||||||
messages: UiMessageController(),
|
messages: UiMessageController(),
|
||||||
preferences: preferences
|
preferences: preferences,
|
||||||
|
fileSystemService: FakeFileSystemService()
|
||||||
)
|
)
|
||||||
return (model, preferences)
|
return (model, preferences)
|
||||||
}
|
}
|
||||||
@@ -213,6 +214,68 @@ final class ContactsModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(subject.displayName, "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(
|
||||||
|
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 {
|
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||||
let gateway = FakeCoreGateway()
|
let gateway = FakeCoreGateway()
|
||||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||||
@@ -247,7 +310,8 @@ final class PairingSuggestionTests: XCTestCase {
|
|||||||
let model = ContactsModel(
|
let model = ContactsModel(
|
||||||
repository: gateway,
|
repository: gateway,
|
||||||
messages: UiMessageController(),
|
messages: UiMessageController(),
|
||||||
preferences: preferences
|
preferences: preferences,
|
||||||
|
fileSystemService: FakeFileSystemService()
|
||||||
)
|
)
|
||||||
return (model, preferences)
|
return (model, preferences)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,20 @@ final class FakeFileSystemService: FileSystemService {
|
|||||||
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
||||||
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
||||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
private(set) var shareDestinations: [ShareDestination] = []
|
||||||
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ final class AppGraph: ObservableObject {
|
|||||||
self.contactsModel = ContactsModel(
|
self.contactsModel = ContactsModel(
|
||||||
repository: coreRepository,
|
repository: coreRepository,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
preferences: preferencesRepository
|
preferences: preferencesRepository,
|
||||||
|
fileSystemService: dependencies.fileSystemService
|
||||||
)
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: coreRepository,
|
repository: coreRepository,
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ enum ShareAccessPolicy: Equatable, Sendable {
|
|||||||
case anyoneWithTransfer
|
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 {
|
enum TransferDirection: Equatable, Sendable {
|
||||||
case send
|
case send
|
||||||
case receive
|
case receive
|
||||||
|
|||||||
@@ -33,12 +33,15 @@ protocol FileSystemService {
|
|||||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
||||||
/// Releases only app-owned picker copies; never deletes original user sources.
|
/// Releases only app-owned picker copies; never deletes original user sources.
|
||||||
func discardPickedFiles(_ files: [PickedShareFile]) async
|
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(
|
func sharePickedFiles(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
files: [PickedShareFile],
|
files: [PickedShareFile],
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
destination: ShareDestination
|
||||||
) async -> Result<Share, Error>
|
) async -> Result<Share, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,19 +52,28 @@ struct ContactsState: Equatable {
|
|||||||
final class ContactsModel: ObservableObject {
|
final class ContactsModel: ObservableObject {
|
||||||
@Published private(set) var state = ContactsState()
|
@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 repository: CoreGateway
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private let preferences: AppPreferencesRepository
|
private let preferences: AppPreferencesRepository
|
||||||
|
private let fileSystemService: FileSystemService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
messages: UiMessageController,
|
messages: UiMessageController,
|
||||||
preferences: AppPreferencesRepository
|
preferences: AppPreferencesRepository,
|
||||||
|
fileSystemService: FileSystemService
|
||||||
) {
|
) {
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
self.preferences = preferences
|
self.preferences = preferences
|
||||||
|
self.fileSystemService = fileSystemService
|
||||||
state.grantLifetime = preferences.preferences.grantLifetime
|
state.grantLifetime = preferences.preferences.grantLifetime
|
||||||
|
|
||||||
repository.signals
|
repository.signals
|
||||||
@@ -247,6 +256,48 @@ final class ContactsModel: ObservableObject {
|
|||||||
return ticket
|
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
|
// MARK: - Management
|
||||||
|
|
||||||
func setLabel(endpointId: String, label: String) async {
|
func setLabel(endpointId: String, label: String) async {
|
||||||
|
|||||||
@@ -220,7 +220,19 @@ struct ContactDetailScreen: View {
|
|||||||
.textSelection(.enabled)
|
.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 {
|
Section {
|
||||||
Label(
|
Label(
|
||||||
String(localized: L10n.Contacts.unreachableBody),
|
String(localized: L10n.Contacts.unreachableBody),
|
||||||
@@ -247,6 +259,7 @@ struct ContactDetailScreen: View {
|
|||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(Text(contact?.displayName ?? ""))
|
.navigationTitle(Text(contact?.displayName ?? ""))
|
||||||
|
.contactSendPickers(model: model)
|
||||||
.onAppear { label = contact?.localLabel ?? "" }
|
.onAppear { label = contact?.localLabel ?? "" }
|
||||||
.onDisappear { commitLabel() }
|
.onDisappear { commitLabel() }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ final class SendModel: ObservableObject {
|
|||||||
files: current.selectedFiles,
|
files: current.selectedFiles,
|
||||||
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
accessPolicy: current.accessPolicy
|
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||||
)
|
)
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let share):
|
case .success(let share):
|
||||||
|
|||||||
@@ -59,15 +59,23 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
files: [PickedShareFile],
|
files: [PickedShareFile],
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
destination: ShareDestination
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.shareEmpty)
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
let sources = files.map { $0.toIosShareSource() }
|
let sources = files.map { $0.toIosShareSource() }
|
||||||
|
switch destination {
|
||||||
|
case .invitation(let accessPolicy):
|
||||||
return await repository.shareSources(
|
return await repository.shareSources(
|
||||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
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 {
|
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct MacFileSystemService: FileSystemService {
|
|||||||
files: [PickedShareFile],
|
files: [PickedShareFile],
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
destination: ShareDestination
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.shareEmpty)
|
return .failure(InvitationError.shareEmpty)
|
||||||
@@ -63,9 +63,17 @@ struct MacFileSystemService: FileSystemService {
|
|||||||
let sources = files.map {
|
let sources = files.map {
|
||||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||||
}
|
}
|
||||||
|
switch destination {
|
||||||
|
case .invitation(let accessPolicy):
|
||||||
return await repository.shareSources(
|
return await repository.shareSources(
|
||||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||||
)
|
)
|
||||||
|
case .contact(let endpointId):
|
||||||
|
return await repository.sendToContact(
|
||||||
|
endpointId: endpointId, sources: sources,
|
||||||
|
transferName: transferName, senderName: senderName
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -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 {
|
enum PickerSupport {
|
||||||
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -129,4 +156,8 @@ extension View {
|
|||||||
func sendPickers(model: SendModel) -> some View {
|
func sendPickers(model: SendModel) -> some View {
|
||||||
modifier(SendPickers(model: model))
|
modifier(SendPickers(model: model))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contactSendPickers(model: ContactsModel) -> some View {
|
||||||
|
modifier(ContactSendPickers(model: model))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user