apple_kit

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

RadrootsDocumentPresentation.swift (14561B)


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