mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
154
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal file
154
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal 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
|
||||
Reference in New Issue
Block a user