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 }