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

RadrootsAppleMediaPreparation.swift (11003B)


      1 import CryptoKit
      2 import Foundation
      3 import ImageIO
      4 import UniformTypeIdentifiers
      5 
      6 public enum RadrootsAppleMediaPreparationError: Error, Equatable, Sendable {
      7     case invalidRequest
      8     case unavailable
      9     case preparationFailure
     10 }
     11 
     12 extension RadrootsAppleMediaPreparationError: LocalizedError {
     13     public var errorDescription: String? {
     14         switch self {
     15         case .invalidRequest: "The media preparation request is invalid."
     16         case .unavailable: "Media preparation is unavailable."
     17         case .preparationFailure: "The media could not be prepared."
     18         }
     19     }
     20 }
     21 
     22 public struct RadrootsAppleImagePreparationRequest: Sendable, Equatable, Hashable {
     23     public let source: RadrootsBackgroundTransferLocalFile
     24     public let maximumInputBytes: Int
     25     public let maximumOutputBytes: Int
     26     public let maximumPixelCount: Int
     27     public let maximumDimension: Int
     28 
     29     public init(
     30         source: RadrootsBackgroundTransferLocalFile, maximumInputBytes: Int = 40 * 1024 * 1024,
     31         maximumOutputBytes: Int = 10 * 1024 * 1024, maximumPixelCount: Int = 40_000_000, maximumDimension: Int = 4096
     32     ) throws {
     33         guard (1 ... (40 * 1024 * 1024)).contains(maximumInputBytes),
     34               (1 ... (10 * 1024 * 1024)).contains(maximumOutputBytes),
     35               (1 ... 40_000_000).contains(maximumPixelCount), (1 ... 8192).contains(maximumDimension)
     36         else { throw RadrootsAppleMediaPreparationError.invalidRequest }
     37         do { try RadrootsBackgroundTransferValidation.validateLocalFile(source) } catch {
     38             throw RadrootsAppleMediaPreparationError.invalidRequest
     39         }
     40         self.source = source
     41         self.maximumInputBytes = maximumInputBytes
     42         self.maximumOutputBytes = maximumOutputBytes
     43         self.maximumPixelCount = maximumPixelCount
     44         self.maximumDimension = maximumDimension
     45     }
     46 }
     47 
     48 public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable, CustomDebugStringConvertible {
     49     public let file: RadrootsStagedBlobReference
     50     public let sha256: String
     51     public let width: UInt32
     52     public let height: UInt32
     53 
     54     public init(
     55         file: RadrootsStagedBlobReference, sha256: String, width: UInt32, height: UInt32
     56     ) throws {
     57         guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, width > 0,
     58               height > 0, file.sizeBytes > 0,
     59               file.mediaType == "image/png"
     60         else { throw RadrootsAppleMediaPreparationError.invalidRequest }
     61         self.file = file
     62         self.sha256 = sha256
     63         self.width = width
     64         self.height = height
     65     }
     66 
     67     public var debugDescription: String {
     68         "RadrootsApplePreparedImage(sha256: \(sha256), sizeBytes: \(file.sizeBytes), "
     69             + "width: \(width), height: \(height))"
     70     }
     71 }
     72 
     73 public actor RadrootsAppleMediaPreparer {
     74     private let roots: RadrootsAppleFileRoots
     75     private let resolver: RadrootsAppleBackgroundTransferFileResolver
     76     private let fileManager: FileManager
     77     private let protectedData: RadrootsProtectedDataProvider
     78 
     79     public init(
     80         roots: RadrootsAppleFileRoots, fileManager: FileManager = .default,
     81         protectedData: RadrootsProtectedDataProvider = .available
     82     ) {
     83         self.roots = roots
     84         resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
     85         self.fileManager = fileManager
     86         self.protectedData = protectedData
     87     }
     88 
     89     /// Prepares a single-frame JPEG, PNG or HEIF/HEIC raster with at most
     90     /// eight-bit source components. Source axes are limited to 32,768 pixels;
     91     /// the existing request limits and a 512 MiB raster working-byte estimate
     92     /// apply before decoding. One request decodes at a time per preparer.
     93     /// The returned PNG contains oriented standard-sRGB pixels, without source
     94     /// location, device, comment or camera-profile metadata.
     95     public func prepareImage(
     96         _ request: RadrootsAppleImagePreparationRequest
     97     ) async throws -> RadrootsApplePreparedImage {
     98         // One actor-owned, non-suspending decode at a time. Drain native temporary
     99         // objects before another queued request can allocate its raster buffers.
    100         do { return try autoreleasepool { try prepareValidatedImage(request) } } catch is CancellationError {
    101             throw CancellationError()
    102         } catch let error as RadrootsAppleMediaPreparationError {
    103             throw error
    104         } catch { throw RadrootsAppleMediaPreparationError.preparationFailure }
    105     }
    106 
    107     private func prepareValidatedImage(
    108         _ request: RadrootsAppleImagePreparationRequest
    109     ) throws -> RadrootsApplePreparedImage {
    110         try Task.checkCancellation()
    111         try requireProtectedData()
    112         let sourceData = try readSource(request)
    113         let normalizedImage = try RadrootsAppleImageDecode.normalizedImage(sourceData, request: request)
    114         try Task.checkCancellation()
    115 
    116         let temporaryURL = roots.temporaryRoot.appendingPathComponent(
    117             "media_preparation", isDirectory: true
    118         ).appendingPathComponent(
    119             "\(UUID().uuidString.lowercased()).png"
    120         ).standardizedFileURL
    121         defer {
    122             if fileManager.fileExists(atPath: temporaryURL.path) {
    123                 try? fileManager.removeItem(at: temporaryURL)
    124             }
    125         }
    126         try encodePNG(normalizedImage, at: temporaryURL)
    127         try Task.checkCancellation()
    128         let outputSize = try Self.fileSize(at: temporaryURL)
    129         guard outputSize > 0, outputSize <= request.maximumOutputBytes else {
    130             throw RadrootsAppleMediaPreparationError.invalidRequest
    131         }
    132         let digest = try RadrootsAppleFileDigest.sha256(at: temporaryURL)
    133         let staged = try RadrootsStagedBlobReference(
    134             blobID: digest, sizeBytes: outputSize, mediaType: "image/png", filenameHint: "\(digest).png"
    135         )
    136         try Task.checkCancellation()
    137         try requireProtectedData()
    138         try Task.checkCancellation()
    139         let relative = "media_preparation/" + temporaryURL.lastPathComponent
    140         let bytes = try RadrootsGovernedFileReader.read(
    141             root: roots.temporaryRoot, relativePath: relative, maximumBytes: request.maximumOutputBytes
    142         )
    143         guard bytes.count == outputSize, RadrootsAppleFileDigest.sha256(bytes) == digest else {
    144             throw RadrootsAppleMediaPreparationError.preparationFailure
    145         }
    146         try Task.checkCancellation()
    147         try RadrootsAppleFileAccess(roots: roots, fileManager: fileManager).installStagedBlob(bytes, reference: staged)
    148         let stagedURL = try roots.stagedBlobURL(for: staged)
    149         #if os(iOS)
    150             try fileManager.setAttributes(
    151                 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
    152                 ofItemAtPath: stagedURL.path
    153             )
    154         #endif
    155         return try RadrootsApplePreparedImage(
    156             file: staged, sha256: digest, width: UInt32(normalizedImage.width),
    157             height: UInt32(normalizedImage.height)
    158         )
    159     }
    160 
    161     public func blossomUploadRequest(
    162         preparedImage: RadrootsApplePreparedImage, remoteURL: URL, authorization: String,
    163         networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS,
    164         identifier: RadrootsBackgroundTransferIdentifier = .generated()
    165     ) throws -> RadrootsBackgroundTransferRequest {
    166         do {
    167             let preparedData = try resolver.read(
    168                 .stagedBlob(preparedImage.file), maximumBytes: preparedImage.file.sizeBytes
    169             )
    170             guard preparedData.count == preparedImage.file.sizeBytes,
    171                   RadrootsAppleFileDigest.sha256(preparedData) == preparedImage.sha256
    172             else { throw RadrootsAppleMediaPreparationError.invalidRequest }
    173             return try RadrootsBackgroundTransferRequest(
    174                 identifier: identifier, remoteURL: remoteURL, method: .put,
    175                 operation: .upload(source: .stagedBlob(preparedImage.file)),
    176                 headers: [
    177                     "Authorization": authorization, "Content-Type": "image/png",
    178                     "X-SHA-256": preparedImage.sha256,
    179                     "Accept": "application/json", "Accept-Encoding": "identity"
    180                 ],
    181                 metadata: ["purpose": "blossom_upload", "sha256": preparedImage.sha256],
    182                 networkPolicy: networkPolicy,
    183                 responsePolicy: .boundedJSON(), expectedSourceSHA256: preparedImage.sha256
    184             )
    185         } catch let error as RadrootsAppleMediaPreparationError { throw error
    186         } catch let error as RadrootsBackgroundTransferError {
    187             throw error
    188         } catch { throw RadrootsAppleMediaPreparationError.preparationFailure }
    189     }
    190 
    191     private func readSource(_ request: RadrootsAppleImagePreparationRequest) throws -> Data {
    192         do {
    193             return try resolver.read(request.source, maximumBytes: request.maximumInputBytes)
    194         } catch {
    195             throw RadrootsAppleMediaPreparationError.invalidRequest
    196         }
    197     }
    198 
    199     private func encodePNG(_ image: CGImage, at url: URL) throws {
    200         try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
    201         #if os(iOS)
    202             try fileManager.setAttributes(
    203                 [.protectionKey: FileProtectionType.complete],
    204                 ofItemAtPath: url.deletingLastPathComponent().path
    205             )
    206         #endif
    207         guard let destination = CGImageDestinationCreateWithURL(
    208             url as CFURL, UTType.png.identifier as CFString, 1, nil
    209         ) else {
    210             throw RadrootsAppleMediaPreparationError.preparationFailure
    211         }
    212         CGImageDestinationAddImage(destination, image, [:] as CFDictionary)
    213         guard CGImageDestinationFinalize(destination) else {
    214             throw RadrootsAppleMediaPreparationError.preparationFailure
    215         }
    216     }
    217 
    218     private func requireProtectedData() throws {
    219         guard protectedData.currentState() == .available else {
    220             throw RadrootsAppleMediaPreparationError.unavailable
    221         }
    222     }
    223 
    224     private static func fileSize(at url: URL) throws -> Int {
    225         guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
    226             throw RadrootsAppleMediaPreparationError.preparationFailure
    227         }
    228         return size
    229     }
    230 }
    231 
    232 enum RadrootsAppleFileDigest {
    233     static func sha256(_ data: Data) -> String {
    234         CryptoKit.SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
    235     }
    236 
    237     static func sha256(at url: URL) throws -> String {
    238         let handle = try FileHandle(forReadingFrom: url)
    239         defer { try? handle.close() }
    240         var hasher = CryptoKit.SHA256()
    241         while true {
    242             let chunk = try handle.read(upToCount: 64 * 1024) ?? Data()
    243             if chunk.isEmpty {
    244                 break
    245             }
    246             hasher.update(data: chunk)
    247         }
    248         return hasher.finalize().map { String(format: "%02x", $0) }.joined()
    249     }
    250 }