diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index daafda82..4d7d9043 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -230,6 +230,73 @@ extension AppManager return authenticationOperation } + + func deactivateApps(for app: ALTApplication, presentingViewController: UIViewController, completion: @escaping (Result) -> Void) + { + guard let activeAppsLimit = UserDefaults.standard.activeAppsLimit else { return completion(.success(())) } + + DispatchQueue.main.async { + let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext) + .filter { $0.bundleIdentifier != app.bundleIdentifier } // Don't count app towards total if it matches activating app + .sorted { ($0.name, $0.refreshedDate) < ($1.name, $1.refreshedDate) } + + var title: String = NSLocalizedString("Cannot Activate More than 3 Apps", comment: "") + let message: String + + if UserDefaults.standard.activeAppLimitIncludesExtensions + { + if app.appExtensions.isEmpty + { + message = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions. Please choose an app to deactivate.", comment: "") + } + else + { + title = NSLocalizedString("Cannot Activate More than 3 Apps and App Extensions", comment: "") + + let appExtensionText = app.appExtensions.count == 1 ? NSLocalizedString("app extension", comment: "") : NSLocalizedString("app extensions", comment: "") + message = String(format: NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions, and “%@” contains %@ %@. Please choose an app to deactivate.", comment: ""), app.name, NSNumber(value: app.appExtensions.count), appExtensionText) + } + } + else + { + message = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps. Please choose an app to deactivate.", comment: "") + } + + let activeAppsCount = activeApps.map { $0.requiredActiveSlots }.reduce(0, +) + + let availableActiveApps = max(activeAppsLimit - activeAppsCount, 0) + let requiredActiveSlots = UserDefaults.standard.activeAppLimitIncludesExtensions ? (1 + app.appExtensions.count) : 1 + guard requiredActiveSlots > availableActiveApps else { return completion(.success(())) } + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in + completion(.failure(OperationError.cancelled)) + }) + + for activeApp in activeApps where activeApp.bundleIdentifier != StoreApp.altstoreAppID + { + alertController.addAction(UIAlertAction(title: activeApp.name, style: .default) { (action) in + activeApp.isActive = false + + self.deactivate(activeApp, presentingViewController: presentingViewController) { (result) in + switch result + { + case .failure(let error): + activeApp.managedObjectContext?.perform { + activeApp.isActive = true + completion(.failure(error)) + } + + case .success: + self.deactivateApps(for: app, presentingViewController: presentingViewController, completion: completion) + } + } + }) + } + + presentingViewController.present(alertController, animated: true, completion: nil) + } + } } extension AppManager @@ -770,7 +837,7 @@ private extension AppManager return group } - private func _install(_ app: AppProtocol, operation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext? = nil, additionalEntitlements: [ALTEntitlement: Any]? = nil, cacheApp: Bool = true, completionHandler: @escaping (Result) -> Void) -> Progress + private func _install(_ app: AppProtocol, operation appOperation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext? = nil, additionalEntitlements: [ALTEntitlement: Any]? = nil, cacheApp: Bool = true, completionHandler: @escaping (Result) -> Void) -> Progress { let progress = Progress.discreteProgress(totalUnitCount: 100) @@ -778,7 +845,7 @@ private extension AppManager assert(context.authenticatedContext === group.context) context.beginInstallationHandler = { (installedApp) in - switch operation + switch appOperation { case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID: // AltStore will quit before installation finishes, @@ -843,6 +910,43 @@ private extension AppManager verifyOperation.addDependency(downloadOperation) + /* Deactivate Apps (if necessary) */ + let deactivateAppsOperation = RSTAsyncBlockOperation { [weak self] (operation) in + do + { + // Only attempt to deactivate apps if we're installing a new app. + // We handle deactivating apps separately when activating an app. + guard case .install = appOperation else { + operation.finish() + return + } + + if let error = context.error + { + throw error + } + + guard let app = context.app, let presentingViewController = context.authenticatedContext.presentingViewController else { throw OperationError.invalidParameters } + + self?.deactivateApps(for: app, presentingViewController: presentingViewController) { result in + switch result + { + case .failure(let error): group.context.error = error + case .success: break + } + + operation.finish() + } + } + catch + { + group.context.error = error + operation.finish() + } + } + deactivateAppsOperation.addDependency(verifyOperation) + + /* Refresh Anisette Data */ let refreshAnisetteDataOperation = FetchAnisetteDataOperation(context: group.context) refreshAnisetteDataOperation.resultHandler = { (result) in @@ -852,7 +956,7 @@ private extension AppManager case .success(let anisetteData): group.context.session?.anisetteData = anisetteData } } - refreshAnisetteDataOperation.addDependency(verifyOperation) + refreshAnisetteDataOperation.addDependency(deactivateAppsOperation) /* Fetch Provisioning Profiles */ @@ -921,7 +1025,7 @@ private extension AppManager progress.addChild(installOperation.progress, withPendingUnitCount: 30) installOperation.addDependency(sendAppOperation) - let operations = [downloadOperation, verifyOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation] + let operations = [downloadOperation, verifyOperation, deactivateAppsOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation] group.add(operations) self.run(operations, context: group.context) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 987e8f8c..857d4f45 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -9,6 +9,7 @@ import UIKit import MobileCoreServices import Intents +import Combine import AltStoreCore import AltSign @@ -1038,46 +1039,76 @@ private extension MyAppsViewController func activate(_ installedApp: InstalledApp) { - func activate() + func finish(_ result: Result) { - installedApp.isActive = true - - AppManager.shared.activate(installedApp, presentingViewController: self) { (result) in - do - { - let app = try result.get() + do + { + let app = try result.get() + app.managedObjectContext?.perform { try? app.managedObjectContext?.save() } - catch - { - print("Failed to activate app:", error) + } + catch OperationError.cancelled + { + // Ignore + } + catch + { + print("Failed to activate app:", error) + + DispatchQueue.main.async { + installedApp.isActive = false - DispatchQueue.main.async { - installedApp.isActive = false - - let toastView = ToastView(error: error) - toastView.show(in: self) - } + let toastView = ToastView(error: error) + toastView.show(in: self) } } } - - if UserDefaults.standard.activeAppsLimit != nil + + if UserDefaults.standard.activeAppsLimit != nil, #available(iOS 13, *) { - self.deactivateApps(for: installedApp) { (shouldContinue) in - if shouldContinue - { - activate() + // UserDefaults.standard.activeAppsLimit is only non-nil on iOS 13.3.1 or later, so the #available check is just so we can use Combine. + + guard let app = ALTApplication(fileURL: installedApp.fileURL) else { return finish(.failure(OperationError.invalidApp)) } + + var cancellable: AnyCancellable? + cancellable = DatabaseManager.shared.viewContext.registeredObjects.publisher + .compactMap { $0 as? InstalledApp } + .filter(\.isActive) + .map { $0.publisher(for: \.isActive) } + .collect() + .flatMap { publishers in + Publishers.MergeMany(publishers) } - else - { - installedApp.isActive = false + .first { isActive in !isActive } + .sink { _ in + // A previously active app is now inactive, + // which means there are now enough slots to activate the app, + // so pre-emptively mark it as active to provide visual feedback sooner. + installedApp.isActive = true + cancellable?.cancel() + } + + AppManager.shared.deactivateApps(for: app, presentingViewController: self) { result in + cancellable?.cancel() + installedApp.managedObjectContext?.perform { + switch result + { + case .failure(let error): + installedApp.isActive = false + finish(.failure(error)) + + case .success: + installedApp.isActive = true + AppManager.shared.activate(installedApp, presentingViewController: self, completionHandler: finish(_:)) + } } } } else { - activate() + installedApp.isActive = true + AppManager.shared.activate(installedApp, presentingViewController: self, completionHandler: finish(_:)) } } @@ -1110,75 +1141,6 @@ private extension MyAppsViewController } } - func deactivateApps(for installedApp: InstalledApp, completion: @escaping (Bool) -> Void) - { - guard let activeAppsLimit = UserDefaults.standard.activeAppsLimit else { return completion(true) } - - let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext) - .filter { $0.bundleIdentifier != installedApp.bundleIdentifier } // Don't count app towards total if it matches activating app - - var title: String = NSLocalizedString("Cannot Activate More than 3 Apps", comment: "") - let message: String - - if UserDefaults.standard.activeAppLimitIncludesExtensions - { - if installedApp.appExtensions.isEmpty - { - message = NSLocalizedString("Free developer accounts are limited to 3 active apps and app extensions. Please choose an app to deactivate.", comment: "") - } - else - { - title = NSLocalizedString("Cannot Activate More than 3 Apps and App Extensions", comment: "") - - let appExtensionText = installedApp.appExtensions.count == 1 ? NSLocalizedString("app extension", comment: "") : NSLocalizedString("app extensions", comment: "") - message = String(format: NSLocalizedString("Free developer accounts are limited to 3 active apps and app extensions, and “%@” contains %@ %@. Please choose an app to deactivate.", comment: ""), installedApp.name, NSNumber(value: installedApp.appExtensions.count), appExtensionText) - } - } - else - { - message = NSLocalizedString("Free developer accounts are limited to 3 active apps. Please choose an app to deactivate.", comment: "") - } - - let activeAppsCount = activeApps.map { $0.requiredActiveSlots }.reduce(0, +) - - let availableActiveApps = max(activeAppsLimit - activeAppsCount, 0) - guard installedApp.requiredActiveSlots > availableActiveApps else { return completion(true) } - - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in - completion(false) - }) - - for app in activeApps where app.bundleIdentifier != StoreApp.altstoreAppID - { - alertController.addAction(UIAlertAction(title: app.name, style: .default) { (action) in - let availableActiveApps = availableActiveApps + app.requiredActiveSlots - if availableActiveApps >= installedApp.requiredActiveSlots - { - // There are enough slots now to activate the app, so pre-emptively - // mark it as active to provide visual feedback sooner. - installedApp.isActive = true - } - - self.deactivate(app) { (result) in - installedApp.managedObjectContext?.perform { - switch result - { - case .failure: - installedApp.isActive = false - completion(false) - - case .success: - self.deactivateApps(for: installedApp, completion: completion) - } - } - } - }) - } - - self.present(alertController, animated: true, completion: nil) - } - func remove(_ installedApp: InstalledApp) { let title = String(format: NSLocalizedString("Remove “%@” from AltStore?", comment: ""), installedApp.name)