field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraLocalRestoreHost.swift (5692B)


      1 import Foundation
      2 import RadrootsKit
      3 import TeraKitBindings
      4 import UIKit
      5 
      6 /// Explicit cold recovery only. It must run before ordinary runtime/transfer
      7 /// admission in a fresh process. Live work is preserved and makes recovery busy.
      8 actor TeraLocalRestoreHost: TeraRestoreHost {
      9   let roots: RadrootsAppleFileRoots
     10   let activeTransfers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>
     11   let protectedData: @Sendable () async -> Bool
     12   private var reservation: RadrootsFileMaintenanceReservation?
     13   private var selected: FfiRestoreRequest?
     14 
     15   init(roots: RadrootsAppleFileRoots,
     16        activeTransfers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>,
     17        protectedData: @escaping @Sendable () async -> Bool = currentProtectedData) throws
     18   {
     19     self.roots = try TeraDurableMediaRoots.selectingStaging(in: roots)
     20     self.activeTransfers = activeTransfers
     21     self.protectedData = protectedData
     22   }
     23 
     24   static func production(roots: RadrootsAppleFileRoots) throws -> TeraLocalRestoreHost {
     25     let identifier = try RadrootsBackgroundTransferValidation.normalizedIdentifier(
     26       TeraRemoteQualificationEnvironment.backgroundTransferIdentifier(appIdentifier: roots.appIdentifier)
     27     )
     28     let store = RadrootsAppleBackgroundTransferStore(roots: roots)
     29     let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
     30     let downloads = try roots.resolvedURL(for: RadrootsFileReference(
     31       scope: .temporary, relativePath: "background_transfers/\(identifier)/downloads"
     32     ), allowRootDirectory: true)
     33     let adapters = try RadrootsAppleBackgroundTransferAdapters.live(
     34       sessionIdentifier: identifier, store: store, fileResolver: resolver, downloadStagingRoot: downloads
     35     )
     36     return try Self(roots: roots, activeTransfers: adapters.activeTransferIdentifiers)
     37   }
     38 
     39   func restore(store: FfiRestoreStore, request: FfiRestoreRequest) async throws -> FfiRestoreGuard {
     40     guard reservation == nil, selected == nil,
     41       store.applicationSupportDirectory == roots.dataRoot.path,
     42       store.publicKey == request.backup.publicKey, store.sourceGeneration == request.backup.sourceGeneration,
     43       let admission = try RadrootsAppleFileMaintenance(root: roots.dataRoot).reserveMaintenance()
     44     else { throw Self.failure("restore_busy") }
     45     reservation = admission
     46     selected = request
     47     defer { reservation = nil; selected = nil; withExtendedLifetime(admission) {} }
     48     try requireNoLiveUsers(admission)
     49     return try await restoreLocalApplicationBackup(store: store, request: request, host: self)
     50   }
     51 
     52   func requireQuiescent() async throws {
     53     guard let reservation, selected != nil, await protectedData(), !Task.isCancelled else {
     54       throw Self.failure("restore_recovery_required")
     55     }
     56     do {
     57       try reservation.validate()
     58       guard try await activeTransfers().isEmpty else { throw Self.failure("restore_busy") }
     59       try Task.checkCancellation()
     60       try reservation.validate()
     61     } catch { throw Self.bound(error) }
     62   }
     63 
     64   func loadCompleted(request: FfiRestoreRequest) async throws -> Data {
     65     guard selected == request else { throw Self.failure("restore_conflict") }
     66     try await requireQuiescent()
     67     do {
     68       guard let bytes = try TeraBackupFiles(roots: roots, publicKey: request.backup.publicKey).read(id: request.backup.backupId, completed: true) else {
     69         throw Self.failure("restore_recovery_required")
     70       }
     71       return bytes
     72     } catch { throw Self.bound(error) }
     73   }
     74 
     75   func restoreMedia(manifest: FfiBackupManifest) async throws {
     76     guard let selected, selected.backup == manifest.request else { throw Self.failure("restore_conflict") }
     77     try await requireQuiescent()
     78     do {
     79       try TeraRestoreFiles(roots: roots, publicKey: selected.backup.publicKey).restoreMedia(manifest)
     80     } catch { throw Self.bound(error) }
     81   }
     82 
     83   func armGuard(guard guardRecord: FfiRestoreGuard) async throws {
     84     guard selected == guardRecord.request else { throw Self.failure("restore_conflict") }
     85     try await requireQuiescent()
     86     do {
     87       try TeraRestoreFiles(roots: roots, publicKey: guardRecord.request.backup.publicKey).arm(guardRecord)
     88     } catch { throw Self.bound(error) }
     89   }
     90 
     91   private func requireNoLiveUsers(_ admission: RadrootsFileMaintenanceReservation) throws {
     92     let scan = try admission.openDirectory()
     93     var remaining = Int(mediaCleanupLimits().directoryEntries)
     94     while remaining > 0 {
     95       let page = try scan.next(limit: min(64, remaining))
     96       remaining -= page.scannedEntries
     97       if let users = page.entries.first(where: { $0.name == TeraMediaProcessUse.directory }) {
     98         guard users.kind == .directory,
     99           try TeraMediaProcessUse.hasNoLiveUsers(roots: roots, scan: admission.openDirectory(relativePath: TeraMediaProcessUse.directory), remaining: &remaining)
    100         else { throw Self.failure("restore_busy") }
    101       }
    102       if page.reachedEnd {
    103         return
    104       }
    105     }
    106     throw Self.failure("restore_capacity_exceeded")
    107   }
    108 
    109   nonisolated static func failure(_ code: String) -> TeraAppError {
    110     .Failure(report: TeraErrorRecord(schemaVersion: 1, code: code, category: "restore", retryable: false,
    111                                      recoveryActions: ["review_restore"], operationId: nil, capabilityId: nil,
    112                                      safeMessage: "Local recovery requires review before continuing."))
    113   }
    114 
    115   private nonisolated static func bound(_ error: Error) -> TeraAppError {
    116     error as? TeraAppError ?? failure("restore_recovery_required")
    117   }
    118 
    119   private static func currentProtectedData() async -> Bool {
    120     await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
    121   }
    122 }