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:
50
apple/VniDrop/Platform/AppDependencies+iOS.swift
Normal file
50
apple/VniDrop/Platform/AppDependencies+iOS.swift
Normal 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
|
||||
50
apple/VniDrop/Platform/AppDependencies+macOS.swift
Normal file
50
apple/VniDrop/Platform/AppDependencies+macOS.swift
Normal 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
|
||||
98
apple/VniDrop/Platform/FileSystemService+iOS.swift
Normal file
98
apple/VniDrop/Platform/FileSystemService+iOS.swift
Normal 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
|
||||
55
apple/VniDrop/Platform/FileSystemService+macOS.swift
Normal file
55
apple/VniDrop/Platform/FileSystemService+macOS.swift
Normal 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
|
||||
21
apple/VniDrop/Platform/InvitationFile.swift
Normal file
21
apple/VniDrop/Platform/InvitationFile.swift
Normal 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
|
||||
}
|
||||
119
apple/VniDrop/Platform/PlatformPickers.swift
Normal file
119
apple/VniDrop/Platform/PlatformPickers.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
262
apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift
Normal file
262
apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift
Normal 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
|
||||
45
apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift
Normal file
45
apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift
Normal 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
|
||||
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
|
||||
48
apple/VniDrop/Platform/TransferShareActions+macOS.swift
Normal file
48
apple/VniDrop/Platform/TransferShareActions+macOS.swift
Normal 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
|
||||
Reference in New Issue
Block a user