diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 272f5d5c..b7041ee1 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -704,12 +704,19 @@ World + + + @@ -737,6 +744,8 @@ World + + diff --git a/AltStore/My Apps/MyAppsComponents.swift b/AltStore/My Apps/MyAppsComponents.swift index 8801b574..e4f9682f 100644 --- a/AltStore/My Apps/MyAppsComponents.swift +++ b/AltStore/My Apps/MyAppsComponents.swift @@ -64,12 +64,30 @@ final class InstalledAppsCollectionFooterView: UICollectionReusableView final class NoUpdatesCollectionViewCell: UICollectionViewCell { @IBOutlet var blurView: UIVisualEffectView! + @IBOutlet var textLabel: UILabel! + @IBOutlet var button: UIButton! override func awakeFromNib() { super.awakeFromNib() self.contentView.preservesSuperviewLayoutMargins = true + + let image: UIImage? + if #available(iOS 13, *) + { + let font = self.textLabel.font ?? UIFont.systemFont(ofSize: 17) + let configuration = UIImage.SymbolConfiguration(font: font) + + image = UIImage(systemName: "ellipsis.circle", withConfiguration: configuration) + } + else + { + image = UIImage(named: "ellipsis.circle") + } + + self.button.setTitle("", for: .normal) + self.button.setImage(image, for: .normal) } } diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 31efec73..f76494ac 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -42,6 +42,7 @@ final class MyAppsViewController: UICollectionViewController private lazy var updatesDataSource = self.makeUpdatesDataSource() private lazy var activeAppsDataSource = self.makeActiveAppsDataSource() private lazy var inactiveAppsDataSource = self.makeInactiveAppsDataSource() + private lazy var hiddenUpdatesFetchedResultsController = self.makeHiddenUpdatesFetchedResultsController() private var prototypeUpdateCell: UpdateCollectionViewCell! private var sideloadingProgressView: UIProgressView! @@ -80,6 +81,7 @@ final class MyAppsViewController: UICollectionViewController // Allows us to intercept delegate callbacks. self.updatesDataSource.fetchedResultsController.delegate = self + self.hiddenUpdatesFetchedResultsController.delegate = self self.collectionView.dataSource = self.dataSource self.collectionView.prefetchDataSource = self.dataSource @@ -187,6 +189,19 @@ private extension MyAppsViewController cell.blurView.layer.cornerRadius = 20 cell.blurView.layer.masksToBounds = true cell.blurView.backgroundColor = .altPrimary + + cell.button.addTarget(self, action: #selector(MyAppsViewController.showHiddenUpdatesAlert(_:)), for: .primaryActionTriggered) + + if let fetchedObjects = self.hiddenUpdatesFetchedResultsController.fetchedObjects, !fetchedObjects.isEmpty + { + cell.textLabel.text = NSLocalizedString("Unsupported Updates Available", comment: "") + cell.button.isHidden = false + } + else + { + cell.textLabel.text = NSLocalizedString("No Updates Available", comment: "") + cell.button.isHidden = true + } } return dynamicDataSource @@ -472,9 +487,37 @@ private extension MyAppsViewController return dataSource } + func makeHiddenUpdatesFetchedResultsController() -> NSFetchedResultsController + { + let fetchRequest = InstalledApp.updatesFetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.bundleIdentifier, ascending: true), + NSSortDescriptor(keyPath: \InstalledApp.storeApp?.sourceIdentifier, ascending: true)] // Sorting doesn't matter as long as it's stable. + + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil) + return fetchedResultsController + } + func updateDataSource() { + do + { + if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil + { + try self.updatesDataSource.fetchedResultsController.performFetch() + } + + if self.hiddenUpdatesFetchedResultsController.fetchedObjects == nil + { + try self.hiddenUpdatesFetchedResultsController.performFetch() + } + } + catch + { + print("[ALTLog] Failed to fetch updates:", error) + } + if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated + { self.dataSource.predicate = nil @@ -487,6 +530,7 @@ private extension MyAppsViewController { do { + try self.hiddenUpdatesFetchedResultsController.performFetch() try self.updatesDataSource.fetchedResultsController.performFetch() } catch @@ -935,6 +979,93 @@ private extension MyAppsViewController cell.bannerView.iconImageView.isIndicatingActivity = false } + + func removeAppExtensions(from application: ALTApplication, completion: @escaping (Result) -> Void) + { + guard !application.appExtensions.isEmpty else { return completion(.success(())) } + + let firstSentence: String + + if UserDefaults.standard.activeAppLimitIncludesExtensions + { + firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions.", comment: "") + } + else + { + firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to creating 10 App IDs per week.", comment: "") + } + + let message = firstSentence + " " + NSLocalizedString("Would you like to remove this app's extensions so they don't count towards your limit?", comment: "") + + let alertController = UIAlertController(title: NSLocalizedString("App Contains Extensions", comment: ""), message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in + completion(.failure(OperationError.cancelled)) + })) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions", comment: ""), style: .default) { (action) in + completion(.success(())) + }) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { (action) in + do + { + for appExtension in application.appExtensions + { + try FileManager.default.removeItem(at: appExtension.fileURL) + } + + completion(.success(())) + } + catch + { + completion(.failure(error)) + } + }) + + self.present(alertController, animated: true, completion: nil) + } + + @objc func showHiddenUpdatesAlert(_ sender: UIButton) + { + guard let installedApps = self.hiddenUpdatesFetchedResultsController.fetchedObjects, !installedApps.isEmpty, self.updatesDataSource.itemCount == 0 else { return } + + let numberOfHiddenUpdates = installedApps.count + + let title = numberOfHiddenUpdates == 1 ? NSLocalizedString("Unsupported Update Available", comment: "") : String(format: NSLocalizedString("%@ Unsupported Updates Available", comment: ""), numberOfHiddenUpdates as NSNumber) + var message = String(format: NSLocalizedString("These updates don't support iOS %@. Please update your device to the latest iOS version to install them.", comment: ""), ProcessInfo.processInfo.operatingSystemVersion.stringValue) + message += "\n" + + for installedApp in installedApps + { + guard let storeApp = installedApp.storeApp else { continue } + + var title = storeApp.name + if let appVersion = storeApp.latestAvailableVersion + { + title += " " + appVersion.version + + var osVersion: String? = nil + if let minOSVersion = appVersion.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) + { + osVersion = String(format: NSLocalizedString("iOS %@ or later", comment: ""), minOSVersion.stringValue) + } + else if let maxOSVersion = appVersion.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion + { + osVersion = String(format: NSLocalizedString("iOS %@ or earlier", comment: ""), maxOSVersion.stringValue) + } + + if let osVersion + { + title += " (" + osVersion + ")" + } + } + + message += "\n" + title + } + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(.ok) + + self.present(alertController, animated: true) + } } private extension MyAppsViewController @@ -1286,12 +1417,6 @@ private extension MyAppsViewController @objc func didFetchSource(_ notification: Notification) { DispatchQueue.main.async { - if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil - { - do { try self.updatesDataSource.fetchedResultsController.performFetch() } - catch { print("Error fetching:", error) } - } - self.update() } } @@ -1966,38 +2091,54 @@ extension MyAppsViewController: NSFetchedResultsControllerDelegate // an accurate pre-update item count. self.collectionView.performBatchUpdates(nil, completion: nil) - self.updatesDataSource.controllerWillChangeContent(controller) + if controller == self.updatesDataSource.fetchedResultsController + { + self.updatesDataSource.controllerWillChangeContent(controller) + } } func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { + guard controller == self.updatesDataSource.fetchedResultsController else { return } + self.updatesDataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type) } func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { + guard controller == self.updatesDataSource.fetchedResultsController else { return } + self.updatesDataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath) } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - let previousUpdateCount = self.collectionView.numberOfItems(inSection: Section.updates.rawValue) - let updateCount = Int(self.updatesDataSource.itemCount) - - if previousUpdateCount == 0 && updateCount > 0 + if controller == self.hiddenUpdatesFetchedResultsController && self.updatesDataSource.itemCount == 0 { - // Remove "No Updates Available" cell. - let change = RSTCellContentChange(type: .delete, currentIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue), destinationIndexPath: nil) - self.collectionView.add(change) + // Reload noUpdates section whenever hiddenUpdatesFetchedResultsController changes (and there are no supported updates). + // This ensures the cell correctly switches between "No Updates Available" and "Unsupported Updates Available". + self.collectionView.reloadSections([Section.noUpdates.rawValue]) } - else if previousUpdateCount > 0 && updateCount == 0 + else if controller == self.updatesDataSource.fetchedResultsController { - // Insert "No Updates Available" cell. - let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue)) - self.collectionView.add(change) + let previousUpdateCount = self.collectionView.numberOfItems(inSection: Section.updates.rawValue) + let updateCount = Int(self.updatesDataSource.itemCount) + + if previousUpdateCount == 0 && updateCount > 0 + { + // Remove "No Updates Available" cell. + let change = RSTCellContentChange(type: .delete, currentIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue), destinationIndexPath: nil) + self.collectionView.add(change) + } + else if previousUpdateCount > 0 && updateCount == 0 + { + // Insert "No Updates Available" cell. + let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue)) + self.collectionView.add(change) + } + + self.updatesDataSource.controllerDidChangeContent(controller) } - - self.updatesDataSource.controllerDidChangeContent(controller) } } diff --git a/AltStore/Resources/Assets.xcassets/ellipsis.circle.symbolset/Contents.json b/AltStore/Resources/Assets.xcassets/ellipsis.circle.symbolset/Contents.json new file mode 100644 index 00000000..314b583c --- /dev/null +++ b/AltStore/Resources/Assets.xcassets/ellipsis.circle.symbolset/Contents.json @@ -0,0 +1,15 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "symbol-rendering-intent" : "template" + }, + "symbols" : [ + { + "filename" : "ellipsis.circle.svg", + "idiom" : "universal" + } + ] +} diff --git a/AltStore/Resources/Assets.xcassets/ellipsis.circle.symbolset/ellipsis.circle.svg b/AltStore/Resources/Assets.xcassets/ellipsis.circle.symbolset/ellipsis.circle.svg new file mode 100644 index 00000000..064041d1 --- /dev/null +++ b/AltStore/Resources/Assets.xcassets/ellipsis.circle.symbolset/ellipsis.circle.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from ellipsis.circle + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +