diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index c5b1514d..445b0598 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -162,6 +162,9 @@ BFBBE2DF22931F73002097FA /* StoreApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2DE22931F73002097FA /* StoreApp.swift */; }; BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2E022931F81002097FA /* InstalledApp.swift */; }; BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1F38C22AEE3A4003AC21A /* DownloadAppOperation.swift */; }; + BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC57A642416C72400EB891E /* DeactivateAppOperation.swift */; }; + BFC57A6E2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC57A6D2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift */; }; + BFC57A702416FC7600EB891E /* InstalledAppsCollectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFC57A6F2416FC7600EB891E /* InstalledAppsCollectionHeaderView.xib */; }; BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2476D2284B9A500981D42 /* AppDelegate.swift */; }; BFD247752284B9A500981D42 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFD247732284B9A500981D42 /* Main.storyboard */; }; BFD247772284B9A700981D42 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BFD247762284B9A700981D42 /* Assets.xcassets */; }; @@ -484,6 +487,9 @@ BFBBE2DE22931F73002097FA /* StoreApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp.swift; sourceTree = ""; }; BFBBE2E022931F81002097FA /* InstalledApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledApp.swift; sourceTree = ""; }; BFC1F38C22AEE3A4003AC21A /* DownloadAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAppOperation.swift; sourceTree = ""; }; + BFC57A642416C72400EB891E /* DeactivateAppOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeactivateAppOperation.swift; sourceTree = ""; }; + BFC57A6D2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledAppsCollectionHeaderView.swift; sourceTree = ""; }; + BFC57A6F2416FC7600EB891E /* InstalledAppsCollectionHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstalledAppsCollectionHeaderView.xib; sourceTree = ""; }; BFD2476A2284B9A500981D42 /* AltStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AltStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; BFD2476D2284B9A500981D42 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BFD247742284B9A500981D42 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -936,6 +942,8 @@ BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */, BF08858422DE7EC800DE9F1E /* UpdateCollectionViewCell.swift */, BFB4323E22DE852000B7F8BC /* UpdateCollectionViewCell.xib */, + BFC57A6D2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift */, + BFC57A6F2416FC7600EB891E /* InstalledAppsCollectionHeaderView.xib */, ); path = "My Apps"; sourceTree = ""; @@ -1176,6 +1184,7 @@ BFE338DE22F0EADB002E24B9 /* FetchSourceOperation.swift */, BFA8172A23C5633D001B5953 /* FetchAnisetteDataOperation.swift */, BF56D2AB23DF8E170006506D /* FetchAppIDsOperation.swift */, + BFC57A642416C72400EB891E /* DeactivateAppOperation.swift */, ); path = Operations; sourceTree = ""; @@ -1428,6 +1437,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + BFC57A702416FC7600EB891E /* InstalledAppsCollectionHeaderView.xib in Resources */, BFB4323F22DE852000B7F8BC /* UpdateCollectionViewCell.xib in Resources */, BFE60738231ADF49002B0E8E /* Settings.storyboard in Resources */, BFD2477A2284B9A700981D42 /* LaunchScreen.storyboard in Resources */, @@ -1684,6 +1694,7 @@ BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */, BFBBE2DF22931F73002097FA /* StoreApp.swift in Sources */, BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */, + BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */, BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, BFD5D6F4230DDB0A007955AB /* Campaign.swift in Sources */, @@ -1709,6 +1720,7 @@ BF100C54232D7DAE006A8926 /* StoreAppPolicy.swift in Sources */, BF100C50232D7CD1006A8926 /* AltStoreToAltStore2.xcmappingmodel in Sources */, BF3D64B022E8D4B800E9056B /* AppContentViewControllerCells.swift in Sources */, + BFC57A6E2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift in Sources */, BFBBE2DD22931B20002097FA /* AltStore.xcdatamodeld in Sources */, BF56D2AA23DF88310006506D /* AppID.swift in Sources */, BF02419422F2156E00129732 /* RefreshAttempt.swift in Sources */, diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 99fa18d2..4a09c8bb 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -103,7 +103,7 @@ - + @@ -133,14 +133,14 @@ - + - + @@ -628,13 +628,13 @@ World - + - + @@ -663,7 +663,7 @@ World - + @@ -723,35 +723,8 @@ World - - - - - - - - - - - - - - - - - - - + @@ -951,6 +924,10 @@ World + + + + @@ -967,8 +944,4 @@ World - - - - diff --git a/AltStore/Extensions/UserDefaults+AltStore.swift b/AltStore/Extensions/UserDefaults+AltStore.swift index 828989f8..1756348b 100644 --- a/AltStore/Extensions/UserDefaults+AltStore.swift +++ b/AltStore/Extensions/UserDefaults+AltStore.swift @@ -22,6 +22,23 @@ extension UserDefaults @NSManaged var legacySideloadedApps: [String]? + var activeAppsLimit: Int? { + get { + return self._activeAppsLimit?.intValue + } + set { + if let value = newValue + { + self._activeAppsLimit = NSNumber(value: value) + } + else + { + self._activeAppsLimit = nil + } + } + } + @NSManaged @objc(activeAppsLimit) private var _activeAppsLimit: NSNumber? + func registerDefaults() { self.register(defaults: [#keyPath(UserDefaults.isBackgroundRefreshEnabled): true]) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index c6ac1229..658326e7 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -58,6 +58,8 @@ extension AppManager let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest fetchRequest.returnsObjectsAsFaults = false + var activeAppsCount = 0 + do { let installedApps = try context.fetch(fetchRequest) @@ -75,19 +77,29 @@ extension AppManager for app in installedApps { - let uti = UTTypeCopyDeclaration(app.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary? - - if app.bundleIdentifier == StoreApp.altstoreAppID - { + guard app.bundleIdentifier != StoreApp.altstoreAppID else { self.scheduleExpirationWarningLocalNotification(for: app) + continue } - else + + let uti = UTTypeCopyDeclaration(app.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary? + guard uti != nil || legacySideloadedApps.contains(app.bundleIdentifier) else { + // This UTI is not declared by any apps, which means this app has been deleted by the user. + // This app is also not a legacy sideloaded app, so we can assume it's fine to delete it. + context.delete(app) + continue + } + + if app.isActive { - if uti == nil && !legacySideloadedApps.contains(app.bundleIdentifier) + if let activeAppsLimit = UserDefaults.standard.activeAppsLimit, activeAppsCount >= activeAppsLimit - 1 { - // This UTI is not declared by any apps, which means this app has been deleted by the user. - // This app is also not a legacy sideloaded app, so we can assume it's fine to delete it. - context.delete(app) + // We have reached active apps limit (excluding AltStore itself), so mark additional active apps as inactive. + app.isActive = false + } + else + { + activeAppsCount += 1 } } } @@ -215,6 +227,42 @@ extension AppManager return self.perform(operations, presentingViewController: presentingViewController, group: group) } + func activate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result) -> Void) + { + let group = self.refresh([installedApp], presentingViewController: presentingViewController) + group.completionHandler = { (results) in + do + { + guard let result = results.values.first else { throw OperationError.unknown } + + let installedApp = try result.get() + installedApp.managedObjectContext?.perform { + installedApp.isActive = true + completionHandler(.success(installedApp)) + } + } + catch + { + completionHandler(.failure(error)) + } + } + } + + func deactivate(_ installedApp: InstalledApp, completionHandler: @escaping (Result) -> Void) + { + let context = OperationContext() + + let findServerOperation = self.findServer(context: context) { _ in } + + let deactivateAppOperation = DeactivateAppOperation(app: installedApp, context: context) + deactivateAppOperation.resultHandler = { (result) in + completionHandler(result) + } + deactivateAppOperation.addDependency(findServerOperation) + + self.run([deactivateAppOperation], requiresSerialQueue: true) + } + func installationProgress(for app: AppProtocol) -> Progress? { let progress = self.installationProgress[app.bundleIdentifier] @@ -468,7 +516,23 @@ private extension AppManager /* Refresh */ let refreshAppOperation = RefreshAppOperation(context: context) refreshAppOperation.resultHandler = { (result) in - completionHandler(result) + switch result + { + case .success(let installedApp): + completionHandler(.success(installedApp)) + + case .failure(ALTServerError.unknownRequest): + // Fall back to installation if AltServer doesn't support newer provisioning profile requests. + app.managedObjectContext?.perform { + let installProgress = self._install(app, group: group) { (result) in + completionHandler(result) + } + progress.addChild(installProgress, withPendingUnitCount: 40) + } + + case .failure(let error): + completionHandler(.failure(error)) + } } progress.addChild(refreshAppOperation.progress, withPendingUnitCount: 40) refreshAppOperation.addDependency(fetchProvisioningProfilesOperation) diff --git a/AltStore/Model/AltStore.xcdatamodeld/AltStore 4.xcdatamodel/contents b/AltStore/Model/AltStore.xcdatamodeld/AltStore 4.xcdatamodel/contents index 0b0dd4f7..9abea4fa 100644 --- a/AltStore/Model/AltStore.xcdatamodeld/AltStore 4.xcdatamodel/contents +++ b/AltStore/Model/AltStore.xcdatamodeld/AltStore 4.xcdatamodel/contents @@ -36,6 +36,7 @@ + @@ -154,8 +155,9 @@ + - + @@ -163,6 +165,5 @@ - \ No newline at end of file diff --git a/AltStore/Model/DatabaseManager.swift b/AltStore/Model/DatabaseManager.swift index 1efd0b93..8a627651 100644 --- a/AltStore/Model/DatabaseManager.swift +++ b/AltStore/Model/DatabaseManager.swift @@ -194,9 +194,22 @@ private extension DatabaseManager } } + let cachedRefreshedDate = installedApp.refreshedDate + let cachedExpirationDate = installedApp.expirationDate + // Must go after comparing versions to see if we need to update our cached AltStore app bundle. installedApp.update(resignedApp: localApp, certificateSerialNumber: serialNumber) + if installedApp.refreshedDate < cachedRefreshedDate + { + // Embedded provisioning profile has a creation date older than our refreshed date. + // This most likely means we've refreshed the app since then, and profile is now outdated, + // so use cached dates instead (i.e. not the dates updated from provisioning profile). + + installedApp.refreshedDate = cachedRefreshedDate + installedApp.expirationDate = cachedExpirationDate + } + do { try context.save() diff --git a/AltStore/Model/InstalledApp.swift b/AltStore/Model/InstalledApp.swift index ea157f94..528df764 100644 --- a/AltStore/Model/InstalledApp.swift +++ b/AltStore/Model/InstalledApp.swift @@ -36,6 +36,8 @@ class InstalledApp: NSManagedObject, InstalledAppProtocol @NSManaged var expirationDate: Date @NSManaged var installedDate: Date + @NSManaged var isActive: Bool + @NSManaged var certificateSerialNumber: String? /* Relationships */ @@ -98,7 +100,15 @@ extension InstalledApp class func updatesFetchRequest() -> NSFetchRequest { let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest - fetchRequest.predicate = NSPredicate(format: "%K != nil AND %K != %K", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.version)) + fetchRequest.predicate = NSPredicate(format: "%K == YES AND %K != nil AND %K != %K", + #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.version)) + return fetchRequest + } + + class func activeAppsFetchRequest() -> NSFetchRequest + { + let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest + fetchRequest.predicate = NSPredicate(format: "%K == YES", #keyPath(InstalledApp.isActive)) return fetchRequest } @@ -110,9 +120,15 @@ extension InstalledApp return altStore } + class func fetchActiveApps(in context: NSManagedObjectContext) -> [InstalledApp] + { + let activeApps = InstalledApp.fetch(InstalledApp.activeAppsFetchRequest(), in: context) + return activeApps + } + class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp] { - var predicate = NSPredicate(format: "%K != %@", #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) + var predicate = NSPredicate(format: "%K == YES AND %K != %@", #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated { @@ -142,7 +158,8 @@ extension InstalledApp // Date 6 hours before now. let date = Date().addingTimeInterval(-1 * 6 * 60 * 60) - var predicate = NSPredicate(format: "(%K < %@) AND (%K != %@)", + var predicate = NSPredicate(format: "(%K == YES) AND (%K < %@) AND (%K != %@)", + #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.refreshedDate), date as NSDate, #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) diff --git a/AltStore/My Apps/InstalledAppsCollectionHeaderView.swift b/AltStore/My Apps/InstalledAppsCollectionHeaderView.swift new file mode 100644 index 00000000..4246d023 --- /dev/null +++ b/AltStore/My Apps/InstalledAppsCollectionHeaderView.swift @@ -0,0 +1,43 @@ +// +// InstalledAppsCollectionHeaderView.swift +// AltStore +// +// Created by Riley Testut on 3/9/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import UIKit + +class InstalledAppsCollectionHeaderView: UICollectionReusableView +{ + let textLabel: UILabel + let button: UIButton + + override init(frame: CGRect) + { + self.textLabel = UILabel() + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.font = UIFont.systemFont(ofSize: 24, weight: .bold) + + self.button = UIButton(type: .system) + self.button.translatesAutoresizingMaskIntoConstraints = false + self.button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) + + super.init(frame: frame) + + self.addSubview(self.textLabel) + self.addSubview(self.button) + + NSLayoutConstraint.activate([self.textLabel.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor), + self.textLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor)]) + + NSLayoutConstraint.activate([self.button.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor), + self.button.firstBaselineAnchor.constraint(equalTo: self.textLabel.firstBaselineAnchor)]) + + self.preservesSuperviewLayoutMargins = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/AltStore/My Apps/InstalledAppsCollectionHeaderView.xib b/AltStore/My Apps/InstalledAppsCollectionHeaderView.xib new file mode 100644 index 00000000..9004e881 --- /dev/null +++ b/AltStore/My Apps/InstalledAppsCollectionHeaderView.xib @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/My Apps/MyAppsComponents.swift b/AltStore/My Apps/MyAppsComponents.swift index 6fe9e23a..e7ddc95d 100644 --- a/AltStore/My Apps/MyAppsComponents.swift +++ b/AltStore/My Apps/MyAppsComponents.swift @@ -18,18 +18,9 @@ class InstalledAppCollectionViewCell: UICollectionViewCell self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] self.contentView.preservesSuperviewLayoutMargins = true - - self.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") - self.bannerView.buttonLabel.isHidden = false } } -class InstalledAppsCollectionHeaderView: UICollectionReusableView -{ - @IBOutlet var textLabel: UILabel! - @IBOutlet var button: UIButton! -} - class InstalledAppsCollectionFooterView: UICollectionReusableView { @IBOutlet var textLabel: UILabel! diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index c2e162dc..6e46b09b 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -23,7 +23,8 @@ extension MyAppsViewController { case noUpdates case updates - case installedApps + case activeApps + case inactiveApps } } @@ -32,10 +33,10 @@ class MyAppsViewController: UICollectionViewController private lazy var dataSource = self.makeDataSource() private lazy var noUpdatesDataSource = self.makeNoUpdatesDataSource() private lazy var updatesDataSource = self.makeUpdatesDataSource() - private lazy var installedAppsDataSource = self.makeInstalledAppsDataSource() + private lazy var activeAppsDataSource = self.makeActiveAppsDataSource() + private lazy var inactiveAppsDataSource = self.makeInactiveAppsDataSource() private var prototypeUpdateCell: UpdateCollectionViewCell! - private var longPressGestureRecognizer: UILongPressGestureRecognizer! private var sideloadingProgressView: UIProgressView! // State @@ -89,6 +90,8 @@ class MyAppsViewController: UICollectionViewController self.collectionView.register(UpdateCollectionViewCell.nib, forCellWithReuseIdentifier: "UpdateCell") self.collectionView.register(UpdatesCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader") + self.collectionView.register(InstalledAppsCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "ActiveAppsHeader") + self.collectionView.register(InstalledAppsCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InactiveAppsHeader") self.sideloadingProgressView = UIProgressView(progressViewStyle: .bar) self.sideloadingProgressView.translatesAutoresizingMaskIntoConstraints = false @@ -102,12 +105,6 @@ class MyAppsViewController: UICollectionViewController self.sideloadingProgressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), self.sideloadingProgressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)]) } - - // Gestures - self.longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(MyAppsViewController.handleLongPressGesture(_:))) - self.collectionView.addGestureRecognizer(self.longPressGestureRecognizer) - - self.registerForPreviewing(with: self, sourceView: self.collectionView) } override func viewWillAppear(_ animated: Bool) @@ -158,7 +155,7 @@ private extension MyAppsViewController { func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource { - let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [self.noUpdatesDataSource, self.updatesDataSource, self.installedAppsDataSource]) + let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [self.noUpdatesDataSource, self.updatesDataSource, self.activeAppsDataSource, self.inactiveAppsDataSource]) dataSource.proxy = self return dataSource } @@ -261,9 +258,9 @@ private extension MyAppsViewController return dataSource } - func makeInstalledAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource + func makeActiveAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { - let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest + let fetchRequest = InstalledApp.activeAppsFetchRequest() fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.storeApp)] fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true), NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false), @@ -283,6 +280,9 @@ private extension MyAppsViewController cell.bannerView.iconImageView.isIndicatingActivity = true cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false) + cell.bannerView.buttonLabel.isHidden = false + cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") + cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered) @@ -344,6 +344,67 @@ private extension MyAppsViewController return dataSource } + func makeInactiveAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource + { + let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.storeApp)] + fetchRequest.predicate = NSPredicate(format: "%K == NO", #keyPath(InstalledApp.isActive)) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true), + NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false), + NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)] + fetchRequest.returnsObjectsAsFaults = false + + let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) + dataSource.cellIdentifierHandler = { _ in "AppCell" } + dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in + let tintColor = installedApp.storeApp?.tintColor ?? .altPrimary + + let cell = cell as! InstalledAppCollectionViewCell + cell.layoutMargins.left = self.view.layoutMargins.left + cell.layoutMargins.right = self.view.layoutMargins.right + cell.tintColor = UIColor.gray + + cell.bannerView.iconImageView.isIndicatingActivity = true + cell.bannerView.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false) + + cell.bannerView.buttonLabel.isHidden = true + + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.button.tintColor = tintColor + cell.bannerView.button.setTitle(NSLocalizedString("ACTIVATE", comment: ""), for: .normal) + cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered) + + cell.bannerView.titleLabel.text = installedApp.name + cell.bannerView.subtitleLabel.text = installedApp.storeApp?.developerName ?? NSLocalizedString("Sideloaded", comment: "") + + // Make sure refresh button is correct size. + cell.layoutIfNeeded() + + // Ensure no leftover progress from active apps cell reuse. + cell.bannerView.button.progress = nil + } + dataSource.prefetchHandler = { (item, indexPath, completion) in + let fileURL = item.fileURL + + return BlockOperation { + guard let application = ALTApplication(fileURL: fileURL) else { + completion(nil, OperationError.invalidApp) + return + } + + let icon = application.icon + completion(icon, nil) + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! InstalledAppCollectionViewCell + cell.bannerView.iconImageView.image = image + cell.bannerView.iconImageView.isIndicatingActivity = false + } + + return dataSource + } + func updateDataSource() { if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated @@ -433,10 +494,11 @@ private extension MyAppsViewController localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count)) } - let detailText = failures.first?.value.localizedDescription + let error = failures.first?.value as NSError? + let detailText = error?.localizedFailureReason ?? error?.localizedDescription toastView = ToastView(text: localizedText, detailText: detailText) - toastView.preferredDuration = 2.0 + toastView.preferredDuration = 4.0 } toastView.show(in: self) @@ -449,7 +511,7 @@ private extension MyAppsViewController self.refreshGroup = group UIView.performWithoutAnimation { - self.collectionView.reloadSections([Section.installedApps.rawValue]) + self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue]) } } } @@ -525,24 +587,7 @@ private extension MyAppsViewController guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } let installedApp = self.dataSource.item(at: indexPath) - - let previousProgress = AppManager.shared.refreshProgress(for: installedApp) - guard previousProgress == nil else { - previousProgress?.cancel() - return - } - - self.refresh([installedApp]) { (results) in - // If an error occured, reload the section so the progress bar is no longer visible. - if results.values.contains(where: { $0.error != nil }) - { - DispatchQueue.main.async { - self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue)) - } - } - - print("Finished refreshing with results:", results.map { ($0, $1.error?.localizedDescription ?? "success") }) - } + self.refresh(installedApp) } @IBAction func refreshAllApps(_ sender: UIBarButtonItem) @@ -555,7 +600,7 @@ private extension MyAppsViewController self.refresh(installedApps) { (result) in DispatchQueue.main.async { self.isRefreshingAllApps = false - self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue)) + self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue]) } } } @@ -705,7 +750,153 @@ private extension MyAppsViewController } } - @objc func presentAlert(for installedApp: InstalledApp) + @IBAction func activateApp(_ sender: UIButton) + { + let point = self.collectionView.convert(sender.center, from: sender.superview) + guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } + + let installedApp = self.dataSource.item(at: indexPath) + self.activate(installedApp) + } + + @IBAction func deactivateApp(_ sender: UIButton) + { + let point = self.collectionView.convert(sender.center, from: sender.superview) + guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } + + let installedApp = self.dataSource.item(at: indexPath) + self.deactivate(installedApp) + } + + @objc func presentInactiveAppsAlert() + { + let alertController = UIAlertController(title: NSLocalizedString("What are inactive apps?", comment: ""), message: NSLocalizedString("Free developer accounts are limited to 3 apps and app extensions. Inactive apps don't count towards your total, but cannot be opened until activated.", comment: ""), preferredStyle: .alert) + alertController.addAction(.ok) + self.present(alertController, animated: true, completion: nil) + } + + func presentDeactivateAppAlert(completionHandler: @escaping (Bool) -> Void) + { + let alertController = UIAlertController(title: NSLocalizedString("Cannot Activate More than 3 Apps", comment: ""), message: NSLocalizedString("Free developer accounts are limited to 3 active apps and app extensions. Please choose an app to deactivate.", comment: ""), preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in + completionHandler(false) + }) + + let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext) + for app in activeApps where app.bundleIdentifier != StoreApp.altstoreAppID + { + alertController.addAction(UIAlertAction(title: app.name, style: .default) { (action) in + self.deactivate(app) { (result) in + switch result + { + case .failure: completionHandler(false) + case .success: completionHandler(true) + } + } + }) + } + + self.present(alertController, animated: true, completion: nil) + } +} + +private extension MyAppsViewController +{ + func refresh(_ installedApp: InstalledApp) + { + let previousProgress = AppManager.shared.refreshProgress(for: installedApp) + guard previousProgress == nil else { + previousProgress?.cancel() + return + } + + self.refresh([installedApp]) { (results) in + // If an error occured, reload the section so the progress bar is no longer visible. + if results.values.contains(where: { $0.error != nil }) + { + DispatchQueue.main.async { + self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue]) + } + } + + print("Finished refreshing with results:", results.map { ($0, $1.error?.localizedDescription ?? "success") }) + } + } + + func activate(_ installedApp: InstalledApp) + { + if let sideloadedAppsLimit = UserDefaults.standard.activeAppsLimit + { + let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext) + let activeAppsCount = activeApps.reduce(0) { $0 + (1 + $1.appExtensions.count) } // As of iOS 13.3.1, app extensions count as "apps" + + let availableActiveApps = max(sideloadedAppsLimit - activeAppsCount, 0) + let requiredActiveAppSlots = 1 + installedApp.appExtensions.count + + guard requiredActiveAppSlots <= availableActiveApps else { + return self.presentDeactivateAppAlert { (shouldContinue) in + guard shouldContinue else { return } + + installedApp.managedObjectContext?.perform { + self.activate(installedApp) + } + } + } + } + + guard !installedApp.isActive else { return } + installedApp.isActive = true + + AppManager.shared.activate(installedApp, presentingViewController: self) { (result) in + do + { + let app = try result.get() + try? app.managedObjectContext?.save() + } + catch + { + print("Failed to activate app:", error) + + DispatchQueue.main.async { + installedApp.isActive = false + + let toastView = ToastView(error: error) + toastView.show(in: self) + } + } + } + } + + func deactivate(_ installedApp: InstalledApp, completionHandler: ((Result) -> Void)? = nil) + { + guard installedApp.isActive else { return } + installedApp.isActive = false + + AppManager.shared.deactivate(installedApp) { (result) in + do + { + let app = try result.get() + try? app.managedObjectContext?.save() + + print("Finished deactivating app:", app.bundleIdentifier) + } + catch + { + print("Failed to activate app:", error) + + DispatchQueue.main.async { + installedApp.isActive = true + + let toastView = ToastView(error: error) + toastView.show(in: self) + } + } + + completionHandler?(result) + } + } + + func remove(_ installedApp: InstalledApp) { let alertController = UIAlertController(title: nil, message: NSLocalizedString("Removing a sideloaded app only removes it from AltStore. You must also delete it from the home screen to fully uninstall the app.", comment: ""), preferredStyle: .actionSheet) alertController.addAction(.cancel) @@ -738,30 +929,6 @@ private extension MyAppsViewController } } - @objc func handleLongPressGesture(_ gestureRecognizer: UILongPressGestureRecognizer) - { - guard gestureRecognizer.state == .began else { return } - - let point = gestureRecognizer.location(in: self.collectionView) - - guard - let indexPath = self.collectionView.indexPathForItem(at: point), - indexPath.section == Section.installedApps.rawValue - else { return } - - let installedApp = self.dataSource.item(at: indexPath) - - #if DEBUG - self.presentAlert(for: installedApp) - #else - if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier) - { - // Only display alert for legacy sideloaded apps. - self.presentAlert(for: installedApp) - } - #endif - } - @objc func importApp(_ notification: Notification) { // Make sure left UIBarButtonItem has been set. @@ -842,24 +1009,54 @@ extension MyAppsViewController return headerView - case .installedApps where kind == UICollectionView.elementKindSectionHeader: - let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InstalledAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView + case .activeApps where kind == UICollectionView.elementKindSectionHeader: + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "ActiveAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView UIView.performWithoutAnimation { - headerView.textLabel.text = NSLocalizedString("Installed", comment: "") + headerView.layoutMargins.left = self.view.layoutMargins.left + headerView.layoutMargins.right = self.view.layoutMargins.right + + if UserDefaults.standard.activeAppsLimit == nil + { + headerView.textLabel.text = NSLocalizedString("Installed", comment: "") + } + else + { + headerView.textLabel.text = NSLocalizedString("Active", comment: "") + } headerView.button.isIndicatingActivity = false headerView.button.activityIndicatorView.color = .altPrimary headerView.button.setTitle(NSLocalizedString("Refresh All", comment: ""), for: .normal) headerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshAllApps(_:)), for: .primaryActionTriggered) - headerView.button.isIndicatingActivity = self.isRefreshingAllApps headerView.button.layoutIfNeeded() + headerView.button.isIndicatingActivity = self.isRefreshingAllApps } return headerView - case .installedApps: + case .inactiveApps where kind == UICollectionView.elementKindSectionHeader: + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InactiveAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView + + UIView.performWithoutAnimation { + headerView.layoutMargins.left = self.view.layoutMargins.left + headerView.layoutMargins.right = self.view.layoutMargins.right + + headerView.textLabel.text = NSLocalizedString("Inactive", comment: "") + headerView.button.setTitle(nil, for: .normal) + + if #available(iOS 13.0, *) + { + headerView.button.setImage(UIImage(systemName: "questionmark.circle"), for: .normal) + } + + headerView.button.addTarget(self, action: #selector(MyAppsViewController.presentInactiveAppsAlert), for: .primaryActionTriggered) + } + + return headerView + + case .activeApps, .inactiveApps: let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "InstalledAppsFooter", for: indexPath) as! InstalledAppsCollectionFooterView guard let team = DatabaseManager.shared.activeTeam() else { return footerView } @@ -904,6 +1101,102 @@ extension MyAppsViewController } } +@available(iOS 13.0, *) +extension MyAppsViewController +{ + private func actions(for installedApp: InstalledApp) -> [UIAction] + { + var actions = [UIAction]() + + let refreshAction = UIAction(title: NSLocalizedString("Refresh", comment: ""), image: UIImage(systemName: "arrow.clockwise")) { (action) in + self.refresh(installedApp) + } + + let activateAction = UIAction(title: NSLocalizedString("Activate", comment: ""), image: UIImage(systemName: "checkmark.circle")) { (action) in + self.activate(installedApp) + } + + let deactivateAction = UIAction(title: NSLocalizedString("Deactivate", comment: ""), image: UIImage(systemName: "xmark.circle"), attributes: .destructive) { (action) in + self.deactivate(installedApp) + } + + let removeAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: .destructive) { (action) in + self.remove(installedApp) + } + + if installedApp.bundleIdentifier == StoreApp.altstoreAppID + { + actions = [refreshAction] + } + else + { + if installedApp.isActive + { + if UserDefaults.standard.activeAppsLimit != nil + { + actions = [refreshAction, deactivateAction] + } + else + { + actions = [refreshAction] + } + } + else + { + actions.append(activateAction) + } + + #if DEBUG + actions.append(removeAction) + #else + if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier) + { + // Only display option for legacy sideloaded apps. + actions.append(removeAction) + } + #endif + } + + return actions + } + + override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? + { + let section = Section(rawValue: indexPath.section)! + switch section + { + case .updates, .noUpdates: return nil + case .activeApps, .inactiveApps: + let installedApp = self.dataSource.item(at: indexPath) + + return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in + let actions = self.actions(for: installedApp) + + let menu = UIMenu(title: "", children: actions) + return menu + } + } + } + + override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? + { + guard let indexPath = configuration.identifier as? NSIndexPath else { return nil } + guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? InstalledAppCollectionViewCell else { return nil } + + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.bounds, cornerRadius: cell.bannerView.layer.cornerRadius) + + let preview = UITargetedPreview(view: cell.bannerView, parameters: parameters) + return preview + } + + override func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? + { + return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration) + } +} + extension MyAppsViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize @@ -936,7 +1229,7 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout self.cachedUpdateSizes[item.bundleIdentifier] = size return size - case .installedApps: + case .activeApps, .inactiveApps: return CGSize(width: collectionView.bounds.width, height: 88) } } @@ -951,18 +1244,18 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout let height: CGFloat = self.updatesDataSource.itemCount > maximumCollapsedUpdatesCount ? 26 : 0 return CGSize(width: collectionView.bounds.width, height: height) - case .installedApps: return CGSize(width: collectionView.bounds.width, height: 29) + case .activeApps: return CGSize(width: collectionView.bounds.width, height: 29) + case .inactiveApps where self.inactiveAppsDataSource.itemCount == 0: return .zero + case .inactiveApps: return CGSize(width: collectionView.bounds.width, height: 29) } } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { let section = Section.allCases[section] - switch section + + func appIDsFooterSize() -> CGSize { - case .noUpdates: return .zero - case .updates: return .zero - case .installedApps: #if BETA guard let _ = DatabaseManager.shared.activeTeam() else { return .zero } @@ -977,6 +1270,18 @@ extension MyAppsViewController: UICollectionViewDelegateFlowLayout return .zero #endif } + + switch section + { + case .noUpdates: return .zero + case .updates: return .zero + + case .activeApps where self.inactiveAppsDataSource.itemCount == 0: return appIDsFooterSize() + case .activeApps: return .zero + + case .inactiveApps where self.inactiveAppsDataSource.itemCount == 0: return .zero + case .inactiveApps: return appIDsFooterSize() + } } func collectionView(_ myCV: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets diff --git a/AltStore/Operations/AuthenticationOperation.swift b/AltStore/Operations/AuthenticationOperation.swift index 1ba36aa8..691a7c9d 100644 --- a/AltStore/Operations/AuthenticationOperation.swift +++ b/AltStore/Operations/AuthenticationOperation.swift @@ -228,6 +228,17 @@ class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppl team.isActiveTeam = false } + let activeAppsMinimumVersion = OperatingSystemVersion(majorVersion: 13, minorVersion: 3, patchVersion: 1) + if team.type == .free, ProcessInfo.processInfo.isOperatingSystemAtLeast(activeAppsMinimumVersion) + { + // Free developer accounts are limited to only 3 active sideloaded apps at a time as of iOS 13.3.1. + UserDefaults.standard.activeAppsLimit = 3 + } + else + { + UserDefaults.standard.activeAppsLimit = nil + } + // Save try context.save() diff --git a/AltStore/Operations/DeactivateAppOperation.swift b/AltStore/Operations/DeactivateAppOperation.swift new file mode 100644 index 00000000..bfd81aa7 --- /dev/null +++ b/AltStore/Operations/DeactivateAppOperation.swift @@ -0,0 +1,90 @@ +// +// DeactivateAppOperation.swift +// AltStore +// +// Created by Riley Testut on 3/4/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import Foundation + +import AltSign +import AltKit + +import Roxas + +@objc(DeactivateAppOperation) +class DeactivateAppOperation: ResultOperation +{ + let app: InstalledApp + let context: OperationContext + + init(app: InstalledApp, context: OperationContext) + { + self.app = app + self.context = context + + super.init() + } + + override func main() + { + super.main() + + if let error = self.context.error + { + self.finish(.failure(error)) + return + } + + guard let server = self.context.server else { return self.finish(.failure(OperationError.invalidParameters)) } + guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { return self.finish(.failure(OperationError.unknownUDID)) } + + ServerManager.shared.connect(to: server) { (result) in + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success(let connection): + print("Sending deactivate app request...") + + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + let installedApp = context.object(with: self.app.objectID) as! InstalledApp + + let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier } + let allIdentifiers = [installedApp.resignedBundleIdentifier] + appExtensionProfiles + + let request = RemoveProvisioningProfilesRequest(udid: udid, bundleIdentifiers: Set(allIdentifiers)) + connection.send(request) { (result) in + print("Sent deactive app request!") + + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success: + print("Waiting for deactivate app response...") + connection.receiveResponse() { (result) in + print("Receiving deactivate app response:", result) + + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success(.error(let response)): self.finish(.failure(response.error)) + case .success(.removeProvisioningProfiles): + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + self.progress.completedUnitCount += 1 + + let installedApp = context.object(with: self.app.objectID) as! InstalledApp + installedApp.isActive = false + self.finish(.success(installedApp)) + } + + case .success: self.finish(.failure(ALTServerError(.unknownResponse))) + } + } + } + } + } + } + } + } +} diff --git a/AltStore/Operations/InstallAppOperation.swift b/AltStore/Operations/InstallAppOperation.swift index 394239bf..0983f93c 100644 --- a/AltStore/Operations/InstallAppOperation.swift +++ b/AltStore/Operations/InstallAppOperation.swift @@ -111,7 +111,40 @@ class InstallAppOperation: ResultOperation self.context.beginInstallationHandler?(installedApp) - let request = BeginInstallationRequest() + var activeProfiles: Set? + if let sideloadedAppsLimit = UserDefaults.standard.activeAppsLimit + { + // When installing these new profiles, AltServer will remove all non-active profiles to ensure we remain under limit. + + let fetchRequest = InstalledApp.activeAppsFetchRequest() + fetchRequest.includesPendingChanges = false + + var activeApps = InstalledApp.fetch(fetchRequest, in: backgroundContext) + if !activeApps.contains(installedApp) + { + let availableActiveApps = max(sideloadedAppsLimit - activeApps.count, 0) + let requiredActiveAppSlots = 1 + installedExtensions.count // As of iOS 13.3.1, app extensions count as "apps" + + if requiredActiveAppSlots <= availableActiveApps + { + // This app has not been explicitly activated, but there are enough slots available, + // so implicitly activate it. + installedApp.isActive = true + activeApps.append(installedApp) + } + else + { + installedApp.isActive = false + } + } + + activeProfiles = Set(activeApps.flatMap { (installedApp) -> [String] in + let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier } + return [installedApp.resignedBundleIdentifier] + appExtensionProfiles + }) + } + + let request = BeginInstallationRequest(activeProfiles: activeProfiles) connection.send(request) { (result) in switch result { diff --git a/AltStore/Operations/RefreshAppOperation.swift b/AltStore/Operations/RefreshAppOperation.swift index a8ba86fc..725b7db5 100644 --- a/AltStore/Operations/RefreshAppOperation.swift +++ b/AltStore/Operations/RefreshAppOperation.swift @@ -58,10 +58,10 @@ class RefreshAppOperation: ResultOperation print("Sending refresh app request...") var activeProfiles: Set? - - if team.type == .free + if UserDefaults.standard.activeAppsLimit != nil { - let activeApps = InstalledApp.all(in: context) + // When installing these new profiles, AltServer will remove all non-active profiles to ensure we remain under limit. + let activeApps = InstalledApp.fetchActiveApps(in: context) activeProfiles = Set(activeApps.flatMap { (installedApp) -> [String] in let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier } return [installedApp.resignedBundleIdentifier] + appExtensionProfiles diff --git a/AltStore/Protocols/Fetchable.swift b/AltStore/Protocols/Fetchable.swift index b1bba1d5..a202474f 100644 --- a/AltStore/Protocols/Fetchable.swift +++ b/AltStore/Protocols/Fetchable.swift @@ -26,6 +26,20 @@ extension Fetchable return managedObjects } + static func fetch(_ fetchRequest: NSFetchRequest, in context: NSManagedObjectContext) -> [Self] + { + do + { + let managedObjects = try context.fetch(fetchRequest) + return managedObjects + } + catch + { + print("Failed to fetch managed objects. Fetch Request: \(fetchRequest). Error: \(error).") + return [] + } + } + private static func all(satisfying predicate: NSPredicate? = nil, sortedBy sortDescriptors: [NSSortDescriptor]? = nil, in context: NSManagedObjectContext, returnFirstResult: Bool) -> [Self] { let registeredObjects = context.registeredObjects.lazy.compactMap({ $0 as? Self }).filter({ predicate?.evaluate(with: $0) != false }) @@ -39,23 +53,15 @@ extension Fetchable fetchRequest.predicate = predicate fetchRequest.sortDescriptors = sortDescriptors - do + let fetchedObjects = self.fetch(fetchRequest, in: context) + + if let fetchedObject = fetchedObjects.first, returnFirstResult { - let managedObjects = try context.fetch(fetchRequest) - - if let managedObject = managedObjects.first, returnFirstResult - { - return [managedObject] - } - else - { - return managedObjects - } + return [fetchedObject] } - catch + else { - print("Failed to fetch managed objects.", error) - return [] + return fetchedObjects } } }