commit 6933a6cdf18b0db3037f8528e7cffbf306e28b2d
parent acfb31baaa6a3364b46e29c2776149e66bebfd16
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 02:16:05 -0700
kit: add SwiftUI document presentation
- add AppleKit-owned SwiftUI import and export modifiers
- add prepared export file document support
- wrap public text sharing behind an AppleKit share link view
- cover content type, import destination, and share adapters
Diffstat:
2 files changed, 343 insertions(+), 0 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsDocumentPresentation.swift b/Sources/RadrootsKit/RadrootsDocumentPresentation.swift
@@ -0,0 +1,264 @@
+import Foundation
+import SwiftUI
+import UniformTypeIdentifiers
+import CoreTransferable
+
+public enum RadrootsDocumentPresentationAdapter {
+ public static func contentTypes(for request: RadrootsDocumentImportRequest) -> [UTType] {
+ let types = request.allowedContentKinds.map(contentType(for:))
+ return types.isEmpty ? [.item] : types
+ }
+
+ public static func contentType(for kind: RadrootsDocumentContentKind) -> UTType {
+ switch kind {
+ case .json:
+ .json
+ case .plainText:
+ .plainText
+ case .url:
+ .url
+ case .file:
+ .item
+ case .stagedBlob:
+ .data
+ }
+ }
+
+ public static func contentType(forMediaType mediaType: String?) -> UTType {
+ guard let mediaType, let contentType = UTType(mimeType: mediaType) else {
+ return .data
+ }
+ return contentType
+ }
+
+ public static func importDestination(
+ sourceURL: URL,
+ scope: RadrootsFileScope,
+ importID: String
+ ) throws -> RadrootsFileReference {
+ let filename = sourceURL.lastPathComponent.isEmpty ? "document" : sourceURL.lastPathComponent
+ let normalizedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(filename)
+ let normalizedImportID = try RadrootsPreparedExportDocument.normalizedPreparedID(importID)
+ return RadrootsFileReference(
+ scope: scope,
+ relativePath: "document_import/\(normalizedImportID)/\(normalizedFilename)"
+ )
+ }
+
+ public static func transferItem(for request: RadrootsShareRequest) throws -> RadrootsShareTransferItem {
+ for item in request.items {
+ switch try item.normalized {
+ case .text(let text):
+ return try RadrootsShareTransferItem(text: text, subject: request.subject)
+ case .url(let url):
+ return try RadrootsShareTransferItem(text: url.absoluteString, subject: request.subject)
+ case .file, .stagedBlob:
+ continue
+ }
+ }
+ throw RadrootsDocumentInterchangeError.invalidRequest("share request does not contain a public text or url item")
+ }
+}
+
+public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hashable {
+ public let text: String
+ public let subject: String?
+
+ public init(text: String, subject: String? = nil) throws {
+ self.text = try RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text")
+ self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(
+ subject,
+ field: "share transfer subject"
+ )
+ }
+
+ public static var transferRepresentation: some TransferRepresentation {
+ ProxyRepresentation(exporting: \.text)
+ }
+}
+
+public struct RadrootsPreparedExportFileDocument: FileDocument {
+ public static var readableContentTypes: [UTType] {
+ [.data]
+ }
+
+ public let fileURL: URL
+
+ public init(preparedExport: RadrootsPreparedExportDocument) {
+ self.fileURL = preparedExport.fileURL
+ }
+
+ public init(configuration: ReadConfiguration) throws {
+ throw RadrootsDocumentInterchangeError.invalidRequest("prepared export documents are write only")
+ }
+
+ public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
+ try FileWrapper(url: fileURL, options: [])
+ }
+}
+
+public struct RadrootsDocumentImportPresentationModifier: ViewModifier {
+ @Binding private var request: RadrootsDocumentImportRequest?
+ private let fileAccess: any RadrootsFileAccess
+ private let onCompletion: (Result<RadrootsDocumentImportResult, Error>) -> Void
+
+ public init(
+ request: Binding<RadrootsDocumentImportRequest?>,
+ fileAccess: any RadrootsFileAccess,
+ onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void
+ ) {
+ self._request = request
+ self.fileAccess = fileAccess
+ self.onCompletion = onCompletion
+ }
+
+ public func body(content: Content) -> some View {
+ content.fileImporter(
+ isPresented: Binding(
+ get: { request != nil },
+ set: { isPresented in
+ if !isPresented {
+ request = nil
+ }
+ }
+ ),
+ allowedContentTypes: request.map(RadrootsDocumentPresentationAdapter.contentTypes(for:)) ?? [.item],
+ allowsMultipleSelection: request?.allowsMultipleSelection ?? false
+ ) { result in
+ handleImportResult(result)
+ }
+ }
+
+ private func handleImportResult(_ result: Result<[URL], Error>) {
+ guard let currentRequest = request else {
+ return
+ }
+ request = nil
+ do {
+ let urls = try result.get()
+ guard !urls.isEmpty else {
+ throw RadrootsDocumentInterchangeError.userCancelled("document import was cancelled")
+ }
+ let documents = try urls.map { sourceURL in
+ let destination = try RadrootsDocumentPresentationAdapter.importDestination(
+ sourceURL: sourceURL,
+ scope: currentRequest.destinationScope,
+ importID: UUID().uuidString.lowercased()
+ )
+ return try fileAccess.copyExternalFile(
+ sourceURL,
+ to: destination,
+ mediaType: nil,
+ suggestedFilename: sourceURL.lastPathComponent
+ )
+ }
+ onCompletion(.success(try RadrootsDocumentImportResult(documents: documents)))
+ } catch {
+ onCompletion(.failure(error))
+ }
+ }
+}
+
+public struct RadrootsDocumentExportPresentationModifier: ViewModifier {
+ @Binding private var preparedExport: RadrootsPreparedExportDocument?
+ private let onCompletion: (Result<RadrootsExportDocumentResult, Error>) -> Void
+
+ public init(
+ preparedExport: Binding<RadrootsPreparedExportDocument?>,
+ onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void
+ ) {
+ self._preparedExport = preparedExport
+ self.onCompletion = onCompletion
+ }
+
+ public func body(content: Content) -> some View {
+ content.fileExporter(
+ isPresented: Binding(
+ get: { preparedExport != nil },
+ set: { isPresented in
+ if !isPresented {
+ preparedExport = nil
+ }
+ }
+ ),
+ document: preparedExport.map(RadrootsPreparedExportFileDocument.init(preparedExport:)),
+ contentType: RadrootsDocumentPresentationAdapter.contentType(forMediaType: preparedExport?.mediaType),
+ defaultFilename: preparedExport?.suggestedFilename
+ ) { result in
+ handleExportResult(result)
+ }
+ }
+
+ private func handleExportResult(_ result: Result<URL, Error>) {
+ guard let currentExport = preparedExport else {
+ return
+ }
+ preparedExport = nil
+ do {
+ let destinationURL = try result.get()
+ onCompletion(
+ .success(
+ try RadrootsExportDocumentResult(
+ exportedFilename: destinationURL.lastPathComponent.isEmpty
+ ? currentExport.suggestedFilename
+ : destinationURL.lastPathComponent,
+ mediaType: currentExport.mediaType,
+ sizeBytes: currentExport.sizeBytes
+ )
+ )
+ )
+ } catch {
+ onCompletion(.failure(error))
+ }
+ }
+}
+
+public struct RadrootsSharePresentationLink<Label: View>: View {
+ private let transferItem: RadrootsShareTransferItem
+ private let label: () -> Label
+
+ public init(
+ request: RadrootsShareRequest,
+ @ViewBuilder label: @escaping () -> Label
+ ) throws {
+ self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem(for: request)
+ self.label = label
+ }
+
+ public var body: some View {
+ ShareLink(
+ item: transferItem.text,
+ subject: transferItem.subject.map(Text.init) ?? Text(""),
+ message: Text(transferItem.text),
+ label: label
+ )
+ }
+}
+
+public extension View {
+ func radrootsDocumentImporter(
+ request: Binding<RadrootsDocumentImportRequest?>,
+ fileAccess: any RadrootsFileAccess,
+ onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void
+ ) -> some View {
+ modifier(
+ RadrootsDocumentImportPresentationModifier(
+ request: request,
+ fileAccess: fileAccess,
+ onCompletion: onCompletion
+ )
+ )
+ }
+
+ func radrootsDocumentExporter(
+ preparedExport: Binding<RadrootsPreparedExportDocument?>,
+ onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void
+ ) -> some View {
+ modifier(
+ RadrootsDocumentExportPresentationModifier(
+ preparedExport: preparedExport,
+ onCompletion: onCompletion
+ )
+ )
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift b/Tests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift
@@ -0,0 +1,79 @@
+import Foundation
+import Testing
+import UniformTypeIdentifiers
+@testable import RadrootsKit
+
+@Test func documentPresentationMapsImportContentTypes() throws {
+ let request = try RadrootsDocumentImportRequest(
+ allowedContentKinds: [.json, .plainText, .url, .file, .stagedBlob]
+ )
+
+ let types = RadrootsDocumentPresentationAdapter.contentTypes(for: request)
+
+ #expect(types == [.json, .plainText, .url, .item, .data])
+ #expect(RadrootsDocumentPresentationAdapter.contentType(forMediaType: "application/json") == .json)
+ #expect(RadrootsDocumentPresentationAdapter.contentType(forMediaType: "text/plain") == .plainText)
+ #expect(RadrootsDocumentPresentationAdapter.contentType(forMediaType: nil) == .data)
+}
+
+@Test func documentPresentationBuildsImportDestinations() throws {
+ let destination = try RadrootsDocumentPresentationAdapter.importDestination(
+ sourceURL: URL(fileURLWithPath: "/tmp/relays.json"),
+ scope: .data,
+ importID: "import_1"
+ )
+
+ #expect(destination.scope == .data)
+ #expect(destination.relativePath == "document_import/import_1/relays.json")
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsDocumentPresentationAdapter.importDestination(
+ sourceURL: URL(fileURLWithPath: "/tmp/../bad.json"),
+ scope: .data,
+ importID: "../escape"
+ )
+ }
+}
+
+@Test func documentPresentationAdaptsPublicShareItems() throws {
+ let textRequest = try RadrootsShareRequest(items: [.text(" public post ")], subject: " Radroots ")
+ let textItem = try RadrootsDocumentPresentationAdapter.transferItem(for: textRequest)
+
+ #expect(textItem.text == "public post")
+ #expect(textItem.subject == "Radroots")
+
+ let urlRequest = try RadrootsShareRequest(items: [.url(URL(string: "https://radroots.org/posts/1")!)])
+ let urlItem = try RadrootsDocumentPresentationAdapter.transferItem(for: urlRequest)
+
+ #expect(urlItem.text == "https://radroots.org/posts/1")
+
+ let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json")
+ let fileRequest = try RadrootsShareRequest(
+ items: [.file(file, suggestedFilename: "diagnostics.json", mediaType: "application/json", sizeBytes: nil)]
+ )
+
+ #expect(throws: RadrootsDocumentInterchangeError.self) {
+ _ = try RadrootsDocumentPresentationAdapter.transferItem(for: fileRequest)
+ }
+}
+
+@Test func preparedExportFileDocumentWrapsPreparedExportURL() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-prepared-export-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let fileURL = directory.appendingPathComponent("diagnostics.json")
+ let data = Data(#"{"status":"ok"}"#.utf8)
+ try data.write(to: fileURL)
+ let prepared = try RadrootsPreparedExportDocument(
+ preparedID: "prepared_1",
+ fileURL: fileURL,
+ suggestedFilename: "diagnostics.json",
+ mediaType: "application/json",
+ sizeBytes: UInt64(data.count)
+ )
+
+ let document = RadrootsPreparedExportFileDocument(preparedExport: prepared)
+
+ #expect(document.fileURL == fileURL.standardizedFileURL)
+ #expect(RadrootsPreparedExportFileDocument.readableContentTypes == [.data])
+}