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