diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 4daf0e30..49d88e79 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -358,6 +358,7 @@ D58D5F2E26DFE68E00E55E38 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = D58D5F2D26DFE68E00E55E38 /* LaunchAtLogin */; }; D593F1942717749A006E82DE /* PatchAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D593F1932717749A006E82DE /* PatchAppOperation.swift */; }; D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; }; + D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; }; D5E1E7C128077DE90016FC96 /* FetchTrustedSourcesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */; }; D5F2F6A92720B7C20081CCF5 /* PatchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */; }; /* End PBXBuildFile section */ @@ -819,6 +820,7 @@ D57F2C9326E01BC700B9FA39 /* UIDevice+Vibration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Vibration.swift"; sourceTree = ""; }; D593F1932717749A006E82DE /* PatchAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchAppOperation.swift; sourceTree = ""; }; D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = ""; }; + D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = ""; }; D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTrustedSourcesOperation.swift; sourceTree = ""; }; D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchViewController.swift; sourceTree = ""; }; EA79A60285C6AF5848AA16E9 /* Pods-AltStore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AltStore.debug.xcconfig"; path = "Target Support Files/Pods-AltStore/Pods-AltStore.debug.xcconfig"; sourceTree = ""; }; @@ -1677,6 +1679,7 @@ BFDBBD7F246CB84F004ED2F3 /* RemoveAppBackupOperation.swift */, BFF00D312501BDA100746320 /* BackgroundRefreshAppsOperation.swift */, D57F2C9026E0070200B9FA39 /* EnableJITOperation.swift */, + D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */, D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */, BF7B44062725A4B8005288A4 /* Patch App */, ); @@ -2612,6 +2615,7 @@ BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */, BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, + D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, BFF435D8255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift in Sources */, BF4B78FE24B3D1DB008AB4AC /* SceneDelegate.swift in Sources */, diff --git a/AltStore/LaunchViewController.swift b/AltStore/LaunchViewController.swift index abe57c9e..ff3330c9 100644 --- a/AltStore/LaunchViewController.swift +++ b/AltStore/LaunchViewController.swift @@ -69,6 +69,7 @@ extension LaunchViewController guard !self.didFinishLaunching else { return } AppManager.shared.update() + AppManager.shared.updatePatronsIfNeeded() PatreonAPI.shared.refreshPatreonAccount() // Add view controller as child (rather than presenting modally) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 2c47bfaa..f9ddba2d 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -20,7 +20,8 @@ import Roxas extension AppManager { - static let didFetchSourceNotification = Notification.Name("com.altstore.AppManager.didFetchSource") + static let didFetchSourceNotification = Notification.Name("io.altstore.AppManager.didFetchSource") + static let didUpdatePatronsNotification = Notification.Name("io.altstore.AppManager.didUpdatePatrons") static let expirationWarningNotificationID = "altstore-expiration-warning" static let enableJITResultNotificationID = "altstore-enable-jit" @@ -48,6 +49,8 @@ class AppManager @available(iOS 13, *) private(set) lazy var publisher: AppManagerPublisher = AppManagerPublisher() + private(set) var updatePatronsResult: Result? + private let operationQueue = OperationQueue() private let serialOperationQueue = OperationQueue() @@ -412,6 +415,34 @@ extension AppManager return fetchTrustedSourcesOperation } + func updatePatronsIfNeeded() + { + guard self.operationQueue.operations.allSatisfy({ !($0 is UpdatePatronsOperation) }) else { + // There's already an UpdatePatronsOperation running. + return + } + + self.updatePatronsResult = nil + + let updatePatronsOperation = UpdatePatronsOperation() + updatePatronsOperation.resultHandler = { (result) in + do + { + try result.get() + self.updatePatronsResult = .success(()) + } + catch + { + print("Error updating Friend Zone Patrons:", error) + self.updatePatronsResult = .failure(error) + } + + NotificationCenter.default.post(name: AppManager.didUpdatePatronsNotification, object: self) + } + + self.run([updatePatronsOperation], context: nil) + } + @discardableResult func install(_ app: T, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result) -> Void) -> RefreshGroup { diff --git a/AltStore/Operations/UpdatePatronsOperation.swift b/AltStore/Operations/UpdatePatronsOperation.swift new file mode 100644 index 00000000..eedccce1 --- /dev/null +++ b/AltStore/Operations/UpdatePatronsOperation.swift @@ -0,0 +1,107 @@ +// +// UpdatePatronsOperation.swift +// AltStore +// +// Created by Riley Testut on 4/11/22. +// Copyright © 2022 Riley Testut. All rights reserved. +// + +import Foundation +import CoreData + +import AltStoreCore + +private extension URL +{ + #if STAGING + static let patreonInfo = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/altstore/patreon.json")! + #else + static let patreonInfo = URL(string: "https://cdn.altstore.io/file/altstore/altstore/patreon.json")! + #endif +} + +extension UpdatePatronsOperation +{ + private struct Response: Decodable + { + var version: Int + var accessToken: String + var refreshID: String + } +} + +class UpdatePatronsOperation: ResultOperation +{ + let context: NSManagedObjectContext + + init(context: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) + { + self.context = context + } + + override func main() + { + super.main() + + let dataTask = URLSession.shared.dataTask(with: .patreonInfo) { (data, response, error) in + do + { + guard let data = data else { throw error! } + + let response = try AltStoreCore.JSONDecoder().decode(Response.self, from: data) + + let previousRefreshID = UserDefaults.shared.patronsRefreshID + guard response.refreshID != previousRefreshID else { + self.finish(.success(())) + return + } + + PatreonAPI.shared.fetchPatrons { (result) in + self.context.perform { + do + { + let patrons = try result.get() + let managedPatrons = patrons.map { (patron) -> PatreonAccount in + let account = PatreonAccount(patron: patron, context: self.context) + account.isFriendZonePatron = true + return account + } + + var patronIDs = Set(managedPatrons.map { $0.identifier }) + if let userAccountID = Keychain.shared.patreonAccountID + { + // Insert userAccountID into patronIDs to prevent it from being deleted. + patronIDs.insert(userAccountID) + } + + let removedPredicate = NSPredicate(format: "NOT (%K IN %@)", #keyPath(PatreonAccount.identifier), patronIDs) + let removedPatrons = PatreonAccount.all(satisfying: removedPredicate, in: self.context) + for patreonAccount in removedPatrons + { + self.context.delete(patreonAccount) + } + + try self.context.save() + + UserDefaults.shared.patronsRefreshID = response.refreshID + + self.finish(.success(())) + + print("Updated Friend Zone Patrons!") + } + catch + { + self.finish(.failure(error)) + } + } + } + } + catch + { + self.finish(.failure(error)) + } + } + + dataTask.resume() + } +} diff --git a/AltStore/Settings/PatreonViewController.swift b/AltStore/Settings/PatreonViewController.swift index b73702c9..8dc4e37b 100644 --- a/AltStore/Settings/PatreonViewController.swift +++ b/AltStore/Settings/PatreonViewController.swift @@ -29,8 +29,6 @@ class PatreonViewController: UICollectionViewController private var prototypeAboutHeader: AboutPatreonHeaderView! - private var patronsResult: Result<[Patron], Error>? - override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } @@ -48,6 +46,8 @@ class PatreonViewController: UICollectionViewController self.collectionView.register(PatronsHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "PatronsHeader") self.collectionView.register(PatronsFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "PatronsFooter") + NotificationCenter.default.addObserver(self, selector: #selector(PatreonViewController.didUpdatePatrons(_:)), name: AppManager.didUpdatePatronsNotification, object: nil) + self.update() } @@ -75,20 +75,24 @@ class PatreonViewController: UICollectionViewController private extension PatreonViewController { - func makeDataSource() -> RSTCompositeCollectionViewDataSource + func makeDataSource() -> RSTCompositeCollectionViewDataSource { - let aboutDataSource = RSTDynamicCollectionViewDataSource() + let aboutDataSource = RSTDynamicCollectionViewDataSource() aboutDataSource.numberOfSectionsHandler = { 1 } aboutDataSource.numberOfItemsHandler = { _ in 0 } - let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [aboutDataSource, self.patronsDataSource]) + let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [aboutDataSource, self.patronsDataSource]) dataSource.proxy = self return dataSource } - func makePatronsDataSource() -> RSTArrayCollectionViewDataSource + func makePatronsDataSource() -> RSTFetchedResultsCollectionViewDataSource { - let patronsDataSource = RSTArrayCollectionViewDataSource(items: []) + let fetchRequest: NSFetchRequest = PatreonAccount.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == YES", #keyPath(PatreonAccount.isFriendZonePatron)) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(PatreonAccount.name), ascending: true, selector: #selector(NSString.caseInsensitiveCompare(_:)))] + + let patronsDataSource = RSTFetchedResultsCollectionViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) patronsDataSource.cellConfigurationHandler = { (cell, patron, indexPath) in let cell = cell as! PatronCollectionViewCell cell.textLabel.text = patron.name @@ -173,31 +177,8 @@ private extension PatreonViewController { @objc func fetchPatrons() { - if let result = self.patronsResult, case .failure = result - { - self.patronsResult = nil - self.collectionView.reloadData() - } - - PatreonAPI.shared.fetchPatrons { (result) in - self.patronsResult = result - - do - { - let patrons = try result.get() - let sortedPatrons = patrons.sorted { $0.name < $1.name } - - self.patronsDataSource.items = sortedPatrons - } - catch - { - print("Failed to fetch patrons:", error) - - DispatchQueue.main.async { - self.collectionView.reloadData() - } - } - } + AppManager.shared.updatePatronsIfNeeded() + self.update() } @objc func openPatreonURL(_ sender: UIButton) @@ -263,6 +244,13 @@ private extension PatreonViewController alertController.addAction(.cancel) self.present(alertController, animated: true, completion: nil) } + + @objc func didUpdatePatrons(_ notification: Notification) + { + DispatchQueue.main.async { + self.collectionView.reloadData() + } + } } extension PatreonViewController @@ -291,11 +279,18 @@ extension PatreonViewController footerView.button.isHidden = false footerView.button.addTarget(self, action: #selector(PatreonViewController.fetchPatrons), for: .primaryActionTriggered) - switch self.patronsResult + if self.patronsDataSource.itemCount > 0 { - case .none: footerView.button.isIndicatingActivity = true - case .success?: footerView.button.isHidden = true - case .failure?: footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal) + footerView.button.isHidden = true + } + else + { + switch AppManager.shared.updatePatronsResult + { + case .none: footerView.button.isIndicatingActivity = true + case .success?: footerView.button.isHidden = true + case .failure?: footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal) + } } return footerView diff --git a/AltStoreCore/Extensions/UserDefaults+AltStore.swift b/AltStoreCore/Extensions/UserDefaults+AltStore.swift index 7241ca88..2b0ac0f4 100644 --- a/AltStoreCore/Extensions/UserDefaults+AltStore.swift +++ b/AltStoreCore/Extensions/UserDefaults+AltStore.swift @@ -37,6 +37,8 @@ public extension UserDefaults @NSManaged var patchedApps: [String]? + @NSManaged var patronsRefreshID: String? + @NSManaged var trustedSourceIDs: [String]? var activeAppsLimit: Int? { diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 9.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 9.xcdatamodel/contents index 82e82502..aa47e2ac 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 9.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 9.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -87,6 +87,7 @@ + @@ -168,7 +169,7 @@ - + diff --git a/AltStoreCore/Model/PatreonAccount.swift b/AltStoreCore/Model/PatreonAccount.swift index 95259c1e..63980931 100644 --- a/AltStoreCore/Model/PatreonAccount.swift +++ b/AltStoreCore/Model/PatreonAccount.swift @@ -38,6 +38,7 @@ public class PatreonAccount: NSManagedObject, Fetchable @NSManaged public var firstName: String? @NSManaged public var isPatron: Bool + @NSManaged public var isFriendZonePatron: NSNumber? private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { @@ -62,6 +63,16 @@ public class PatreonAccount: NSManagedObject, Fetchable self.isPatron = false } } + + public init(patron: Patron, context: NSManagedObjectContext) + { + super.init(entity: PatreonAccount.entity(), insertInto: context) + + self.identifier = patron.identifier + self.name = patron.name + self.firstName = nil + self.isPatron = (patron.status == .active) + } } public extension PatreonAccount