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

RadrootsAppleFileAccess.swift (12523B)


      1 import Darwin
      2 import Foundation
      3 
      4 public final class RadrootsAppleFileAccess: RadrootsFileAccess {
      5     static let maximumGovernedFileBytes = 512 * 1024 * 1024
      6 
      7     public let roots: RadrootsAppleFileRoots
      8     let fileManager: FileManager
      9     let persistence: RadrootsFilePersistence
     10 
     11     public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) {
     12         self.roots = roots
     13         self.fileManager = fileManager
     14         persistence = .live
     15     }
     16 
     17     init(roots: RadrootsAppleFileRoots, persistence: RadrootsFilePersistence) {
     18         self.roots = roots
     19         fileManager = .default
     20         self.persistence = persistence
     21     }
     22 
     23     public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws {
     24         let url = try roots.resolvedURL(for: file)
     25         try createParentDirectory(for: url)
     26         switch payload {
     27         case let .inline(inlineData):
     28             try classifiedFileSystemOperation {
     29                 try inlineData.write(to: url, options: [.atomic])
     30             }
     31         case let .stagedBlob(stagedBlob):
     32             let data = try readStagedBlob(stagedBlob)
     33             try classifiedFileSystemOperation {
     34                 try data.write(to: url, options: [.atomic])
     35             }
     36         }
     37     }
     38 
     39     public func read(
     40         _ file: RadrootsFileReference, mode: RadrootsFileReadMode
     41     ) throws -> RadrootsFileReadResult {
     42         switch mode {
     43         case let .inline(maxBytes):
     44             return try .inline(readGovernedFile(file, maximumBytes: maxBytes))
     45         case let .preferInline(maxBytes):
     46             do {
     47                 return try .inline(
     48                     readGovernedFile(file, maximumBytes: maxBytes, preserveTooLarge: true)
     49                 )
     50             } catch RadrootsGovernedFileReadError.tooLarge {
     51                 let url = try roots.resolvedURL(for: file)
     52                 let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
     53                 return .stagedBlob(staged)
     54             }
     55         case .stagedBlob:
     56             let url = try roots.resolvedURL(for: file)
     57             let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
     58             return .stagedBlob(staged)
     59         }
     60     }
     61 
     62     public func delete(_ file: RadrootsFileReference) throws {
     63         let url = try roots.resolvedURL(for: file)
     64         guard fileManager.fileExists(atPath: url.path) else {
     65             return
     66         }
     67         try classifiedFileSystemOperation {
     68             try fileManager.removeItem(at: url)
     69         }
     70     }
     71 
     72     public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] {
     73         let rootURL = roots.root(for: directory.scope).standardizedFileURL
     74         let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true)
     75         var isDirectory = ObjCBool(false)
     76         guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else {
     77             return []
     78         }
     79         guard isDirectory.boolValue else {
     80             throw RadrootsAppleFileError.invalidRequest
     81         }
     82         return try classifiedFileSystemOperation {
     83             let urls = try fileManager.contentsOfDirectory(
     84                 at: directoryURL,
     85                 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
     86                 options: []
     87             )
     88             return try urls.map { url in
     89                 let values = try url.resourceValues(
     90                     forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
     91                 )
     92                 let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL)
     93                 return RadrootsFileEntry(
     94                     file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath),
     95                     name: url.lastPathComponent,
     96                     isDirectory: values.isDirectory ?? false,
     97                     sizeBytes: values.fileSize,
     98                     modifiedAt: values.contentModificationDate
     99                 )
    100             }
    101             .sorted { left, right in
    102                 left.file.relativePath < right.file.relativePath
    103             }
    104         }
    105     }
    106 
    107     public func reset(scope: RadrootsFileScope) throws {
    108         let url = roots.root(for: scope)
    109         try classifiedFileSystemOperation {
    110             if fileManager.fileExists(atPath: url.path) {
    111                 try fileManager.removeItem(at: url)
    112             }
    113             try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
    114         }
    115     }
    116 }
    117 
    118 public extension RadrootsAppleFileAccess {
    119     @discardableResult
    120     func stageBlob(
    121         _ data: Data,
    122         mediaType: String? = nil,
    123         filenameHint: String? = nil
    124     ) throws -> RadrootsStagedBlobReference {
    125         guard data.count <= Self.maximumGovernedFileBytes else {
    126             throw RadrootsAppleFileError.invalidRequest
    127         }
    128         let blobID = UUID().uuidString.lowercased()
    129         let blob = try RadrootsStagedBlobReference(
    130             blobID: blobID,
    131             sizeBytes: data.count,
    132             mediaType: mediaType,
    133             filenameHint: filenameHint
    134         )
    135         try installStagedBlob(data, reference: blob)
    136         return blob
    137     }
    138 
    139     @discardableResult
    140     func stageFile(
    141         _ file: RadrootsFileReference,
    142         mediaType: String? = nil,
    143         filenameHint: String? = nil
    144     ) throws -> RadrootsStagedBlobReference {
    145         let sourceURL = try roots.resolvedURL(for: file)
    146         return try stageBlob(
    147             readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes),
    148             mediaType: mediaType,
    149             filenameHint: filenameHint ?? sourceURL.lastPathComponent
    150         )
    151     }
    152 
    153     @discardableResult
    154     func stageExternalFile(
    155         _ sourceURL: URL,
    156         mediaType: String? = nil,
    157         filenameHint: String? = nil
    158     ) throws -> RadrootsStagedBlobReference {
    159         try withSecurityScopedFile(sourceURL) { scopedURL in
    160             try stageFileURL(
    161                 scopedURL,
    162                 mediaType: mediaType,
    163                 filenameHint: filenameHint ?? scopedURL.lastPathComponent
    164             )
    165         }
    166     }
    167 
    168     @discardableResult
    169     func copyExternalFile(
    170         _ sourceURL: URL,
    171         to file: RadrootsFileReference,
    172         mediaType: String? = nil,
    173         suggestedFilename: String? = nil
    174     ) throws -> RadrootsImportedDocument {
    175         try withSecurityScopedFile(sourceURL) { scopedURL in
    176             let destinationURL = try roots.resolvedURL(for: file)
    177             try createParentDirectory(for: destinationURL)
    178             try copyReplacingItem(from: scopedURL, to: destinationURL)
    179             let sizeBytes = try fileSizeUInt64(at: destinationURL)
    180             return try RadrootsImportedDocument(
    181                 file: file,
    182                 originalURL: scopedURL,
    183                 suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent,
    184                 mediaType: mediaType,
    185                 sizeBytes: sizeBytes
    186             )
    187         }
    188     }
    189 
    190     @discardableResult
    191     func prepareExport(
    192         _ request: RadrootsExportDocumentRequest
    193     ) throws -> RadrootsPreparedExportDocument {
    194         let preparedID = UUID().uuidString.lowercased()
    195         let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true)
    196         let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL
    197         try classifiedFileSystemOperation {
    198             try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
    199         }
    200         let preparedData: Data = switch request.source {
    201         case let .inlineData(data):
    202             data
    203         case let .file(file):
    204             try readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes)
    205         case let .stagedBlob(stagedBlob):
    206             try readStagedBlob(stagedBlob)
    207         }
    208         try classifiedFileSystemOperation {
    209             try preparedData.write(to: fileURL, options: [.atomic])
    210         }
    211         let sizeBytes: UInt64 =
    212             if let requestSizeBytes = request.sizeBytes {
    213                 requestSizeBytes
    214             } else {
    215                 try fileSizeUInt64(at: fileURL)
    216             }
    217         return try RadrootsPreparedExportDocument(
    218             preparedID: preparedID,
    219             fileURL: fileURL,
    220             suggestedFilename: request.suggestedFilename,
    221             mediaType: request.mediaType,
    222             sizeBytes: sizeBytes
    223         )
    224     }
    225 
    226     func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data {
    227         guard (0 ... Self.maximumGovernedFileBytes).contains(blob.sizeBytes) else {
    228             throw RadrootsAppleFileError.invalidRequest
    229         }
    230         let data: Data
    231         do {
    232             data = try RadrootsGovernedFileReader.read(
    233                 root: roots.stagedBlobsRoot,
    234                 relativePath: blob.blobID,
    235                 maximumBytes: blob.sizeBytes
    236             )
    237         } catch let error as RadrootsGovernedFileReadError {
    238             throw mappedGovernedReadError(error)
    239         }
    240         guard data.count == blob.sizeBytes else {
    241             throw RadrootsAppleFileError.permanentFailure
    242         }
    243         return data
    244     }
    245 
    246     func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws {
    247         let url = try stagedBlobURL(for: blob)
    248         if fileManager.fileExists(atPath: url.path) {
    249             try classifiedFileSystemOperation {
    250                 try fileManager.removeItem(at: url)
    251             }
    252         }
    253     }
    254 
    255     func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool {
    256         let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
    257         return fileManager.fileExists(atPath: directoryURL.path)
    258             && fileManager.fileExists(atPath: preparedExport.fileURL.path)
    259     }
    260 
    261     func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws {
    262         let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
    263         if fileManager.fileExists(atPath: directoryURL.path) {
    264             try classifiedFileSystemOperation {
    265                 try fileManager.removeItem(at: directoryURL)
    266             }
    267         }
    268     }
    269 
    270     @discardableResult
    271     func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] {
    272         guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else {
    273             return []
    274         }
    275         return try classifiedFileSystemOperation {
    276             let urls = try fileManager.contentsOfDirectory(
    277                 at: roots.stagedBlobsRoot,
    278                 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
    279                 options: []
    280             )
    281             var released: [RadrootsStagedBlobReference] = []
    282             for url in urls {
    283                 // Interrupted atomic outputs are identifiable orphans, not valid
    284                 // blob references. Their owner reconciles them separately.
    285                 guard !url.lastPathComponent.hasPrefix(".radroots_pending_") else { continue }
    286                 let values = try url.resourceValues(
    287                     forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
    288                 )
    289                 guard values.isDirectory != true else {
    290                     continue
    291                 }
    292                 guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else {
    293                     continue
    294                 }
    295                 let blob = try RadrootsStagedBlobReference(
    296                     blobID: url.lastPathComponent,
    297                     sizeBytes: values.fileSize ?? 0
    298                 )
    299                 try fileManager.removeItem(at: url)
    300                 released.append(blob)
    301             }
    302             return released.sorted { left, right in
    303                 left.blobID < right.blobID
    304             }
    305         }
    306     }
    307 
    308     func resetStagedBlobs() throws {
    309         try classifiedFileSystemOperation {
    310             if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) {
    311                 try fileManager.removeItem(at: roots.stagedBlobsRoot)
    312             }
    313             try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
    314         }
    315     }
    316 
    317     func resetFileRoots() throws {
    318         for scope in RadrootsFileScope.allCases {
    319             try reset(scope: scope)
    320         }
    321         try resetStagedBlobs()
    322     }
    323 }