// // BackupController.swift // AltBackup // // Created by Riley Testut on 5/12/20. // Copyright © 2020 Riley Testut. All rights reserved. // import Foundation extension ErrorUserInfoKey { static let sourceFile: String = "alt_sourceFile" static let sourceFileLine: String = "alt_sourceFileLine" } extension Error { var sourceDescription: String? { guard let sourceFile = (self as NSError).userInfo[ErrorUserInfoKey.sourceFile] as? String, let sourceFileLine = (self as NSError).userInfo[ErrorUserInfoKey.sourceFileLine] else { return nil } return "(\((sourceFile as NSString).lastPathComponent), Line \(sourceFileLine))" } } struct BackupError: ALTLocalizedError { enum Code: ALTErrorEnum, RawRepresentable { case invalidBundleID case appGroupNotFound(String?) case randomError // Used for debugging. // Provide failure reason for each error code var errorFailureReason: String { switch self { case .invalidBundleID: return NSLocalizedString("The bundle identifier is invalid.", comment: "") case .appGroupNotFound(let appGroup): if let appGroup = appGroup { return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup) } else { return NSLocalizedString("The AltStore app group could not be found.", comment: "") } case .randomError: return NSLocalizedString("A random error occurred.", comment: "") } } static var errorDomain: String { return "com.sidestore.BackupError" } // Add a raw value for RawRepresentable conformance var rawValue: Int { switch self { case .invalidBundleID: return 0 case .appGroupNotFound: return 1 case .randomError: return 2 } } // Initializer for RawRepresentable init?(rawValue: Int) { switch rawValue { case 0: self = .invalidBundleID case 1: self = .appGroupNotFound(nil) case 2: self = .randomError default: return nil } } } let code: Code let sourceFile: String let sourceFileLine: Int var failure: String? var errorTitle: String? var errorFailure: String? var failureReason: String? { switch self.code { case .invalidBundleID: return NSLocalizedString("The bundle identifier is invalid.", comment: "") case .appGroupNotFound(let appGroup): if let appGroup = appGroup { return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup) } else { return NSLocalizedString("The AltStore app group could not be found.", comment: "") } case .randomError: return NSLocalizedString("A random error occured.", comment: "") } } var errorUserInfo: [String : Any] { let userInfo: [String: Any?] = [NSLocalizedDescriptionKey: self.errorDescription, NSLocalizedFailureReasonErrorKey: self.failureReason, NSLocalizedFailureErrorKey: self.failure, ErrorUserInfoKey.sourceFile: self.sourceFile, ErrorUserInfoKey.sourceFileLine: self.sourceFileLine] return userInfo.compactMapValues { $0 } } // Implement description for CustomStringConvertible var description: String { return "\(errorTitle ?? "Unknown Error"): \(failureReason ?? "No reason available")" } init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line) { self.code = code self.failure = description self.sourceFile = file self.sourceFileLine = line self.errorTitle = NSLocalizedString("Backup Error", comment: "") self.errorFailure = description } } class BackupController: NSObject { private let fileCoordinator = NSFileCoordinator(filePresenter: nil) private let operationQueue = OperationQueue() override init() { self.operationQueue.name = "AltBackup-BackupQueue" } func performBackup(completionHandler: @escaping (Result) -> Void) { do { guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else { throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to create backup directory.", comment: "")) } guard let altstoreAppGroup = Bundle.main.altstoreAppGroup, let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup) else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: "")) } let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups") // Use temporary directory to prevent messing up successful backup with incomplete one. let temporaryAppBackupDirectory = backupsDirectory.appendingPathComponent("Temp", isDirectory: true).appendingPathComponent(UUID().uuidString) let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier) let writingIntent = NSFileAccessIntent.writingIntent(with: temporaryAppBackupDirectory, options: []) let replacementIntent = NSFileAccessIntent.writingIntent(with: appBackupDirectory, options: [.forReplacing]) self.fileCoordinator.coordinate(with: [writingIntent, replacementIntent], queue: self.operationQueue) { (error) in do { if let error = error { throw error } do { let mainGroupBackupDirectory = temporaryAppBackupDirectory.appendingPathComponent("App") try FileManager.default.createDirectory(at: mainGroupBackupDirectory, withIntermediateDirectories: true, attributes: nil) let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent) if FileManager.default.fileExists(atPath: backupDocumentsDirectory.path) { try FileManager.default.removeItem(at: backupDocumentsDirectory) } if FileManager.default.fileExists(atPath: documentsDirectory.path) { try FileManager.default.copyItem(at: documentsDirectory, to: backupDocumentsDirectory) } print("Copied Documents directory from \(documentsDirectory) to \(backupDocumentsDirectory)") let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent) if FileManager.default.fileExists(atPath: backupLibraryDirectory.path) { try FileManager.default.removeItem(at: backupLibraryDirectory) } if FileManager.default.fileExists(atPath: libraryDirectory.path) { try FileManager.default.copyItem(at: libraryDirectory, to: backupLibraryDirectory) } print("Copied Library directory from \(libraryDirectory) to \(backupLibraryDirectory)") } for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup { guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to create app group backup directory.", comment: "")) } let backupAppGroupURL = temporaryAppBackupDirectory.appendingPathComponent(appGroup) // There are several system hidden files that we don't have permission to read, so we just skip all hidden files in app group directories. try self.copyDirectoryContents(at: appGroupURL, to: backupAppGroupURL, options: [.skipsHiddenFiles]) } // Replace previous backup with new backup. _ = try FileManager.default.replaceItemAt(appBackupDirectory, withItemAt: temporaryAppBackupDirectory) print("Replaced previous backup with new backup:", temporaryAppBackupDirectory) completionHandler(.success(())) } catch { do { try FileManager.default.removeItem(at: temporaryAppBackupDirectory) } catch { print("Failed to remove temporary directory.", error) } completionHandler(.failure(error)) } } } catch { completionHandler(.failure(error)) } } func restoreBackup(completionHandler: @escaping (Result) -> Void) { do { guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else { throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to access backup.", comment: "")) } guard let altstoreAppGroup = Bundle.main.altstoreAppGroup, let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup) else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to access backup.", comment: "")) } let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups") let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier) let readingIntent = NSFileAccessIntent.readingIntent(with: appBackupDirectory, options: []) self.fileCoordinator.coordinate(with: [readingIntent], queue: self.operationQueue) { (error) in do { if let error = error { throw error } let mainGroupBackupDirectory = appBackupDirectory.appendingPathComponent("App") let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent) let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent) try self.copyDirectoryContents(at: backupDocumentsDirectory, to: documentsDirectory) try self.copyDirectoryContents(at: backupLibraryDirectory, to: libraryDirectory) for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup { guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to read app group backup.", comment: "")) } let backupAppGroupURL = appBackupDirectory.appendingPathComponent(appGroup) try self.copyDirectoryContents(at: backupAppGroupURL, to: appGroupURL) } completionHandler(.success(())) } catch { completionHandler(.failure(error)) } } } catch { completionHandler(.failure(error)) } } } private extension BackupController { func copyDirectoryContents(at sourceDirectoryURL: URL, to destinationDirectoryURL: URL, options: FileManager.DirectoryEnumerationOptions = []) throws { guard FileManager.default.fileExists(atPath: sourceDirectoryURL.path) else { return } if !FileManager.default.fileExists(atPath: destinationDirectoryURL.path) { try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil) } for fileURL in try FileManager.default.contentsOfDirectory(at: sourceDirectoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: options) { let isDirectory = try fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false let destinationURL = destinationDirectoryURL.appendingPathComponent(fileURL.lastPathComponent) if FileManager.default.fileExists(atPath: destinationURL.path) { do { try FileManager.default.removeItem(at: destinationURL) } catch CocoaError.fileWriteNoPermission where isDirectory { try self.copyDirectoryContents(at: fileURL, to: destinationURL, options: options) continue } catch { print(error) throw error } } do { try FileManager.default.copyItem(at: fileURL, to: destinationURL) print("Copied item from \(fileURL) to \(destinationURL)") } catch let error where fileURL.lastPathComponent == "Inbox" && fileURL.deletingLastPathComponent().lastPathComponent == "Documents" { // Ignore errors for /Documents/Inbox print("Failed to copy Inbox directory:", error) } catch { print(error) throw error } } } }