mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +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:
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user