apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

RadrootsDocumentPresentation.swift (14663B)


      1 import Foundation
      2 import SwiftUI
      3 import UniformTypeIdentifiers
      4 import CoreTransferable
      5 
      6 public enum RadrootsDocumentPresentationAdapter {
      7     public static func contentTypes(for request: RadrootsDocumentImportRequest) -> [UTType] {
      8         let types = request.allowedContentKinds.map(contentType(for:))
      9         return types.isEmpty ? [.item] : types
     10     }
     11 
     12     public static func contentType(for kind: RadrootsDocumentContentKind) -> UTType {
     13         switch kind {
     14         case .json:
     15             .json
     16         case .plainText:
     17             .plainText
     18         case .url:
     19             .url
     20         case .file:
     21             .item
     22         case .stagedBlob:
     23             .data
     24         }
     25     }
     26 
     27     public static func contentType(forMediaType mediaType: String?) -> UTType {
     28         guard let mediaType, let contentType = UTType(mimeType: mediaType) else {
     29             return .data
     30         }
     31         return contentType
     32     }
     33 
     34     public static func importDestination(
     35         sourceURL: URL,
     36         scope: RadrootsFileScope,
     37         importID: String
     38     ) throws -> RadrootsFileReference {
     39         let filename = sourceURL.lastPathComponent.isEmpty ? "document" : sourceURL.lastPathComponent
     40         let normalizedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(filename)
     41         let normalizedImportID = try RadrootsPreparedExportDocument.normalizedPreparedID(importID)
     42         return RadrootsFileReference(
     43             scope: scope,
     44             relativePath: "document_import/\(normalizedImportID)/\(normalizedFilename)"
     45         )
     46     }
     47 
     48     public static func transferItem(for request: RadrootsShareRequest) throws -> RadrootsShareTransferItem {
     49         try transferItem(for: request, fileAccess: nil)
     50     }
     51 
     52     public static func transferItem(
     53         for request: RadrootsShareRequest,
     54         fileAccess: any RadrootsFileAccess
     55     ) throws -> RadrootsShareTransferItem {
     56         let optionalFileAccess: (any RadrootsFileAccess)? = fileAccess
     57         return try transferItem(for: request, fileAccess: optionalFileAccess)
     58     }
     59 
     60     private static func transferItem(
     61         for request: RadrootsShareRequest,
     62         fileAccess: (any RadrootsFileAccess)?
     63     ) throws -> RadrootsShareTransferItem {
     64         for item in request.items {
     65             switch try item.normalized {
     66             case .text(let text):
     67                 return try RadrootsShareTransferItem(text: text, subject: request.subject)
     68             case .url(let url):
     69                 return try RadrootsShareTransferItem(url: url, subject: request.subject)
     70             case .file(let file, let suggestedFilename, let mediaType, let sizeBytes):
     71                 guard let fileAccess else {
     72                     continue
     73                 }
     74                 let export = try fileAccess.prepareExport(
     75                     RadrootsExportDocumentRequest(
     76                         source: .file(file),
     77                         suggestedFilename: try shareFilename(
     78                             explicitFilename: suggestedFilename,
     79                             fallbackFilename: NSString(string: file.relativePath).lastPathComponent
     80                         ),
     81                         mediaType: mediaType,
     82                         sizeBytes: sizeBytes
     83                     )
     84                 )
     85                 return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject)
     86             case .stagedBlob(let stagedBlob, let suggestedFilename):
     87                 guard let fileAccess else {
     88                     continue
     89                 }
     90                 let export = try fileAccess.prepareExport(
     91                     RadrootsExportDocumentRequest(
     92                         source: .stagedBlob(stagedBlob),
     93                         suggestedFilename: try shareFilename(
     94                             explicitFilename: suggestedFilename,
     95                             fallbackFilename: stagedBlob.filenameHint ?? stagedBlob.blobID
     96                         ),
     97                         mediaType: stagedBlob.mediaType,
     98                         sizeBytes: UInt64(stagedBlob.sizeBytes)
     99                     )
    100                 )
    101                 return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject)
    102             }
    103         }
    104         throw RadrootsDocumentInterchangeError.invalidRequest("share request does not contain a supported public share item")
    105     }
    106 
    107     private static func shareFilename(explicitFilename: String?, fallbackFilename: String) throws -> String {
    108         if let explicitFilename {
    109             return try RadrootsDocumentInterchangeValidation.normalizedFilename(explicitFilename)
    110         }
    111         let fallback = fallbackFilename.trimmingCharacters(in: .whitespacesAndNewlines)
    112         if fallback.isEmpty {
    113             return "radroots-share-item"
    114         }
    115         return try RadrootsDocumentInterchangeValidation.normalizedFilename(fallback)
    116     }
    117 }
    118 
    119 public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hashable {
    120     public enum Payload: Sendable, Equatable, Hashable {
    121         case text(String)
    122         case url(URL)
    123         case file(RadrootsPreparedExportDocument)
    124     }
    125 
    126     public let payload: Payload
    127     public let subject: String?
    128 
    129     public init(text: String, subject: String? = nil) throws {
    130         self.payload = .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text"))
    131         self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(
    132             subject,
    133             field: "share transfer subject"
    134         )
    135     }
    136 
    137     public init(url: URL, subject: String? = nil) throws {
    138         self.payload = .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(url))
    139         self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(
    140             subject,
    141             field: "share transfer subject"
    142         )
    143     }
    144 
    145     public init(preparedExport: RadrootsPreparedExportDocument, subject: String? = nil) throws {
    146         self.payload = .file(preparedExport)
    147         self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(
    148             subject,
    149             field: "share transfer subject"
    150         )
    151     }
    152 
    153     public var text: String? {
    154         switch payload {
    155         case .text(let text):
    156             text
    157         case .url(let url):
    158             url.absoluteString
    159         case .file:
    160             nil
    161         }
    162     }
    163 
    164     public var url: URL? {
    165         guard case .url(let url) = payload else {
    166             return nil
    167         }
    168         return url
    169     }
    170 
    171     public var preparedExport: RadrootsPreparedExportDocument? {
    172         guard case .file(let preparedExport) = payload else {
    173             return nil
    174         }
    175         return preparedExport
    176     }
    177 
    178     public var transferText: String {
    179         switch payload {
    180         case .text(let text):
    181             text
    182         case .url(let url):
    183             url.absoluteString
    184         case .file(let preparedExport):
    185             preparedExport.suggestedFilename
    186         }
    187     }
    188 
    189     public static var transferRepresentation: some TransferRepresentation {
    190         ProxyRepresentation(exporting: \.transferText)
    191     }
    192 }
    193 
    194 public struct RadrootsPreparedExportFileDocument: FileDocument {
    195     public static var readableContentTypes: [UTType] {
    196         [.data]
    197     }
    198 
    199     public let fileURL: URL
    200 
    201     public init(preparedExport: RadrootsPreparedExportDocument) {
    202         self.fileURL = preparedExport.fileURL
    203     }
    204 
    205     public init(configuration: ReadConfiguration) throws {
    206         throw RadrootsDocumentInterchangeError.invalidRequest("prepared export documents are write only")
    207     }
    208 
    209     public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
    210         try FileWrapper(url: fileURL, options: [])
    211     }
    212 }
    213 
    214 public struct RadrootsDocumentImportPresentationModifier: ViewModifier {
    215     @Binding private var request: RadrootsDocumentImportRequest?
    216     private let fileAccess: any RadrootsFileAccess
    217     private let onCompletion: (Result<RadrootsDocumentImportResult, Error>) -> Void
    218 
    219     public init(
    220         request: Binding<RadrootsDocumentImportRequest?>,
    221         fileAccess: any RadrootsFileAccess,
    222         onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void
    223     ) {
    224         self._request = request
    225         self.fileAccess = fileAccess
    226         self.onCompletion = onCompletion
    227     }
    228 
    229     public func body(content: Content) -> some View {
    230         content.fileImporter(
    231             isPresented: Binding(
    232                 get: { request != nil },
    233                 set: { isPresented in
    234                     if !isPresented {
    235                         request = nil
    236                     }
    237                 }
    238             ),
    239             allowedContentTypes: request.map(RadrootsDocumentPresentationAdapter.contentTypes(for:)) ?? [.item],
    240             allowsMultipleSelection: request?.allowsMultipleSelection ?? false
    241         ) { result in
    242             handleImportResult(result)
    243         }
    244     }
    245 
    246     private func handleImportResult(_ result: Result<[URL], Error>) {
    247         guard let currentRequest = request else {
    248             return
    249         }
    250         request = nil
    251         do {
    252             let urls = try result.get()
    253             guard !urls.isEmpty else {
    254                 throw RadrootsDocumentInterchangeError.userCancelled("document import was cancelled")
    255             }
    256             let documents = try urls.map { sourceURL in
    257                 let destination = try RadrootsDocumentPresentationAdapter.importDestination(
    258                     sourceURL: sourceURL,
    259                     scope: currentRequest.destinationScope,
    260                     importID: UUID().uuidString.lowercased()
    261                 )
    262                 return try fileAccess.copyExternalFile(
    263                     sourceURL,
    264                     to: destination,
    265                     mediaType: nil,
    266                     suggestedFilename: sourceURL.lastPathComponent
    267                 )
    268             }
    269             onCompletion(.success(try RadrootsDocumentImportResult(documents: documents)))
    270         } catch {
    271             onCompletion(.failure(error))
    272         }
    273     }
    274 }
    275 
    276 public struct RadrootsDocumentExportPresentationModifier: ViewModifier {
    277     @Binding private var preparedExport: RadrootsPreparedExportDocument?
    278     private let onCompletion: (Result<RadrootsExportDocumentResult, Error>) -> Void
    279 
    280     public init(
    281         preparedExport: Binding<RadrootsPreparedExportDocument?>,
    282         onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void
    283     ) {
    284         self._preparedExport = preparedExport
    285         self.onCompletion = onCompletion
    286     }
    287 
    288     public func body(content: Content) -> some View {
    289         content.fileExporter(
    290             isPresented: Binding(
    291                 get: { preparedExport != nil },
    292                 set: { isPresented in
    293                     if !isPresented {
    294                         preparedExport = nil
    295                     }
    296                 }
    297             ),
    298             document: preparedExport.map(RadrootsPreparedExportFileDocument.init(preparedExport:)),
    299             contentType: RadrootsDocumentPresentationAdapter.contentType(forMediaType: preparedExport?.mediaType),
    300             defaultFilename: preparedExport?.suggestedFilename
    301         ) { result in
    302             handleExportResult(result)
    303         }
    304     }
    305 
    306     private func handleExportResult(_ result: Result<URL, Error>) {
    307         guard let currentExport = preparedExport else {
    308             return
    309         }
    310         preparedExport = nil
    311         do {
    312             let destinationURL = try result.get()
    313             onCompletion(
    314                 .success(
    315                     try RadrootsExportDocumentResult(
    316                         exportedFilename: destinationURL.lastPathComponent.isEmpty
    317                             ? currentExport.suggestedFilename
    318                             : destinationURL.lastPathComponent,
    319                         mediaType: currentExport.mediaType,
    320                         sizeBytes: currentExport.sizeBytes
    321                     )
    322                 )
    323             )
    324         } catch {
    325             onCompletion(.failure(error))
    326         }
    327     }
    328 }
    329 
    330 public struct RadrootsSharePresentationLink<Label: View>: View {
    331     private let transferItem: RadrootsShareTransferItem
    332     private let label: () -> Label
    333 
    334     public init(
    335         request: RadrootsShareRequest,
    336         @ViewBuilder label: @escaping () -> Label
    337     ) throws {
    338         self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem(for: request)
    339         self.label = label
    340     }
    341 
    342     public init(
    343         request: RadrootsShareRequest,
    344         fileAccess: any RadrootsFileAccess,
    345         @ViewBuilder label: @escaping () -> Label
    346     ) throws {
    347         self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem(
    348             for: request,
    349             fileAccess: fileAccess
    350         )
    351         self.label = label
    352     }
    353 
    354     @ViewBuilder
    355     public var body: some View {
    356         switch transferItem.payload {
    357         case .text(let text):
    358             ShareLink(
    359                 item: text,
    360                 subject: transferItem.subject.map(Text.init) ?? Text(""),
    361                 message: Text(text),
    362                 label: label
    363             )
    364         case .url(let url):
    365             ShareLink(
    366                 item: url,
    367                 subject: transferItem.subject.map(Text.init) ?? Text(""),
    368                 message: Text(url.absoluteString),
    369                 label: label
    370             )
    371         case .file(let preparedExport):
    372             ShareLink(
    373                 item: preparedExport.fileURL,
    374                 subject: transferItem.subject.map(Text.init) ?? Text(""),
    375                 message: Text(preparedExport.suggestedFilename),
    376                 label: label
    377             )
    378         }
    379     }
    380 }
    381 
    382 public extension View {
    383     func radrootsDocumentImporter(
    384         request: Binding<RadrootsDocumentImportRequest?>,
    385         fileAccess: any RadrootsFileAccess,
    386         onCompletion: @escaping (Result<RadrootsDocumentImportResult, Error>) -> Void
    387     ) -> some View {
    388         modifier(
    389             RadrootsDocumentImportPresentationModifier(
    390                 request: request,
    391                 fileAccess: fileAccess,
    392                 onCompletion: onCompletion
    393             )
    394         )
    395     }
    396 
    397     func radrootsDocumentExporter(
    398         preparedExport: Binding<RadrootsPreparedExportDocument?>,
    399         onCompletion: @escaping (Result<RadrootsExportDocumentResult, Error>) -> Void
    400     ) -> some View {
    401         modifier(
    402             RadrootsDocumentExportPresentationModifier(
    403                 preparedExport: preparedExport,
    404                 onCompletion: onCompletion
    405             )
    406         )
    407     }
    408 }