diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index be997b5d..3f7a9bae 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -314,8 +314,8 @@ - - + + @@ -944,15 +944,15 @@ World - + - - + + @@ -999,7 +999,46 @@ World + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Components/TextCollectionReusableView.swift b/AltStore/Components/TextCollectionReusableView.swift index 960dca48..fa2561f8 100644 --- a/AltStore/Components/TextCollectionReusableView.swift +++ b/AltStore/Components/TextCollectionReusableView.swift @@ -11,4 +11,9 @@ import UIKit class TextCollectionReusableView: UICollectionReusableView { @IBOutlet var textLabel: UILabel! + + @IBOutlet var topLayoutConstraint: NSLayoutConstraint! + @IBOutlet var bottomLayoutConstraint: NSLayoutConstraint! + @IBOutlet var leadingLayoutConstraint: NSLayoutConstraint! + @IBOutlet var trailingLayoutConstraint: NSLayoutConstraint! } diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 3939d58a..2c47bfaa 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -312,9 +312,12 @@ extension AppManager extension AppManager { - func fetchSource(sourceURL: URL, completionHandler: @escaping (Result) -> Void) + func fetchSource(sourceURL: URL, + managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(), + dependencies: [Foundation.Operation] = [], + completionHandler: @escaping (Result) -> Void) { - let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL) + let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL, managedObjectContext: managedObjectContext) fetchSourceOperation.resultHandler = { (result) in switch result { @@ -326,6 +329,11 @@ extension AppManager } } + for dependency in dependencies + { + fetchSourceOperation.addDependency(dependency) + } + self.run([fetchSourceOperation], context: nil) } diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index 57bd146d..38bcefd1 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -30,6 +30,22 @@ struct SourceError: LocalizedError } } +@objc(SourcesFooterView) +private class SourcesFooterView: TextCollectionReusableView +{ + @IBOutlet var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet var textView: UITextView! +} + +extension SourcesViewController +{ + private enum Section: Int, CaseIterable + { + case added + case trusted + } +} + class SourcesViewController: UICollectionViewController { var deepLinkSourceURL: URL? { @@ -40,6 +56,12 @@ class SourcesViewController: UICollectionViewController } private lazy var dataSource = self.makeDataSource() + private lazy var addedSourcesDataSource = self.makeAddedSourcesDataSource() + private lazy var trustedSourcesDataSource = self.makeTrustedSourcesDataSource() + + private var fetchTrustedSourcesOperation: FetchTrustedSourcesOperation? + private var fetchTrustedSourcesResult: Result? + private var _fetchTrustedSourcesContext: NSManagedObjectContext? override func viewDidLoad() { @@ -61,6 +83,11 @@ class SourcesViewController: UICollectionViewController { self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true } + + if self.fetchTrustedSourcesOperation == nil + { + self.fetchTrustedSources() + } } override func viewDidAppear(_ animated: Bool) @@ -70,22 +97,15 @@ class SourcesViewController: UICollectionViewController if let sourceURL = self.deepLinkSourceURL { self.addSource(url: sourceURL) - self.deepLinkSourceURL = nil } } } private extension SourcesViewController { - func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource + func makeDataSource() -> RSTCompositeCollectionViewDataSource { - 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) + let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.addedSourcesDataSource, self.trustedSourcesDataSource]) dataSource.proxy = self dataSource.cellConfigurationHandler = { (cell, source, indexPath) in let tintColor = UIColor.altPrimary @@ -97,8 +117,43 @@ private extension SourcesViewController cell.bannerView.iconImageView.isHidden = true cell.bannerView.buttonLabel.isHidden = true - cell.bannerView.button.isHidden = true cell.bannerView.button.isIndicatingActivity = false + + switch Section.allCases[indexPath.section] + { + case .added: + cell.bannerView.button.isHidden = true + + case .trusted: + // Quicker way to determine whether a source is already added than by reading from disk. + if (self.addedSourcesDataSource.fetchedResultsController.fetchedObjects ?? []).contains(where: { $0.identifier == source.identifier }) + { + // Source exists in .added section, so hide the button. + cell.bannerView.button.isHidden = true + + if #available(iOS 13.0, *) + { + let configuation = UIImage.SymbolConfiguration(pointSize: 24) + + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "checkmark.circle", withConfiguration: configuation)?.withTintColor(.altPrimary) + + let attributedText = NSAttributedString(attachment: imageAttachment) + cell.bannerView.buttonLabel.attributedText = attributedText + cell.bannerView.buttonLabel.textAlignment = .center + cell.bannerView.buttonLabel.isHidden = false + } + } + else + { + // Source does not exist in .added section, so show the button. + cell.bannerView.button.isHidden = false + cell.bannerView.buttonLabel.attributedText = nil + } + + cell.bannerView.button.setTitle(NSLocalizedString("ADD", comment: ""), for: .normal) + cell.bannerView.button.addTarget(self, action: #selector(SourcesViewController.addTrustedSource(_:)), for: .primaryActionTriggered) + } cell.bannerView.titleLabel.text = source.name cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString @@ -116,6 +171,24 @@ private extension SourcesViewController return dataSource } + + func makeAddedSourcesDataSource() -> RSTFetchedResultsCollectionViewDataSource + { + let fetchRequest = Source.fetchRequest() as NSFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Source.name, ascending: true), + NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true), + NSSortDescriptor(keyPath: \Source.identifier, ascending: true)] + + let dataSource = RSTFetchedResultsCollectionViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) + return dataSource + } + + func makeTrustedSourcesDataSource() -> RSTArrayCollectionViewDataSource + { + let dataSource = RSTArrayCollectionViewDataSource(items: []) + return dataSource + } } private extension SourcesViewController @@ -135,31 +208,52 @@ private extension SourcesViewController guard let httpsSourceURL = URL(string: "https://" + text) else { return } sourceURL = httpsSourceURL } - self.addSource(url: sourceURL) + + self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true + + self.addSource(url: sourceURL) { _ in + self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false + } }) self.present(alertController, animated: true, completion: nil) } - - func addSource(url: URL) + + func addSource(url: URL, isTrusted: Bool = false, completionHandler: ((Result) -> Void)? = nil) { guard self.view.window != nil else { return } - self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true + if url == self.deepLinkSourceURL + { + // Only handle deep link once. + self.deepLinkSourceURL = nil + } - func finish(error: Error?) + func finish(_ result: Result) { DispatchQueue.main.async { - if let error = error + switch result { - self.present(error) + case .success: break + case .failure(OperationError.cancelled): break + case .failure(let error): self.present(error) } - self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false + self.collectionView.reloadSections([Section.trusted.rawValue]) + + completionHandler?(result) } } - AppManager.shared.fetchSource(sourceURL: url) { (result) in + var dependencies: [Foundation.Operation] = [] + if let fetchTrustedSourcesOperation = self.fetchTrustedSourcesOperation + { + // Must fetch trusted sources first to determine whether this is a trusted source. + // We assume fetchTrustedSources() has already been called before this method. + dependencies = [fetchTrustedSourcesOperation] + } + + AppManager.shared.fetchSource(sourceURL: url, dependencies: dependencies) { (result) in do { let source = try result.get() @@ -167,25 +261,28 @@ private extension SourcesViewController let managedObjectContext = source.managedObjectContext #if !BETA - guard Source.allowedIdentifiers.contains(source.identifier) else { throw SourceError(code: .unsupported, source: source) } + guard let trustedSourceIDs = UserDefaults.shared.trustedSourceIDs, trustedSourceIDs.contains(source.identifier) else { throw SourceError(code: .unsupported, source: source) } #endif + // Hide warning when adding a featured trusted source. + let message = isTrusted ? nil : NSLocalizedString("Make sure to only add sources that you trust.", comment: "") + DispatchQueue.main.async { let alertController = UIAlertController(title: String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName), - message: NSLocalizedString("Sources control what apps appear in AltStore. Make sure to only add sources that you trust.", comment: ""), preferredStyle: .alert) + message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in - finish(error: nil) + finish(.failure(OperationError.cancelled)) }) - alertController.addAction(UIAlertAction(title: UIAlertAction.ok.title, style: UIAlertAction.ok.style) { _ in + alertController.addAction(UIAlertAction(title: NSLocalizedString("Add Source", comment: ""), style: UIAlertAction.ok.style) { _ in managedObjectContext?.perform { do { try managedObjectContext?.save() - finish(error: nil) + finish(.success(())) } catch { - finish(error: error) + finish(.failure(error)) } } }) @@ -194,11 +291,11 @@ private extension SourcesViewController } catch { - finish(error: error) + finish(.failure(error)) } } } - + func present(_ error: Error) { if let transitionCoordinator = self.transitionCoordinator @@ -217,6 +314,130 @@ private extension SourcesViewController alertController.addAction(.ok) self.present(alertController, animated: true, completion: nil) } + + func fetchTrustedSources() + { + func finish(_ result: Result<[Source], Error>) + { + self.fetchTrustedSourcesResult = result.map { _ in () } + + DispatchQueue.main.async { + do + { + let sources = try result.get() + print("Fetched trusted sources:", sources.map { $0.identifier }) + + let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) + self.trustedSourcesDataSource.setItems(sources, with: [sectionUpdate]) + } + catch + { + print("Error fetching trusted sources:", error) + + let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) + self.trustedSourcesDataSource.setItems([], with: [sectionUpdate]) + } + } + } + + self.fetchTrustedSourcesOperation = AppManager.shared.fetchTrustedSources { result in + switch result + { + case .failure(let error): finish(.failure(error)) + case .success(let trustedSources): + // Cache trusted source IDs. + UserDefaults.shared.trustedSourceIDs = trustedSources.map { $0.identifier } + + // Don't show sources without a sourceURL. + let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL } + + // This context is never saved, but keeps the managed sources alive. + let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() + self._fetchTrustedSourcesContext = context + + let dispatchGroup = DispatchGroup() + + var sourcesByURL = [URL: Source]() + var fetchError: Error? + + for sourceURL in featuredSourceURLs + { + dispatchGroup.enter() + + AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in + // Serialize access to sourcesByURL. + context.performAndWait { + switch result + { + case .failure(let error): fetchError = error + case .success(let source): sourcesByURL[source.sourceURL] = source + } + + dispatchGroup.leave() + } + } + } + + dispatchGroup.notify(queue: .main) { + if let error = fetchError + { + finish(.failure(error)) + } + else + { + let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] } + finish(.success(sources)) + } + } + } + } + } + + @IBAction func addTrustedSource(_ sender: PillButton) + { + let point = self.collectionView.convert(sender.center, from: sender.superview) + guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } + + let completedProgress = Progress(totalUnitCount: 1) + completedProgress.completedUnitCount = 1 + sender.progress = completedProgress + + let source = self.dataSource.item(at: indexPath) + self.addSource(url: source.sourceURL, isTrusted: true) { _ in + //FIXME: Handle cell reuse. + sender.progress = nil + } + } + + func remove(_ source: Source) + { + let alertController = UIAlertController(title: String(format: NSLocalizedString("Are you sure you want to remove the source “%@”?", comment: ""), source.name), + message: NSLocalizedString("Any apps you've installed from this source will remain, but they'll no longer receive any app updates.", comment: ""), preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: nil)) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove Source", comment: ""), style: .destructive) { _ in + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + let source = context.object(with: source.objectID) as! Source + context.delete(source) + + do + { + try context.save() + + DispatchQueue.main.async { + self.collectionView.reloadSections([Section.trusted.rawValue]) + } + } + catch + { + DispatchQueue.main.async { + self.present(error) + } + } + } + }) + + self.present(alertController, animated: true, completion: nil) + } } extension SourcesViewController @@ -251,11 +472,112 @@ extension SourcesViewController: UICollectionViewDelegateFlowLayout return size } + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize + { + guard Section(rawValue: section) == .trusted else { return .zero } + + let indexPath = IndexPath(row: 0, section: section) + let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, 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 + let reuseIdentifier = (kind == UICollectionView.elementKindSectionHeader) ? "Header" : "Footer" + + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! TextCollectionReusableView headerView.layoutMargins.left = self.view.layoutMargins.left headerView.layoutMargins.right = self.view.layoutMargins.right + + let almostRequiredPriority = UILayoutPriority(UILayoutPriority.required.rawValue - 1) // Can't be required or else we can't satisfy constraints when hidden (size = 0). + headerView.leadingLayoutConstraint?.priority = almostRequiredPriority + headerView.trailingLayoutConstraint?.priority = almostRequiredPriority + headerView.topLayoutConstraint?.priority = almostRequiredPriority + headerView.bottomLayoutConstraint?.priority = almostRequiredPriority + + switch kind + { + case UICollectionView.elementKindSectionHeader: + switch Section.allCases[indexPath.section] + { + case .added: + headerView.textLabel.text = NSLocalizedString("Sources control what apps are available to download through AltStore.", comment: "") + headerView.textLabel.font = UIFont.preferredFont(forTextStyle: .callout) + headerView.textLabel.textAlignment = .natural + + headerView.topLayoutConstraint.constant = 14 + headerView.bottomLayoutConstraint.constant = 30 + + case .trusted: + switch self.fetchTrustedSourcesResult + { + case .failure: headerView.textLabel.text = NSLocalizedString("Error Loading Trusted Sources", comment: "") + case .success, .none: headerView.textLabel.text = NSLocalizedString("Trusted Sources", comment: "") + } + + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .callout).withSymbolicTraits(.traitBold)! + headerView.textLabel.font = UIFont(descriptor: descriptor, size: 0) + headerView.textLabel.textAlignment = .center + + headerView.topLayoutConstraint.constant = 54 + headerView.bottomLayoutConstraint.constant = 15 + } + + case UICollectionView.elementKindSectionFooter: + let footerView = headerView as! SourcesFooterView + let font = UIFont.preferredFont(forTextStyle: .subheadline) + + switch self.fetchTrustedSourcesResult + { + case .failure(let error): + footerView.textView.font = font + footerView.textView.text = error.localizedDescription + + footerView.activityIndicatorView.stopAnimating() + footerView.topLayoutConstraint.constant = 0 + footerView.textView.textAlignment = .center + + case .success, .none: + footerView.textView.delegate = self + + let attributedText = NSMutableAttributedString( + string: NSLocalizedString("AltStore has reviewed these sources to make sure they meet our safety standards.\n\nSupport for untrusted sources is currently in beta, but you can help test them out by", comment: ""), + attributes: [.font: font, .foregroundColor: UIColor.gray] + ) + attributedText.mutableString.append(" ") + + let boldedFont = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold)!, size: font.pointSize) + let openPatreonURL = URL(string: "https://altstore.io/patreon")! + + let joinPatreonText = NSAttributedString( + string: NSLocalizedString("joining our Patreon.", comment: ""), + attributes: [.font: boldedFont, .link: openPatreonURL, .underlineColor: UIColor.clear] + ) + attributedText.append(joinPatreonText) + + footerView.textView.attributedText = attributedText + footerView.textView.textAlignment = .natural + + if self.fetchTrustedSourcesResult != nil + { + footerView.activityIndicatorView.stopAnimating() + footerView.topLayoutConstraint.constant = 20 + } + else + { + footerView.activityIndicatorView.startAnimating() + footerView.topLayoutConstraint.constant = 0 + } + } + + default: break + } + return headerView } } @@ -274,20 +596,11 @@ extension SourcesViewController } 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) - } - } + self.remove(source) + } + + let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { (action) in + self.addSource(url: source.sourceURL, isTrusted: true) } var actions: [UIAction] = [] @@ -297,11 +610,23 @@ extension SourcesViewController actions.append(viewErrorAction) } - if source.identifier != Source.altStoreIdentifier + switch Section.allCases[indexPath.section] { - actions.append(deleteAction) - } - + case .added: + if source.identifier != Source.altStoreIdentifier + { + actions.append(deleteAction) + } + + case .trusted: + if let cell = collectionView.cellForItem(at: indexPath) as? BannerCollectionViewCell, !cell.bannerView.button.isHidden + { + actions.append(addAction) + } + } + + guard !actions.isEmpty else { return nil } + let menu = UIMenu(title: "", children: actions) return menu } @@ -325,3 +650,11 @@ extension SourcesViewController return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration) } } + +extension SourcesViewController: UITextViewDelegate +{ + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool + { + return true + } +} diff --git a/AltStoreCore/Extensions/UserDefaults+AltStore.swift b/AltStoreCore/Extensions/UserDefaults+AltStore.swift index bcd66a9f..7241ca88 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 trustedSourceIDs: [String]? + var activeAppsLimit: Int? { get { return self._activeAppsLimit?.intValue diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index 2138ac92..cc0f2e32 100644 --- a/AltStoreCore/Model/Source.swift +++ b/AltStoreCore/Model/Source.swift @@ -33,10 +33,6 @@ public extension Source #endif #endif - - static let exampleIdentifier = "io.altstore.example" - - static let allowedIdentifiers: Set = [exampleIdentifier] } @objc(Source) diff --git a/Dependencies/Roxas b/Dependencies/Roxas index 636bab81..fb33e158 160000 --- a/Dependencies/Roxas +++ b/Dependencies/Roxas @@ -1 +1 @@ -Subproject commit 636bab81a2c0ca18c21b15b1da52ec6a13854b21 +Subproject commit fb33e158ae1e621ca705f9ab34b6ec16e83342ed