diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index cb0da7e0..f710f71a 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -389,6 +389,31 @@ extension AppManager } } + func backup(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) + { + let group = RefreshGroup() + group.completionHandler = { (results) in + do + { + guard let result = results.values.first else { throw OperationError.unknown } + + let installedApp = try result.get() + assert(installedApp.managedObjectContext != nil) + + installedApp.managedObjectContext?.perform { + completionHandler(.success(installedApp)) + } + } + catch + { + completionHandler(.failure(error)) + } + } + + let operation = AppOperation.backup(installedApp) + self.perform([operation], presentingViewController: presentingViewController, group: group) + } + func restore(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) { let group = RefreshGroup() @@ -479,14 +504,15 @@ private extension AppManager case refresh(InstalledApp) case activate(InstalledApp) case deactivate(InstalledApp) + case backup(InstalledApp) case restore(InstalledApp) var app: AppProtocol { switch self { - case .install(let app), .update(let app), - .refresh(let app as AppProtocol), .activate(let app as AppProtocol), - .deactivate(let app as AppProtocol), .restore(let app as AppProtocol): + case .install(let app), .update(let app), .refresh(let app as AppProtocol), + .activate(let app as AppProtocol), .deactivate(let app as AppProtocol), + .backup(let app as AppProtocol), .restore(let app as AppProtocol): return app } } @@ -606,6 +632,12 @@ private extension AppManager } progress?.addChild(deactivateProgress, withPendingUnitCount: 80) + case .backup(let app): + let backupProgress = self._backup(app, operation: operation, group: group) { (result) in + self.finish(operation, result: result, group: group, progress: progress) + } + progress?.addChild(backupProgress, withPendingUnitCount: 80) + case .restore(let app): // Restoring, which is effectively just activating an app. @@ -1017,6 +1049,68 @@ private extension AppManager return progress } + private func _backup(_ app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, completionHandler: @escaping (Result) -> Void) -> Progress + { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + let restoreContext = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) + let appContext = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) + + let installBackupAppProgress = Progress.discreteProgress(totalUnitCount: 100) + let installBackupAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in + app.managedObjectContext?.perform { + guard let self = self else { return } + + let progress = self._installBackupApp(for: app, operation: appOperation, group: group, context: restoreContext) { (result) in + switch result + { + case .success(let installedApp): restoreContext.installedApp = installedApp + case .failure(let error): + restoreContext.error = error + appContext.error = error + } + + operation.finish() + } + installBackupAppProgress.addChild(progress, withPendingUnitCount: 100) + } + } + progress.addChild(installBackupAppProgress, withPendingUnitCount: 30) + + let backupAppOperation = BackupAppOperation(action: .backup, context: restoreContext) + backupAppOperation.resultHandler = { (result) in + switch result + { + case .success: break + case .failure(let error): + restoreContext.error = error + appContext.error = error + } + } + backupAppOperation.addDependency(installBackupAppOperation) + progress.addChild(backupAppOperation.progress, withPendingUnitCount: 15) + + let installAppProgress = Progress.discreteProgress(totalUnitCount: 100) + let installAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in + app.managedObjectContext?.perform { + guard let self = self else { return } + + let progress = self._install(app, operation: appOperation, group: group, context: appContext) { (result) in + completionHandler(result) + operation.finish() + } + installAppProgress.addChild(progress, withPendingUnitCount: 100) + } + } + installAppOperation.addDependency(backupAppOperation) + progress.addChild(installAppProgress, withPendingUnitCount: 55) + + group.add([installBackupAppOperation, backupAppOperation, installAppOperation]) + self.run([installBackupAppOperation, installAppOperation, backupAppOperation], context: group.context) + + return progress + } + private func _installBackupApp(for app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext, completionHandler: @escaping (Result) -> Void) -> Progress { let progress = Progress.discreteProgress(totalUnitCount: 100) @@ -1176,7 +1270,7 @@ private extension AppManager event = nil case .update: event = .updatedApp(installedApp) - case .activate, .deactivate, .restore: event = nil + case .activate, .deactivate, .backup, .restore: event = nil } if let event = event @@ -1234,7 +1328,7 @@ private extension AppManager switch operation { case .install, .update: return self.installationProgress[operation.bundleIdentifier] - case .refresh, .activate, .deactivate, .restore: return self.refreshProgress[operation.bundleIdentifier] + case .refresh, .activate, .deactivate, .backup, .restore: return self.refreshProgress[operation.bundleIdentifier] } } @@ -1243,7 +1337,7 @@ private extension AppManager switch operation { case .install, .update: self.installationProgress[operation.bundleIdentifier] = progress - case .refresh, .activate, .deactivate, .restore: self.refreshProgress[operation.bundleIdentifier] = progress + case .refresh, .activate, .deactivate, .backup, .restore: self.refreshProgress[operation.bundleIdentifier] = progress } } } diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 4022bf90..a646a31b 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -1174,6 +1174,45 @@ private extension MyAppsViewController self.present(alertController, animated: true, completion: nil) } + func backup(_ installedApp: InstalledApp) + { + let title = NSLocalizedString("Start Backup?", comment: "") + let message = NSLocalizedString("This will replace any previous backups. Please leave AltStore open until the backup is complete.", comment: "") + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) + alertController.addAction(.cancel) + + let actionTitle = String(format: NSLocalizedString("Back Up %@", comment: ""), installedApp.name) + alertController.addAction(UIAlertAction(title: actionTitle, style: .default, handler: { (action) in + AppManager.shared.backup(installedApp, presentingViewController: self) { (result) in + do + { + let app = try result.get() + try? app.managedObjectContext?.save() + + print("Finished backing up app:", app.bundleIdentifier) + } + catch + { + print("Failed to back up app:", error) + + DispatchQueue.main.async { + let toastView = ToastView(error: error) + toastView.show(in: self) + + self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue]) + } + } + } + + DispatchQueue.main.async { + self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue]) + } + })) + + self.present(alertController, animated: true, completion: nil) + } + func restore(_ installedApp: InstalledApp) { let message = String(format: NSLocalizedString("This will replace all data you currently have in %@.", comment: ""), installedApp.name) @@ -1404,6 +1443,10 @@ extension MyAppsViewController self.remove(installedApp) } + let backupAction = UIAction(title: NSLocalizedString("Back Up", comment: ""), image: UIImage(systemName: "doc.on.doc")) { (action) in + self.backup(installedApp) + } + let exportBackupAction = UIAction(title: NSLocalizedString("Export Backup", comment: ""), image: UIImage(systemName: "arrow.up.doc")) { (action) in self.exportBackup(for: installedApp) } @@ -1419,14 +1462,26 @@ extension MyAppsViewController if installedApp.isActive { actions.append(refreshAction) - actions.append(deactivateAction) } else { actions.append(activateAction) } + + if installedApp.isActive + { + actions.append(backupAction) + } + else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported + { + // Allow backing up inactive apps if they are still installed, + // but on an iOS version that no longer supports legacy deactivation. + // This handles edge case where you can't install more apps until you + // delete some, but can't activate inactive apps again to back them up first. + actions.append(backupAction) + } - if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp), !UserDefaults.standard.isLegacyDeactivationSupported + if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) { var backupExists = false var outError: NSError? = nil @@ -1454,6 +1509,11 @@ extension MyAppsViewController } } + if installedApp.isActive + { + actions.append(deactivateAction) + } + #if DEBUG if installedApp.bundleIdentifier != StoreApp.altstoreAppID