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 @@
+
+
+
+