feat(apple): native SwiftUI app for iOS and macOS

Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
This commit is contained in:
2026-07-18 22:06:30 +02:00
parent 601cbc486e
commit ea376a382b
68 changed files with 8650 additions and 1 deletions

View File

@@ -0,0 +1,50 @@
#if os(iOS)
import Foundation
import UIKit
/// Builds the iOS dependency graph, ported from `rememberIosAppDependencies`.
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)",
appVersion: version,
defaultCoreDataDir: applicationDataDirectory(),
defaultUsername: device.name.isEmpty ? "Receiver" : device.name
)
return AppDependencies(
environment: env,
deviceInfoProvider: IosDeviceInfoProvider(),
fileSystemService: IosFileSystemService(),
notificationService: LocalNotificationService(),
externalInvitations: externalInvitations
)
}
private func applicationDataDirectory() -> String {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("VniDrop").path
}
private struct IosDeviceInfoProvider: DeviceInfoProvider {
@MainActor
func load() async -> DeviceInfo {
let device = UIDevice.current
let battery: String? = {
let wasMonitoring = device.isBatteryMonitoringEnabled
device.isBatteryMonitoringEnabled = true
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
let level = device.batteryLevel
return level >= 0 ? "\(Int(level * 100))%" : nil
}()
return DeviceInfo(
deviceName: device.name,
deviceModel: device.model,
operatingSystem: "\(device.systemName) \(device.systemVersion)",
network: nil,
batteryLevel: battery
)
}
}
#endif

View File

@@ -0,0 +1,50 @@
#if os(macOS)
import Foundation
import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
appVersion: version,
defaultCoreDataDir: applicationDataDirectory(),
defaultUsername: host
)
return AppDependencies(
environment: env,
deviceInfoProvider: MacDeviceInfoProvider(),
fileSystemService: MacFileSystemService(),
notificationService: LocalNotificationService(),
externalInvitations: externalInvitations
)
}
private func applicationDataDirectory() -> String {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("VniDrop").path
}
private struct MacDeviceInfoProvider: DeviceInfoProvider {
func load() async -> DeviceInfo {
DeviceInfo(
deviceName: Host.current().localizedName,
deviceModel: modelIdentifier(),
operatingSystem: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
network: nil,
batteryLevel: nil
)
}
private func modelIdentifier() -> String? {
var size = 0
sysctlbyname("hw.model", nil, &size, nil, 0)
guard size > 0 else { return nil }
var model = [CChar](repeating: 0, count: size)
sysctlbyname("hw.model", &model, &size, nil, 0)
return String(cString: model)
}
}
#endif

View File

@@ -0,0 +1,98 @@
#if os(iOS)
import Foundation
import UIKit
import VnidropCore
/// iOS file system service, ported from `FileSystemService.ios.kt`.
/// App-owned Documents is the fixed receive folder; custom folders are not
/// supported because raw external picker URLs do not survive relaunch.
struct IosFileSystemService: FileSystemService {
var supportsCustomReceiveFolders: Bool { false }
func defaultReceiveFolder() -> ReceiveFolder {
let path = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)
.first?.path ?? ""
return ReceiveFolder(kind: .fileSystemPath, value: path, displayName: "Documents")
}
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
switch folder.kind {
case .fileSystemPath:
return FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
case .iosSecurityScopedUrl:
return validateSecurityScopedUrl(folder.value)
}
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool {
folder.kind == .fileSystemPath
&& folder.value.trimmingTrailingSlash == defaultReceiveFolder().value.trimmingTrailingSlash
}
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else {
return .failure(InvitationError.message("The receive folder is not VniDrop Documents"))
}
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
return .failure(InvitationError.message("The Files location URL is unavailable"))
}
let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:]) { success in
continuation.resume(returning: success)
}
}
}
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files"))
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
for path in paths {
try? FileManager.default.removeItem(atPath: path)
}
}
func sharePickedFiles(
repository: CoreRepository,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
}
let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
let url = URL(string: value) ?? URL(fileURLWithPath: value)
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
if FileManager.default.isWritableFile(atPath: url.path) {
return .writable
}
return .permissionRequired
}
}
extension PickedShareFile {
/// iOS shares by filesystem path (from `asCopy` picker temp files).
func toIosShareSource() -> ShareSource {
ShareSource(kind: .path, value: value, displayName: displayName, isDirectory: isDirectory)
}
}
private extension String {
var trimmingTrailingSlash: String {
var s = self
while s.hasSuffix("/") { s.removeLast() }
return s
}
}
#endif

View File

@@ -0,0 +1,55 @@
#if os(macOS)
import Foundation
import AppKit
import VnidropCore
/// macOS file system service. Mirrors the desktop JVM behavior: default Downloads
/// receive folder, custom folders enabled via security-scoped bookmarks, reveal in
/// Finder. The Rust core streams bytes; Swift passes filesystem paths.
struct MacFileSystemService: FileSystemService {
var supportsCustomReceiveFolders: Bool { true }
func defaultReceiveFolder() -> ReceiveFolder {
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads")
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
}
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { true }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
let url = URL(fileURLWithPath: folder.value, isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url])
return .success(())
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
for path in paths {
try? FileManager.default.removeItem(atPath: path)
}
}
func sharePickedFiles(
repository: CoreRepository,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
}
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
)
}
}
#endif

View File

@@ -0,0 +1,21 @@
import Foundation
/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS
/// helper in `TransferShareActions.ios.kt`.
func invitationFileName(_ transferName: String) -> String {
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
let base = trimmed.isEmpty ? "invitation" : trimmed
let safe = base.components(separatedBy: CharacterSet.alphanumerics.inverted.subtracting(CharacterSet(charactersIn: "-_ ")))
.joined()
.replacingOccurrences(of: " ", with: "-")
let name = safe.isEmpty ? "invitation" : safe
return "\(name).\(vniDropInvitationExtension)"
}
/// Writes a temporary `.vnd` file for sharing/exporting.
func writeTemporaryInvitation(ticket: String, transferName: String) throws -> URL {
let dir = FileManager.default.temporaryDirectory
let url = dir.appendingPathComponent(invitationFileName(transferName))
try ticket.write(to: url, atomically: true, encoding: .utf8)
return url
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
import UniformTypeIdentifiers
/// Root-level pickers that are NOT triggered from inside a sheet. Currently just
/// the receive-folder picker (Settings is presented in the tab's navigation
/// stack, not a sheet, so presenting from the root works).
struct PlatformPickers: ViewModifier {
@ObservedObject var settingsModel: SettingsModel
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: Binding(get: { settingsModel.pendingReceiveFolderPick }, set: { settingsModel.pendingReceiveFolderPick = $0 }),
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
settingsModel.onReceiveFolderPicked(PickerSupport.receiveFolder(from: url))
case .failure(let error):
if !error.isUserCancellation { settingsModel.onReceiveFolderPickFailed(error.technicalDetail) }
}
}
}
}
/// Send file/folder pickers. Must be attached to the composer view so the picker
/// presents from the composer's sheet, not the already-presenting root controller.
struct SendPickers: ViewModifier {
@ObservedObject var model: SendModel
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: Binding(get: { model.pendingFilePick }, set: { model.pendingFilePick = $0 }),
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { result in
handleShareSelection(result, isDirectory: false)
}
.fileImporter(
isPresented: Binding(get: { model.pendingFolderPick }, set: { model.pendingFolderPick = $0 }),
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
handleShareSelection(result, isDirectory: true)
}
}
private func handleShareSelection(_ result: Result<[URL], Error>, isDirectory: Bool) {
switch result {
case .success(let urls):
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
if files.isEmpty {
model.onFilePickFailed("The selected document could not be opened")
} else {
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)
// External receive folders on iOS use security-scoped URLs; the core holds
// access while streaming. Store the URL string.
return ReceiveFolder(kind: .iosSecurityScopedUrl, value: url.absoluteString, displayName: url.lastPathComponent)
#else
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
#endif
}
static func pickedFile(from url: URL, isDirectory: Bool) -> PickedShareFile? {
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
#if os(iOS)
// Copy into a temporary sandbox location so the core can read the file
// after the picker/security scope ends. Folders are passed by path.
if isDirectory {
return PickedShareFile(value: url.path, displayName: url.lastPathComponent, isDirectory: true)
}
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("share-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let dest = tempDir.appendingPathComponent(url.lastPathComponent)
do {
try FileManager.default.copyItem(at: url, to: dest)
} catch {
return nil
}
let size = (try? dest.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
return PickedShareFile(
value: dest.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: true, isDirectory: false
)
#else
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
return PickedShareFile(
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: false, isDirectory: isDirectory
)
#endif
}
}
extension View {
func platformPickers(settingsModel: SettingsModel) -> some View {
modifier(PlatformPickers(settingsModel: settingsModel))
}
func sendPickers(model: SendModel) -> some View {
modifier(SendPickers(model: model))
}
}

View File

@@ -0,0 +1,262 @@
#if os(iOS)
import UIKit
import AVFoundation
import CoreNFC
import UniformTypeIdentifiers
@MainActor
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
/// iOS invitation acquisition, ported from `ReceiveInvitationActions.ios.kt`:
/// document picker, camera QR scanner, and NFC read.
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
private var documentResult: ((Result<String, Error>) -> Void)?
private var nfcReader: InvitationNfcReader?
private var qrController: QrScannerViewController?
var fileAvailability: ReceiveMethodAvailability { .available }
var qrAvailability: ReceiveMethodAvailability {
AVCaptureDevice.default(for: .video) != nil ? .available : .unavailable
}
var nfcAvailability: ReceiveMethodAvailability {
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
}
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
documentResult = onResult
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: true)
picker.delegate = self
picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
}
presenter.present(picker, animated: true)
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
}
ensureCameraAccess { [weak self] granted in
guard let self else { return }
guard granted else {
return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes")))
}
let scanner = QrScannerViewController { result in
self.qrController = nil
onResult(result)
}
self.qrController = scanner
scanner.modalPresentationStyle = .fullScreen
presenter.present(scanner, animated: true)
}
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard NFCNDEFReaderSession.readingAvailable else {
return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device")))
}
let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil
onResult(result)
}
nfcReader = reader
reader.start()
}
func cancel() {
nfcReader?.cancel()
nfcReader = nil
qrController?.cancelScan()
qrController = nil
documentResult = nil
}
// UIDocumentPickerDelegate
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let result = documentResult
documentResult = nil
result?(Result {
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") }
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
guard data.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
return try decodeInvitationBytes(data)
})
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
documentResult = nil
}
private func ensureCameraAccess(_ completion: @escaping (Bool) -> Void) {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
completion(true)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async { completion(granted) }
}
default:
completion(false)
}
}
}
/// Full-screen camera QR scanner, ported from `QrScannerViewController`.
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
private let onResult: (Result<String, Error>) -> Void
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var finished = false
init(onResult: @escaping (Result<String, Error>) -> Void) {
self.onResult = onResult
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let hint = UILabel(frame: view.bounds)
hint.text = "Point the camera at a VniDrop QR code"
hint.textColor = .white
hint.textAlignment = .center
hint.numberOfLines = 0
hint.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(hint)
let close = UIButton(type: .system)
close.setTitle("Cancel", for: .normal)
close.setTitleColor(.white, for: .normal)
close.frame = CGRect(x: 16, y: 52, width: 88, height: 36)
close.addAction(UIAction { [weak self] _ in self?.cancelScan() }, for: .touchUpInside)
view.addSubview(close)
configureSession()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if session.isRunning { session.stopRunning() }
}
func cancelScan() {
finish(.failure(InvitationError.message("QR scanning was cancelled")))
}
private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
return finish(.failure(InvitationError.message("No camera is available")))
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
session.sessionPreset = .high
DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() }
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
let value = metadataObjects
.compactMap { $0 as? AVMetadataMachineReadableCodeObject }
.first { $0.type == .qr }?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !value.isEmpty { finish(.success(value)) }
}
private func finish(_ result: Result<String, Error>) {
if finished { return }
finished = true
if session.isRunning { session.stopRunning() }
if presentingViewController != nil {
dismiss(animated: true) { self.onResult(result) }
} else {
onResult(result)
}
}
}
/// NFC invitation reader, ported from `InvitationNfcReader`.
final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate {
private let onResult: (Result<String, Error>) -> Void
private var session: NFCNDEFReaderSession?
private var finished = false
init(onResult: @escaping (Result<String, Error>) -> Void) {
self.onResult = onResult
}
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
session = reader
reader.begin()
}
func cancel() {
session?.invalidate()
session = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200
finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
let result = Result<String, Error> {
let ticket = messages
.flatMap { $0.records }
.compactMap { payloadAsInvitation($0) }
.first
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") }
return ticket
}
session.invalidate()
finish(result)
}
private func finish(_ result: Result<String, Error>) {
if finished { return }
finished = true
session = nil
DispatchQueue.main.async { self.onResult(result) }
}
}
private func payloadAsInvitation(_ payload: NFCNDEFPayload) -> String? {
guard let type = String(data: payload.type, encoding: .utf8) else { return nil }
let data = payload.payload
if payload.typeNameFormat == .media && (type == vniDropInvitationMimeType || type.hasPrefix("text/")) {
return try? decodeInvitationBytes(data)
}
return nil
}
#endif

View File

@@ -0,0 +1,45 @@
#if os(macOS)
import AppKit
import UniformTypeIdentifiers
@MainActor
func makeReceiveInvitationActions() -> ReceiveInvitationActions { MacReceiveInvitationActions() }
/// macOS invitation acquisition: file picker only. QR (camera) and NFC are hidden
/// on the desktop, matching the availability model.
final class MacReceiveInvitationActions: ReceiveInvitationActions {
var fileAvailability: ReceiveMethodAvailability { .available }
var qrAvailability: ReceiveMethodAvailability { .hidden }
var nfcAvailability: ReceiveMethodAvailability { .hidden }
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if let vnd = UTType(filenameExtension: vniDropInvitationExtension) {
panel.allowedContentTypes = [vnd, .data, .text]
}
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled")))
return
}
onResult(Result {
let data = try Data(contentsOf: url)
return try decodeInvitationBytes(data)
})
}
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS")))
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
}
func cancel() {}
}
#endif

View File

@@ -0,0 +1,154 @@
#if os(iOS)
import UIKit
import CoreNFC
@MainActor
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
/// iOS invitation delivery, ported from `TransferShareActions.ios.kt`: export via
/// document picker, native share via `UIActivityViewController`, and NFC write.
final class IosTransferShareActions: NSObject, TransferShareActions {
private var nfcWriter: InvitationNfcWriter?
var canUseNativeShare: Bool { true }
var nfcAvailability: NfcShareAvailability {
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
}
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(Result {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.modalPresentationStyle = .formSheet
try present(picker)
})
}
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(Result {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
controller.modalPresentationStyle = .formSheet
try present(controller)
})
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
return
}
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
self?.nfcWriter = nil
onResult(result)
}
nfcWriter = writer
writer.start()
}
func cancelNfcWrite() {
nfcWriter?.cancel()
nfcWriter = nil
}
@MainActor
private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else {
throw InvitationError.message("Could not find an iOS view controller")
}
presenter.present(controller, animated: true)
}
}
/// Writes a VniDrop invitation to a writable NDEF tag, ported from
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate {
private let ticket: String
private let onResult: (Result<Void, Error>) -> Void
private var session: NFCNDEFReaderSession?
private var finished = false
init(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
self.ticket = ticket
self.onResult = onResult
}
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
session = reader
reader.begin()
}
func cancel() {
session?.invalidate()
session = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let tag = tags.first else {
return finish(.failure(InvitationError.message("No NFC tag was detected")))
}
session.connect(to: tag) { [weak self] connectError in
guard let self else { return }
if let connectError { return self.finish(.failure(connectError)) }
tag.queryNDEFStatus { status, _, queryError in
if let queryError { return self.finish(.failure(queryError)) }
switch status {
case .notSupported:
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
case .readOnly:
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
default:
guard let message = self.invitationMessage() else {
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC")))
}
tag.writeNDEF(message) { writeError in
if let writeError {
self.finish(.failure(writeError))
} else {
session.alertMessage = "Invitation written"
session.invalidate()
self.finish(.success(()))
}
}
}
}
}
}
private func invitationMessage() -> NFCNDEFMessage? {
guard let type = vniDropInvitationMimeType.data(using: .utf8),
let payload = ticket.data(using: .utf8) else { return nil }
let record = NFCNDEFPayload(format: .media, type: type, identifier: Data(), payload: payload)
return NFCNDEFMessage(records: [record])
}
private func finish(_ result: Result<Void, Error>) {
if finished { return }
finished = true
session = nil
DispatchQueue.main.async { self.onResult(result) }
}
}
@MainActor
func topPresenter() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let keyWindow = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
var controller = keyWindow?.rootViewController
while let presented = controller?.presentedViewController {
controller = presented
}
return controller
}
#endif

View File

@@ -0,0 +1,48 @@
#if os(macOS)
import AppKit
import SwiftUI
@MainActor
func makePlatformShareActions() -> TransferShareActions { MacTransferShareActions() }
/// macOS invitation delivery, mirroring the iOS actions: save panel export and
/// `NSSharingServicePicker` native share. NFC is unavailable on macOS.
final class MacTransferShareActions: TransferShareActions {
var canUseNativeShare: Bool { true }
var nfcAvailability: NfcShareAvailability { .hidden }
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
let panel = NSSavePanel()
panel.nameFieldStringValue = invitationFileName(transferName)
panel.allowedContentTypes = []
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled")))
return
}
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
}
}
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.message("No window available")))
return
}
let picker = NSSharingServicePicker(items: [url])
picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
onResult(.success(()))
} catch {
onResult(.failure(error))
}
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
}
func cancelNfcWrite() {}
}
#endif