diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 7b798965..49005bf2 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -125,6 +125,8 @@ BF6C336224197D700034FD24 /* NSError+LocalizedFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C336124197D700034FD24 /* NSError+LocalizedFailure.swift */; }; BF6C33652419AE310034FD24 /* AltStore4ToAltStore5.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = BF6C33642419AE310034FD24 /* AltStore4ToAltStore5.xcmappingmodel */; }; BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAA242935ED00125131 /* NSAttributedString+Markdown.m */; }; + BF6C8FAE2429597900125131 /* BannerCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAD2429597900125131 /* BannerCollectionViewCell.swift */; }; + BF6C8FB02429599900125131 /* TextCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */; }; BF6F439223644C6E00A0B879 /* RefreshAltStoreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F439123644C6E00A0B879 /* RefreshAltStoreViewController.swift */; }; BF718BC923C919E300A89F2D /* CFNotificationName+AltStore.m in Sources */ = {isa = PBXBuildFile; fileRef = BF718BC823C919E300A89F2D /* CFNotificationName+AltStore.m */; }; BF718BD123C91BD300A89F2D /* ALTWiredConnection.m in Sources */ = {isa = PBXBuildFile; fileRef = BF718BD023C91BD300A89F2D /* ALTWiredConnection.m */; }; @@ -152,7 +154,7 @@ BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFA8172A23C5633D001B5953 /* FetchAnisetteDataOperation.swift */; }; BFA8172D23C5823E001B5953 /* InstalledExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFA8172C23C5823E001B5953 /* InstalledExtension.swift */; }; BFB11692229322E400BB457C /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB11691229322E400BB457C /* DatabaseManager.swift */; }; - BFB1169B2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB1169A2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift */; }; + BFB1169B2293274D00BB457C /* JSONDecoder+Properties.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB1169A2293274D00BB457C /* JSONDecoder+Properties.swift */; }; BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB364592325985F00CD0EB1 /* FindServerOperation.swift */; }; BFB4323F22DE852000B7F8BC /* UpdateCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFB4323E22DE852000B7F8BC /* UpdateCollectionViewCell.xib */; }; BFB49AAA23834CF900D542D9 /* ALTAnisetteData.m in Sources */ = {isa = PBXBuildFile; fileRef = BFB49AA823834CF900D542D9 /* ALTAnisetteData.m */; }; @@ -167,6 +169,7 @@ 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 */; }; + BFC84A4D2421A19100853474 /* SourcesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC84A4C2421A19100853474 /* SourcesViewController.swift */; }; 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 */; }; @@ -234,7 +237,6 @@ BFE6326622A857C200F30809 /* Team.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6326522A857C100F30809 /* Team.swift */; }; BFE6326822A858F300F30809 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6326722A858F300F30809 /* Account.swift */; }; BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */; }; - BFEE944123F22AA100CDA07D /* AppIDComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEE944023F22AA100CDA07D /* AppIDComponents.swift */; }; BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF0B68D23219520007A79E1 /* PatreonViewController.swift */; }; BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF0B68F23219C6D007A79E1 /* PatreonComponents.swift */; }; BFF0B6922321A305007A79E1 /* AboutPatreonHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFF0B6912321A305007A79E1 /* AboutPatreonHeaderView.xib */; }; @@ -444,6 +446,8 @@ BF6C33642419AE310034FD24 /* AltStore4ToAltStore5.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore4ToAltStore5.xcmappingmodel; sourceTree = ""; }; BF6C8FAA242935ED00125131 /* NSAttributedString+Markdown.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "NSAttributedString+Markdown.m"; path = "Dependencies/MarkdownAttributedString/NSAttributedString+Markdown.m"; sourceTree = SOURCE_ROOT; }; BF6C8FAB242935ED00125131 /* NSAttributedString+Markdown.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "NSAttributedString+Markdown.h"; path = "Dependencies/MarkdownAttributedString/NSAttributedString+Markdown.h"; sourceTree = SOURCE_ROOT; }; + BF6C8FAD2429597900125131 /* BannerCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerCollectionViewCell.swift; sourceTree = ""; }; + BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCollectionReusableView.swift; sourceTree = ""; }; BF6F439123644C6E00A0B879 /* RefreshAltStoreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAltStoreViewController.swift; sourceTree = ""; }; BF718BC723C919CC00A89F2D /* CFNotificationName+AltStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CFNotificationName+AltStore.h"; sourceTree = ""; }; BF718BC823C919E300A89F2D /* CFNotificationName+AltStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "CFNotificationName+AltStore.m"; sourceTree = ""; }; @@ -478,7 +482,7 @@ BFA8172A23C5633D001B5953 /* FetchAnisetteDataOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchAnisetteDataOperation.swift; sourceTree = ""; }; BFA8172C23C5823E001B5953 /* InstalledExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledExtension.swift; sourceTree = ""; }; BFB11691229322E400BB457C /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; - BFB1169A2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "JSONDecoder+ManagedObjectContext.swift"; sourceTree = ""; }; + BFB1169A2293274D00BB457C /* JSONDecoder+Properties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "JSONDecoder+Properties.swift"; sourceTree = ""; }; BFB1169C22932DB100BB457C /* apps.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = apps.json; sourceTree = ""; }; BFB364592325985F00CD0EB1 /* FindServerOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindServerOperation.swift; sourceTree = ""; }; BFB4323E22DE852000B7F8BC /* UpdateCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UpdateCollectionViewCell.xib; sourceTree = ""; }; @@ -496,6 +500,7 @@ 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 = ""; }; + BFC84A4C2421A19100853474 /* SourcesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesViewController.swift; 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 = ""; }; @@ -567,7 +572,6 @@ BFE6326522A857C100F30809 /* Team.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Team.swift; sourceTree = ""; }; BFE6326722A858F300F30809 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationOperation.swift; sourceTree = ""; }; - BFEE944023F22AA100CDA07D /* AppIDComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIDComponents.swift; sourceTree = ""; }; BFF0B68D23219520007A79E1 /* PatreonViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatreonViewController.swift; sourceTree = ""; }; BFF0B68F23219C6D007A79E1 /* PatreonComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatreonComponents.swift; sourceTree = ""; }; BFF0B6912321A305007A79E1 /* AboutPatreonHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AboutPatreonHeaderView.xib; sourceTree = ""; }; @@ -876,7 +880,6 @@ isa = PBXGroup; children = ( BF56D2AE23DF9E310006506D /* AppIDsViewController.swift */, - BFEE944023F22AA100CDA07D /* AppIDComponents.swift */, ); path = "App IDs"; sourceTree = ""; @@ -982,6 +985,14 @@ path = Server; sourceTree = ""; }; + BFC84A4B2421A13000853474 /* Sources */ = { + isa = PBXGroup; + children = ( + BFC84A4C2421A19100853474 /* SourcesViewController.swift */, + ); + path = Sources; + sourceTree = ""; + }; BFD247612284B9A500981D42 = { isa = PBXGroup; children = ( @@ -1025,6 +1036,7 @@ BFD5D6E6230CC94B007955AB /* Patreon */, BFD2478A2284C49000981D42 /* Managing Apps */, BF56D2AD23DF9E170006506D /* App IDs */, + BFC84A4B2421A13000853474 /* Sources */, BFC51D7922972F1F00388324 /* Server */, BFD247982284D7FC00981D42 /* Model */, BFDB6A0922AAEDA1007EA6D6 /* Operations */, @@ -1078,6 +1090,8 @@ BF3D649E22E7B24C00E9056B /* CollapsingTextView.swift */, BF2901302318F7A800D88A45 /* AppBannerView.swift */, BF29012E2318F6B100D88A45 /* AppBannerView.xib */, + BF6C8FAD2429597900125131 /* BannerCollectionViewCell.swift */, + BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */, ); path = Components; sourceTree = ""; @@ -1128,7 +1142,7 @@ isa = PBXGroup; children = ( BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */, - BFB1169A2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift */, + BFB1169A2293274D00BB457C /* JSONDecoder+Properties.swift */, BF43002F22A71C960051E2BC /* UserDefaults+AltStore.swift */, BF9ABA4E22DD41A9008935CF /* UIColor+Hex.swift */, BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */, @@ -1710,7 +1724,6 @@ BFD5D6F6230DDB12007955AB /* Tier.swift in Sources */, BFB11692229322E400BB457C /* DatabaseManager.swift in Sources */, BF56D2AC23DF8E170006506D /* FetchAppIDsOperation.swift in Sources */, - BFEE944123F22AA100CDA07D /* AppIDComponents.swift in Sources */, BF26A0E12370C5D400F53F9F /* ALTSourceUserInfoKey.m in Sources */, BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */, BF54E8212315EF0D000AE0D8 /* ALTPatreonBenefitType.m in Sources */, @@ -1747,6 +1760,7 @@ BF100C50232D7CD1006A8926 /* AltStoreToAltStore2.xcmappingmodel in Sources */, BF3D64B022E8D4B800E9056B /* AppContentViewControllerCells.swift in Sources */, BFC57A6E2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift in Sources */, + BF6C8FAE2429597900125131 /* BannerCollectionViewCell.swift in Sources */, BFBBE2DD22931B20002097FA /* AltStore.xcdatamodeld in Sources */, BF56D2AA23DF88310006506D /* AppID.swift in Sources */, BF02419422F2156E00129732 /* RefreshAttempt.swift in Sources */, @@ -1754,7 +1768,7 @@ BFE60742231B07E6002B0E8E /* SettingsHeaderFooterView.swift in Sources */, BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */, BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */, - BFB1169B2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift in Sources */, + BFB1169B2293274D00BB457C /* JSONDecoder+Properties.swift in Sources */, BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */, BF3D648822E79A3700E9056B /* AppPermission.swift in Sources */, BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */, @@ -1771,6 +1785,7 @@ BF0F5FC723F394AD0080DB64 /* AltStore3ToAltStore4.xcmappingmodel in Sources */, BF02419622F2199300129732 /* RefreshAttemptsViewController.swift in Sources */, BF08858322DE795100DE9F1E /* MyAppsViewController.swift in Sources */, + BFC84A4D2421A19100853474 /* SourcesViewController.swift in Sources */, BF9ABA4F22DD41A9008935CF /* UIColor+Hex.swift in Sources */, BFF0B696232242D3007A79E1 /* LicensesViewController.swift in Sources */, BFD52BD422A0800A000B7ED1 /* ServerManager.swift in Sources */, @@ -1788,6 +1803,7 @@ BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, + BF6C8FB02429599900125131 /* TextCollectionReusableView.swift in Sources */, BFB6B220231870B00022A802 /* NewsCollectionViewCell.swift in Sources */, BFDB6A0522A9AFB2007EA6D6 /* Fetchable.swift in Sources */, BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */, diff --git a/AltStore/App IDs/AppIDsViewController.swift b/AltStore/App IDs/AppIDsViewController.swift index 47433cf4..75d6fe16 100644 --- a/AltStore/App IDs/AppIDsViewController.swift +++ b/AltStore/App IDs/AppIDsViewController.swift @@ -71,7 +71,7 @@ private extension AppIDsViewController dataSource.cellConfigurationHandler = { (cell, appID, indexPath) in let tintColor = UIColor.altPrimary - let cell = cell as! AppIDCollectionViewCell + let cell = cell as! BannerCollectionViewCell cell.layoutMargins.left = self.view.layoutMargins.left cell.layoutMargins.right = self.view.layoutMargins.right cell.tintColor = tintColor @@ -80,6 +80,8 @@ private extension AppIDsViewController cell.bannerView.button.isIndicatingActivity = false cell.bannerView.betaBadgeView.isHidden = true + cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in…", comment: "") + if let expirationDate = appID.expirationDate { cell.bannerView.button.isHidden = false @@ -181,7 +183,7 @@ extension AppIDsViewController: UICollectionViewDelegateFlowLayout switch kind { case UICollectionView.elementKindSectionHeader: - let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! AppIDsCollectionReusableView + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView headerView.layoutMargins.left = self.view.layoutMargins.left headerView.layoutMargins.right = self.view.layoutMargins.right @@ -208,7 +210,7 @@ extension AppIDsViewController: UICollectionViewDelegateFlowLayout return headerView case UICollectionView.elementKindSectionFooter: - let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! AppIDsCollectionReusableView + let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView let count = self.dataSource.itemCount if count == 1 diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index 3184eefb..50d70570 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -250,20 +250,20 @@ private extension AppDelegate backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void, completionHandler: @escaping (Result<[String: Result], Error>) -> Void) { - var fetchSourceResult: Result? + var fetchSourcesResult: Result, Error>? var serversResult: Result? let dispatchGroup = DispatchGroup() dispatchGroup.enter() - AppManager.shared.fetchSource() { (result) in - fetchSourceResult = result + AppManager.shared.fetchSources() { (result) in + fetchSourcesResult = result do { - let source = try result.get() + let sources = try result.get() - guard let context = source.managedObjectContext else { return } + guard let context = sources.first?.managedObjectContext else { return } let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest previousUpdatesFetchRequest.includesPendingChanges = false @@ -331,7 +331,7 @@ private extension AppDelegate { print("Error fetching apps:", error) - fetchSourceResult = .failure(error) + fetchSourcesResult = .failure(error) } dispatchGroup.leave() @@ -424,12 +424,12 @@ private extension AppDelegate dispatchGroup.notify(queue: .main) { if !UserDefaults.standard.isBackgroundRefreshEnabled { - guard let fetchSourceResult = fetchSourceResult else { + guard let fetchSourcesResult = fetchSourcesResult else { backgroundFetchCompletionHandler(.failed) return } - switch fetchSourceResult + switch fetchSourcesResult { case .failure: backgroundFetchCompletionHandler(.failed) case .success: backgroundFetchCompletionHandler(.newData) @@ -439,13 +439,13 @@ private extension AppDelegate } else { - guard let fetchSourceResult = fetchSourceResult, let serversResult = serversResult else { + guard let fetchSourcesResult = fetchSourcesResult, let serversResult = serversResult else { backgroundFetchCompletionHandler(.failed) return } // Call completionHandler early to improve chances of refreshing in the background again. - switch (fetchSourceResult, serversResult) + switch (fetchSourcesResult, serversResult) { case (.success, .success): backgroundFetchCompletionHandler(.newData) case (.success, .failure(ConnectionError.serverNotFound)): backgroundFetchCompletionHandler(.newData) diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 4a09c8bb..79a6a15b 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -64,7 +64,13 @@ - + + + + + + + @@ -792,7 +798,7 @@ World - + @@ -818,7 +824,7 @@ World - + @@ -839,7 +845,7 @@ World - + @@ -923,6 +929,109 @@ World + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index b9637497..599e7450 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -50,6 +50,11 @@ class BrowseViewController: UICollectionViewController self.fetchSource() self.updateDataSource() } + + @IBAction private func unwindToBrowseViewController(_ segue: UIStoryboardSegue) + { + self.fetchSource() + } } private extension BrowseViewController @@ -57,17 +62,12 @@ private extension BrowseViewController func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true), NSSortDescriptor(keyPath: \StoreApp.name, ascending: true)] + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.name, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)] fetchRequest.returnsObjectsAsFaults = false - - if let source = Source.fetchAltStoreSource(in: DatabaseManager.shared.viewContext) - { - fetchRequest.predicate = NSPredicate(format: "%K != %@ AND %K == %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID, #keyPath(StoreApp.source), source) - } - else - { - fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID) - } + fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID) let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) dataSource.cellConfigurationHandler = { (cell, app, indexPath) in @@ -167,11 +167,11 @@ private extension BrowseViewController { self.loadingState = .loading - AppManager.shared.fetchSource() { (result) in + AppManager.shared.fetchSources() { (result) in do { - let source = try result.get() - try source.managedObjectContext?.save() + let sources = try result.get() + try sources.first?.managedObjectContext?.save() DispatchQueue.main.async { self.loadingState = .finished(.success(())) @@ -182,8 +182,8 @@ private extension BrowseViewController DispatchQueue.main.async { if self.dataSource.itemCount > 0 { - let toastView = ToastView(text: error.localizedDescription, detailText: nil) - toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0) + let toastView = ToastView(text: NSLocalizedString("Failed to Fetch Sources", comment: ""), detailText: error.localizedDescription) + toastView.show(in: self) } self.loadingState = .finished(.failure(error)) diff --git a/AltStore/App IDs/AppIDComponents.swift b/AltStore/Components/BannerCollectionViewCell.swift similarity index 50% rename from AltStore/App IDs/AppIDComponents.swift rename to AltStore/Components/BannerCollectionViewCell.swift index abbfca0d..74ef7251 100644 --- a/AltStore/App IDs/AppIDComponents.swift +++ b/AltStore/Components/BannerCollectionViewCell.swift @@ -1,14 +1,14 @@ // -// AppIDComponents.swift +// BannerCollectionViewCell.swift // AltStore // -// Created by Riley Testut on 2/10/20. +// Created by Riley Testut on 3/23/20. // Copyright © 2020 Riley Testut. All rights reserved. // import UIKit -class AppIDCollectionViewCell: UICollectionViewCell +class BannerCollectionViewCell: UICollectionViewCell { @IBOutlet var bannerView: AppBannerView! @@ -18,13 +18,5 @@ class AppIDCollectionViewCell: UICollectionViewCell self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] self.contentView.preservesSuperviewLayoutMargins = true - - self.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") - self.bannerView.buttonLabel.isHidden = false } } - -class AppIDsCollectionReusableView: UICollectionReusableView -{ - @IBOutlet var textLabel: UILabel! -} diff --git a/AltStore/Components/TextCollectionReusableView.swift b/AltStore/Components/TextCollectionReusableView.swift new file mode 100644 index 00000000..960dca48 --- /dev/null +++ b/AltStore/Components/TextCollectionReusableView.swift @@ -0,0 +1,14 @@ +// +// TextCollectionReusableView.swift +// AltStore +// +// Created by Riley Testut on 3/23/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import UIKit + +class TextCollectionReusableView: UICollectionReusableView +{ + @IBOutlet var textLabel: UILabel! +} diff --git a/AltStore/Extensions/JSONDecoder+ManagedObjectContext.swift b/AltStore/Extensions/JSONDecoder+ManagedObjectContext.swift deleted file mode 100644 index b79240d7..00000000 --- a/AltStore/Extensions/JSONDecoder+ManagedObjectContext.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// JSONDecoder+ManagedObjectContext.swift -// Harmony -// -// Created by Riley Testut on 10/3/18. -// Copyright © 2018 Riley Testut. All rights reserved. -// - -import Foundation -import CoreData - -private extension CodingUserInfoKey -{ - static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")! -} - -public extension JSONDecoder -{ - var managedObjectContext: NSManagedObjectContext? { - get { - let managedObjectContext = self.userInfo[.managedObjectContext] as? NSManagedObjectContext - return managedObjectContext - } - set { - self.userInfo[.managedObjectContext] = newValue - } - } -} - -public extension Decoder -{ - var managedObjectContext: NSManagedObjectContext? { - let managedObjectContext = self.userInfo[.managedObjectContext] as? NSManagedObjectContext - return managedObjectContext - } -} diff --git a/AltStore/Extensions/JSONDecoder+Properties.swift b/AltStore/Extensions/JSONDecoder+Properties.swift new file mode 100644 index 00000000..7ca04ab7 --- /dev/null +++ b/AltStore/Extensions/JSONDecoder+Properties.swift @@ -0,0 +1,64 @@ +// +// JSONDecoder+Properties.swift +// Harmony +// +// Created by Riley Testut on 10/3/18. +// Copyright © 2018 Riley Testut. All rights reserved. +// + +import Foundation +import CoreData + +extension CodingUserInfoKey +{ + static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")! + static let sourceURL = CodingUserInfoKey(rawValue: "sourceURL")! +} + +public final class JSONDecoder: Foundation.JSONDecoder +{ + @DecoderItem(key: .managedObjectContext) + var managedObjectContext: NSManagedObjectContext? + + @DecoderItem(key: .sourceURL) + var sourceURL: URL? +} + +extension Decoder +{ + var managedObjectContext: NSManagedObjectContext? { self.userInfo[.managedObjectContext] as? NSManagedObjectContext } + var sourceURL: URL? { self.userInfo[.sourceURL] as? URL } +} + +@propertyWrapper +struct DecoderItem +{ + let key: CodingUserInfoKey + + var wrappedValue: Value? { + get { fatalError("only works on instance properties of classes") } + set { fatalError("only works on instance properties of classes") } + } + + init(key: CodingUserInfoKey) + { + self.key = key + } + + public static subscript( + _enclosingInstance decoder: OuterSelf, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value? { + get { + let wrapper = decoder[keyPath: storageKeyPath] + + let value = decoder.userInfo[wrapper.key] as? Value + return value + } + set { + let wrapper = decoder[keyPath: storageKeyPath] + decoder.userInfo[wrapper.key] = newValue + } + } +} diff --git a/AltStore/Extensions/NSError+LocalizedFailure.swift b/AltStore/Extensions/NSError+LocalizedFailure.swift index dc47b323..7a3a17eb 100644 --- a/AltStore/Extensions/NSError+LocalizedFailure.swift +++ b/AltStore/Extensions/NSError+LocalizedFailure.swift @@ -15,4 +15,13 @@ extension NSError let localizedFailure = (self.userInfo[NSLocalizedFailureErrorKey] as? String) ?? (NSError.userInfoValueProvider(forDomain: self.domain)?(self, NSLocalizedFailureErrorKey) as? String) return localizedFailure } + + func withLocalizedFailure(_ failure: String) -> NSError + { + var userInfo = self.userInfo + userInfo[NSLocalizedFailureErrorKey] = failure + + let error = NSError(domain: self.domain, code: self.code, userInfo: userInfo) + return error + } } diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 8f2c3dda..8b215f96 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -170,26 +170,68 @@ extension AppManager extension AppManager { - func fetchSource(completionHandler: @escaping (Result) -> Void) + func fetchSource(sourceURL: URL, completionHandler: @escaping (Result) -> Void) + { + let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL) + fetchSourceOperation.resultHandler = { (result) in + switch result + { + case .failure(let error): + completionHandler(.failure(error)) + + case .success(let source): + completionHandler(.success(source)) + } + } + + self.run([fetchSourceOperation]) + } + + func fetchSources(completionHandler: @escaping (Result, Error>) -> Void) { DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in - guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context) else { - return completionHandler(.failure(OperationError.noSources)) + let sources = Source.all(in: context) + guard !sources.isEmpty else { return completionHandler(.failure(OperationError.noSources)) } + + let dispatchGroup = DispatchGroup() + var fetchedSources = Set() + var error: Error? + + let managedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext() + + let operations = sources.map { (source) -> FetchSourceOperation in + dispatchGroup.enter() + + let fetchSourceOperation = FetchSourceOperation(sourceURL: source.sourceURL, managedObjectContext: managedObjectContext) + fetchSourceOperation.resultHandler = { (result) in + switch result + { + case .failure(let e): error = e + case .success(let source): fetchedSources.insert(source) + } + + dispatchGroup.leave() + } + + return fetchSourceOperation } - let fetchSourceOperation = FetchSourceOperation(sourceURL: source.sourceURL) - fetchSourceOperation.resultHandler = { (result) in - switch result + dispatchGroup.notify(queue: .global()) { + if let error = error { - case .failure(let error): completionHandler(.failure(error)) - - case .success(let source): - completionHandler(.success(source)) - NotificationCenter.default.post(name: AppManager.didFetchSourceNotification, object: self) } + else + { + managedObjectContext.perform { + completionHandler(.success(fetchedSources)) + } + } + + NotificationCenter.default.post(name: AppManager.didFetchSourceNotification, object: self) } - self.run([fetchSourceOperation]) + + self.run(operations) } } diff --git a/AltStore/Model/AltStore.xcdatamodeld/AltStore 5.xcdatamodel/contents b/AltStore/Model/AltStore.xcdatamodeld/AltStore 5.xcdatamodel/contents index 9abea4fa..3a22e4d0 100644 --- a/AltStore/Model/AltStore.xcdatamodeld/AltStore 5.xcdatamodel/contents +++ b/AltStore/Model/AltStore.xcdatamodeld/AltStore 5.xcdatamodel/contents @@ -69,6 +69,7 @@ + @@ -76,6 +77,7 @@ + @@ -105,8 +107,8 @@ - - + + @@ -124,6 +126,7 @@ + @@ -135,6 +138,7 @@ + @@ -159,11 +163,11 @@ - + - - + + \ No newline at end of file diff --git a/AltStore/Model/MergePolicy.swift b/AltStore/Model/MergePolicy.swift index 0a5faa13..5401c117 100644 --- a/AltStore/Model/MergePolicy.swift +++ b/AltStore/Model/MergePolicy.swift @@ -15,8 +15,33 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy open override func resolve(constraintConflicts conflicts: [NSConstraintConflict]) throws { guard conflicts.allSatisfy({ $0.databaseObject != nil }) else { - assertionFailure("MergePolicy is only intended to work with database-level conflicts.") - return try super.resolve(constraintConflicts: conflicts) + for conflict in conflicts + { + switch conflict.conflictingObjects.first + { + case is StoreApp where conflict.conflictingObjects.count == 2: + // Modified cached StoreApp while replacing it with new one, causing context-level conflict. + // Most likely, we set up a relationship between the new StoreApp and a NewsItem, + // causing cached StoreApp to delete it's NewsItem relationship, resulting in (resolvable) conflict. + + if let previousApp = conflict.conflictingObjects.first(where: { !$0.isInserted }) as? StoreApp + { + // Delete previous permissions (same as below). + for permission in previousApp.permissions + { + permission.managedObjectContext?.delete(permission) + } + } + + default: + // Unknown context-level conflict. + assertionFailure("MergePolicy is only intended to work with database-level conflicts.") + } + } + + try super.resolve(constraintConflicts: conflicts) + + return } for conflict in conflicts diff --git a/AltStore/Model/NewsItem.swift b/AltStore/Model/NewsItem.swift index 84efd54a..04aa1ed0 100644 --- a/AltStore/Model/NewsItem.swift +++ b/AltStore/Model/NewsItem.swift @@ -26,6 +26,7 @@ class NewsItem: NSManagedObject, Decodable, Fetchable @NSManaged var externalURL: URL? @NSManaged var appID: String? + @NSManaged var sourceIdentifier: String? /* Relationships */ @NSManaged var storeApp: StoreApp? diff --git a/AltStore/Model/Source.swift b/AltStore/Model/Source.swift index 6250ee27..05cc87e9 100644 --- a/AltStore/Model/Source.swift +++ b/AltStore/Model/Source.swift @@ -15,7 +15,7 @@ extension Source #if STAGING static let altStoreSourceURL = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/apps-staging.json")! #else - static let altStoreSourceURL = URL(string: "https://cdn.altstore.io/file/altstore/apps.json")! + static let altStoreSourceURL = URL(string: "https://apps.altstore.io/")! #endif } @@ -70,55 +70,64 @@ class Source: NSManagedObject, Fetchable, Decodable required init(from decoder: Decoder) throws { guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") } + guard let sourceURL = decoder.sourceURL else { preconditionFailure("Decoder must have non-nil sourceURL.") } super.init(entity: Source.entity(), insertInto: nil) + self.sourceURL = sourceURL + let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) self.identifier = try container.decode(String.self, forKey: .identifier) - self.sourceURL = try container.decode(URL.self, forKey: .sourceURL) let userInfo = try container.decodeIfPresent([String: String].self, forKey: .userInfo) self.userInfo = userInfo?.reduce(into: [:]) { $0[ALTSourceUserInfoKey($1.key)] = $1.value } let apps = try container.decodeIfPresent([StoreApp].self, forKey: .apps) ?? [] + let appsByID = Dictionary(apps.map { ($0.bundleIdentifier, $0) }, uniquingKeysWith: { (a, b) in return a }) + for (index, app) in apps.enumerated() { + app.sourceIdentifier = self.identifier app.sortIndex = Int32(index) } let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? [] for (index, item) in newsItems.enumerated() { + item.sourceIdentifier = self.identifier item.sortIndex = Int32(index) } context.insert(self) - - let appsByID = Dictionary(apps.map { ($0.bundleIdentifier, $0) }, uniquingKeysWith: { (a, b) in return a }) - + for newsItem in newsItems - { - newsItem.source = self - + { guard let appID = newsItem.appID else { continue } if let storeApp = appsByID[appID] { newsItem.storeApp = storeApp } + else + { + newsItem.storeApp = nil + } } // Must assign after we're inserted into context. self._apps = NSMutableOrderedSet(array: apps) self._newsItems = NSMutableOrderedSet(array: newsItems) - - print("Downloaded Order:", self.apps.map { $0.bundleIdentifier }) } } extension Source { + @nonobjc class func fetchRequest() -> NSFetchRequest + { + return NSFetchRequest(entityName: "Source") + } + class func makeAltStoreSource(in context: NSManagedObjectContext) -> Source { let source = Source(context: context) diff --git a/AltStore/Model/StoreApp.swift b/AltStore/Model/StoreApp.swift index 875409cf..8efa8a01 100644 --- a/AltStore/Model/StoreApp.swift +++ b/AltStore/Model/StoreApp.swift @@ -46,12 +46,26 @@ class StoreApp: NSManagedObject, Decodable, Fetchable @NSManaged private(set) var tintColor: UIColor? @NSManaged private(set) var isBeta: Bool + @NSManaged var sourceIdentifier: String? + @NSManaged var sortIndex: Int32 /* Relationships */ @NSManaged var installedApp: InstalledApp? - @NSManaged var source: Source? - @objc(permissions) @NSManaged var _permissions: NSOrderedSet + @NSManaged var newsItems: Set + + @NSManaged @objc(source) var _source: Source? + @NSManaged @objc(permissions) var _permissions: NSOrderedSet + + @nonobjc var source: Source? { + set { + self._source = newValue + self.sourceIdentifier = newValue?.identifier + } + get { + return self._source + } + } @nonobjc var permissions: [AppPermission] { return self._permissions.array as! [AppPermission] diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index dfa73fcc..8ae42e85 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -99,9 +99,11 @@ private extension NewsViewController func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: false)] + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.date, ascending: false), + NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true), + NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)] - let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.sortIndex), cacheName: nil) + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.objectID), cacheName: nil) let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchedResultsController: fetchedResultsController) dataSource.proxy = self @@ -165,11 +167,11 @@ private extension NewsViewController { self.loadingState = .loading - AppManager.shared.fetchSource() { (result) in + AppManager.shared.fetchSources() { (result) in do { - let source = try result.get() - try source.managedObjectContext?.save() + let sources = try result.get() + try sources.first?.managedObjectContext?.save() DispatchQueue.main.async { self.loadingState = .finished(.success(())) @@ -180,7 +182,7 @@ private extension NewsViewController DispatchQueue.main.async { if self.dataSource.itemCount > 0 { - let toastView = ToastView(text: error.localizedDescription, detailText: nil) + let toastView = ToastView(text: NSLocalizedString("Failed to Fetch Sources", comment: ""), detailText: error.localizedDescription) toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0) } diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index 707ade58..8d823db0 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -7,12 +7,15 @@ // import Foundation +import CoreData + import Roxas @objc(FetchSourceOperation) class FetchSourceOperation: ResultOperation { let sourceURL: URL + let managedObjectContext: NSManagedObjectContext private let session: URLSession @@ -21,9 +24,10 @@ class FetchSourceOperation: ResultOperation return dateFormatter }() - init(sourceURL: URL) + init(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) { self.sourceURL = sourceURL + self.managedObjectContext = managedObjectContext let configuration = URLSessionConfiguration.default configuration.requestCachePolicy = .reloadIgnoringLocalCacheData @@ -37,7 +41,7 @@ class FetchSourceOperation: ResultOperation super.main() let dataTask = self.session.dataTask(with: self.sourceURL) { (data, response, error) in - DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + self.managedObjectContext.perform { do { let (data, _) = try Result((data, response), error).get() @@ -64,19 +68,16 @@ class FetchSourceOperation: ResultOperation throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.") }) - decoder.managedObjectContext = context + decoder.managedObjectContext = self.managedObjectContext + decoder.sourceURL = self.sourceURL let source = try decoder.decode(Source.self, from: data) - if let patreonAccessToken = source.userInfo?[.patreonAccessToken] + if source.identifier == Source.altStoreIdentifier, let patreonAccessToken = source.userInfo?[.patreonAccessToken] { Keychain.shared.patreonCreatorAccessToken = patreonAccessToken } - #if STAGING - source.sourceURL = self.sourceURL - #endif - self.finish(.success(source)) } catch diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift new file mode 100644 index 00000000..7ab840df --- /dev/null +++ b/AltStore/Sources/SourcesViewController.swift @@ -0,0 +1,179 @@ +// +// SourcesViewController.swift +// AltStore +// +// Created by Riley Testut on 3/17/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import UIKit +import CoreData + +import Roxas + +class SourcesViewController: UICollectionViewController +{ + private lazy var dataSource = self.makeDataSource() + + override func viewDidLoad() + { + super.viewDidLoad() + + self.collectionView.dataSource = self.dataSource + } +} + +private extension SourcesViewController +{ + func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource + { + let fetchRequest = Source.fetchRequest() as NSFetchRequest + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Source.name, ascending: true), + NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true), + NSSortDescriptor(keyPath: \Source.identifier, ascending: true)] + fetchRequest.returnsObjectsAsFaults = false + + let dataSource = RSTFetchedResultsCollectionViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) + dataSource.proxy = self + dataSource.cellConfigurationHandler = { (cell, source, indexPath) in + let tintColor = UIColor.altPrimary + + let cell = cell as! BannerCollectionViewCell + cell.layoutMargins.left = self.view.layoutMargins.left + cell.layoutMargins.right = self.view.layoutMargins.right + cell.tintColor = tintColor + + cell.bannerView.iconImageView.isHidden = true + cell.bannerView.betaBadgeView.isHidden = true + cell.bannerView.buttonLabel.isHidden = true + cell.bannerView.button.isHidden = true + cell.bannerView.button.isIndicatingActivity = false + + cell.bannerView.titleLabel.text = source.name + cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString + cell.bannerView.subtitleLabel.numberOfLines = 2 + + // Make sure refresh button is correct size. + cell.layoutIfNeeded() + } + + return dataSource + } +} + +private extension SourcesViewController +{ + @IBAction func addSource() + { + func addSource(url: URL) + { + AppManager.shared.fetchSource(sourceURL: url) { (result) in + do + { + let source = try result.get() + try source.managedObjectContext?.save() + } + catch let error as NSError + { + let error = error.withLocalizedFailure(NSLocalizedString("Could not add source.", comment: "")) + + DispatchQueue.main.async { + let toastView = ToastView(error: error) + toastView.show(in: self) + } + } + } + } + + let alertController = UIAlertController(title: NSLocalizedString("Add Source", comment: ""), message: nil, preferredStyle: .alert) + alertController.addTextField { (textField) in + textField.placeholder = "https://apps.altstore.io" + textField.textContentType = .URL + } + alertController.addAction(.cancel) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Add", comment: ""), style: .default) { (action) in + guard let text = alertController.textFields![0].text, let sourceURL = URL(string: text) else { return } + addSource(url: sourceURL) + }) + + self.present(alertController, animated: true, completion: nil) + } +} + +extension SourcesViewController: UICollectionViewDelegateFlowLayout +{ + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize + { + return CGSize(width: collectionView.bounds.width, height: 80) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize + { + let indexPath = IndexPath(row: 0, section: section) + let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath) + + // Use this view to calculate the optimal size based on the collection view's width + let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height), + withHorizontalFittingPriority: .required, // Width is fixed + verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed + return size + } + + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView + { + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView + headerView.layoutMargins.left = self.view.layoutMargins.left + headerView.layoutMargins.right = self.view.layoutMargins.right + return headerView + } +} + +@available(iOS 13, *) +extension SourcesViewController +{ + override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? + { + let source = self.dataSource.item(at: indexPath) + guard source.identifier != Source.altStoreIdentifier else { return nil } + + return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in + let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in + + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + let source = context.object(with: source.objectID) as! Source + context.delete(source) + + do + { + try context.save() + } + catch + { + print("Failed to save source context.", error) + } + } + } + + let menu = UIMenu(title: "", children: [deleteAction]) + 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? BannerCollectionViewCell 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) + } +}