From bc02cfc8a9dd5c1101c3592cc6246b44a67e2bdb Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 11 Mar 2020 14:43:19 -0700 Subject: [PATCH] Adds support for activating and deactivating apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 13.3.1 limits free developer accounts to 3 apps and app extensions. As a workaround, we now allow up to 3 “active” apps (apps with installed provisioning profiles), as well as additional “inactivate” apps which don’t have any profiles installed, causing them to not count towards the total. Inactive apps cannot be opened until they are activated. --- AltStore.xcodeproj/project.pbxproj | 12 + AltStore/Base.lproj/Main.storyboard | 53 +-- .../Extensions/UserDefaults+AltStore.swift | 17 + AltStore/Managing Apps/AppManager.swift | 84 +++- .../AltStore 4.xcdatamodel/contents | 5 +- AltStore/Model/DatabaseManager.swift | 13 + AltStore/Model/InstalledApp.swift | 23 +- .../InstalledAppsCollectionHeaderView.swift | 43 ++ .../InstalledAppsCollectionHeaderView.xib | 43 ++ AltStore/My Apps/MyAppsComponents.swift | 9 - AltStore/My Apps/MyAppsViewController.swift | 445 +++++++++++++++--- .../Operations/AuthenticationOperation.swift | 11 + .../Operations/DeactivateAppOperation.swift | 90 ++++ AltStore/Operations/InstallAppOperation.swift | 35 +- AltStore/Operations/RefreshAppOperation.swift | 6 +- AltStore/Protocols/Fetchable.swift | 34 +- 16 files changed, 771 insertions(+), 152 deletions(-) create mode 100644 AltStore/My Apps/InstalledAppsCollectionHeaderView.swift create mode 100644 AltStore/My Apps/InstalledAppsCollectionHeaderView.xib create mode 100644 AltStore/Operations/DeactivateAppOperation.swift 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 } } }