Acord/ios/src/DocumentPicker.swift

134 lines
5.4 KiB
Swift

import UIKit
import UniformTypeIdentifiers
/// Bridges UIDocumentPickerViewController into the Rust viewport.
/// Open and Save flows rely on iOS's per-file permission grant a
/// security-scoped URL is what the picker hands back, and we copy bytes in
/// or out under `startAccessingSecurityScopedResource` while it's in scope.
enum DocumentPicker {
private static var openDelegate: OpenDelegate?
private static var saveDelegate: SaveDelegate?
static func presentOpen(handle: OpaquePointer) {
dlog("presentOpen called")
guard let root = topViewController() else {
dlog("presentOpen: topViewController returned nil — picker NOT shown")
return
}
// .item is the broadest "any file" UTI without this, files whose UTI
// doesn't exactly match get rendered grey/unselectable in the picker.
// asCopy:true sidesteps the security-scoped-resource entitlement dance:
// iOS hands us a copy in our sandbox tmp dir we can just read.
var types: [UTType] = [.plainText, .utf8PlainText, .text, .sourceCode, .data, .item]
if let md = UTType(filenameExtension: "md") { types.insert(md, at: 0) }
if let md = UTType("net.daringfireball.markdown") { types.insert(md, at: 0) }
dlog("presentOpen: types=\(types.map(\.identifier))")
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
let delegate = OpenDelegate(handle: handle)
openDelegate = delegate
picker.delegate = delegate
picker.allowsMultipleSelection = false
root.present(picker, animated: true) {
dlog("presentOpen: picker presented from \(type(of: root))")
}
}
static func presentSave(handle: OpaquePointer, defaultName: String) {
dlog("presentSave called")
guard let root = topViewController() else {
dlog("presentSave: topViewController returned nil — picker NOT shown")
return
}
guard let cstr = viewport_get_text(handle) else {
dlog("presentSave: viewport_get_text returned null")
return
}
let text = String(cString: cstr)
viewport_free_string(cstr)
dlog("presentSave: serialized \(text.utf8.count) bytes from viewport")
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("\(defaultName).md")
do {
try text.data(using: .utf8)?.write(to: tmp)
dlog("presentSave: wrote tmp \(tmp.path)")
} catch {
dlog("presentSave: tmp write failed: \(error)")
return
}
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
let delegate = SaveDelegate(handle: handle, source: tmp)
saveDelegate = delegate
picker.delegate = delegate
root.present(picker, animated: true) {
dlog("presentSave: picker presented")
}
}
private static func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
dlog("topViewController: no UIWindowScene")
return nil
}
guard let window = scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first else {
dlog("topViewController: no window in scene")
return nil
}
var top = window.rootViewController
while let presented = top?.presentedViewController { top = presented }
return top
}
}
private final class OpenDelegate: NSObject, UIDocumentPickerDelegate {
let handle: OpaquePointer
init(handle: OpaquePointer) { self.handle = handle }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
dlog("open delegate fired with \(urls.count) urls")
guard let url = urls.first else {
dlog("open: no url in selection")
return
}
dlog("open: url=\(url.path)")
// asCopy:true means url is already in our sandbox tmp dir no scoped access needed.
do {
let data = try Data(contentsOf: url)
guard let text = String(data: data, encoding: .utf8) else {
dlog("open: file at \(url.path) is not utf-8 (\(data.count) bytes)")
return
}
text.withCString { cstr in
viewport_set_text(handle, cstr)
}
dlog("open: loaded \(data.count) bytes (\(text.count) chars) from \(url.lastPathComponent)")
} catch {
dlog("open: read failed: \(error)")
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
dlog("open: cancelled by user")
}
}
private final class SaveDelegate: NSObject, UIDocumentPickerDelegate {
let handle: OpaquePointer
let source: URL
init(handle: OpaquePointer, source: URL) {
self.handle = handle
self.source = source
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
dlog("save: picker resolved with destinations=\(urls.map(\.path))")
try? FileManager.default.removeItem(at: source)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
dlog("save: cancelled by user")
try? FileManager.default.removeItem(at: source)
}
}