// // SourcesViewController.swift // AltStore // // Created by Riley Testut on 3/17/20. // Copyright © 2020 Riley Testut. All rights reserved. // import UIKit import CoreData import AltStoreCore import Roxas import Nuke struct SourceError: ALTLocalizedError { enum Code: Int, ALTErrorCode { typealias Error = SourceError case unsupported } var code: Code var errorTitle: String? var errorFailure: String? @Managed var source: Source var errorFailureReason: String { switch self.code { case .unsupported: return String(format: NSLocalizedString("The source “%@” is not supported by this version of SideStore.", comment: ""), self.$source.name) } } } @objc(SourcesFooterView) private final class SourcesFooterView: TextCollectionReusableView { @IBOutlet var activityIndicatorView: UIActivityIndicatorView! @IBOutlet var textView: UITextView! } private extension UIAction.Identifier { static let showDetails = UIAction.Identifier("io.altstore.showDetails") static let showError = UIAction.Identifier("io.altstore.showError") } final class SourcesViewController: UICollectionViewController { var deepLinkSourceURL: URL? { didSet { self.handleAddSourceDeepLink() } } private lazy var dataSource = self.makeDataSource() private lazy var dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .none return dateFormatter }() private var placeholderView: RSTPlaceholderView! private var placeholderViewButton: UIButton! private var placeholderViewCenterYConstraint: NSLayoutConstraint! override func viewDidLoad() { super.viewDidLoad() let layout = self.makeLayout() self.collectionView.collectionViewLayout = layout self.navigationController?.view.tintColor = .altPrimary self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader) self.collectionView.dataSource = self.dataSource self.collectionView.prefetchDataSource = self.dataSource self.collectionView.allowsSelectionDuringEditing = false let backgroundView = UIView(frame: .zero) backgroundView.backgroundColor = .altBackground self.collectionView.backgroundView = backgroundView self.placeholderView = RSTPlaceholderView(frame: .zero) self.placeholderView.translatesAutoresizingMaskIntoConstraints = false self.placeholderView.textLabel.text = NSLocalizedString("Add More Sources!", comment: "") self.placeholderView.detailTextLabel.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis massa tortor, tempor vel est vitae, consequat luctus arcu." backgroundView.addSubview(self.placeholderView) let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).bolded() self.placeholderView.textLabel.font = UIFont(descriptor: fontDescriptor, size: 0.0) self.placeholderView.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body) self.placeholderView.detailTextLabel.textAlignment = .natural self.placeholderViewButton = UIButton(type: .system, primaryAction: UIAction(title: NSLocalizedString("View Recommended Sources", comment: "")) { [weak self] _ in self?.performSegue(withIdentifier: "addSource", sender: nil) }) self.placeholderViewButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) self.placeholderView.stackView.spacing = 15 self.placeholderView.stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15) self.placeholderView.stackView.isLayoutMarginsRelativeArrangement = true self.placeholderView.stackView.addArrangedSubview(self.placeholderViewButton) self.placeholderViewCenterYConstraint = self.placeholderView.safeAreaLayoutGuide.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor, constant: 0) NSLayoutConstraint.activate([ self.placeholderViewCenterYConstraint, self.placeholderView.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), self.placeholderView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor), self.placeholderView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor), self.placeholderView.topAnchor.constraint(equalTo: self.placeholderView.stackView.topAnchor), self.placeholderView.bottomAnchor.constraint(equalTo: self.placeholderView.stackView.bottomAnchor), ]) self.navigationItem.rightBarButtonItem = self.editButtonItem self.update() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.handleAddSourceDeepLink() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Vertically center placeholder view in gap below first item. let indexPath = IndexPath(item: 0, section: 0) guard let layoutAttributes = self.collectionView.layoutAttributesForItem(at: indexPath) else { return } let maxY = layoutAttributes.frame.maxY let constant = maxY / 2 if self.placeholderViewCenterYConstraint.constant != constant { self.placeholderViewCenterYConstraint.constant = constant } } } private extension SourcesViewController { func makeLayout() -> UICollectionViewCompositionalLayout { var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) configuration.headerMode = .supplementary configuration.showsSeparators = false configuration.backgroundColor = .clear configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in guard let self else { return UISwipeActionsConfiguration(actions: []) } let source = self.dataSource.item(at: indexPath) var actions: [UIContextualAction] = [] if source.identifier != Source.altStoreIdentifier { // Prevent users from removing AltStore source. let removeAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Remove", comment: "")) { _, _, completion in self.remove(source, completionHandler: completion) } removeAction.image = UIImage(systemName: "trash.fill") actions.append(removeAction) } if let error = source.error { let viewErrorAction = UIContextualAction(style: .normal, title: NSLocalizedString("View Error", comment: "")) { _, _, completion in self.present(error) completion(true) } viewErrorAction.backgroundColor = .systemYellow viewErrorAction.image = UIImage(systemName: "exclamationmark.circle.fill") actions.append(viewErrorAction) } let config = UISwipeActionsConfiguration(actions: actions) config.performsFirstActionWithFullSwipe = false return config } let layout = UICollectionViewCompositionalLayout.list(using: configuration) return layout } func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = Source.fetchRequest() as NSFetchRequest fetchRequest.returnsObjectsAsFaults = false fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Source.name, ascending: true), // Can't sort by URLs or else app will crash. // NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true), NSSortDescriptor(keyPath: \Source.identifier, ascending: true)] let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil) fetchedResultsController.delegate = self let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchedResultsController: fetchedResultsController) dataSource.proxy = self dataSource.cellConfigurationHandler = { [weak self] (cell, source, indexPath) in guard let self else { return } let cell = cell as! AppBannerCollectionViewCell cell.layoutMargins.top = 5 cell.layoutMargins.bottom = 5 cell.layoutMargins.left = self.view.layoutMargins.left cell.layoutMargins.right = self.view.layoutMargins.right cell.bannerView.configure(for: source) cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true let numberOfApps: Int if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated { numberOfApps = source.apps.count } else { numberOfApps = source.apps.filter { !$0.isBeta }.count } if let error = source.error { let image = UIImage(systemName: "exclamationmark")?.withTintColor(.white, renderingMode: .alwaysOriginal) cell.bannerView.button.setImage(image, for: .normal) cell.bannerView.button.setTitle(nil, for: .normal) cell.bannerView.button.tintColor = .systemYellow.withAlphaComponent(0.75) let action = UIAction(identifier: .showError) { _ in self.present(error) } cell.bannerView.button.addAction(action, for: .primaryActionTriggered) cell.bannerView.button.removeAction(identifiedBy: .showDetails, for: .primaryActionTriggered) } else { cell.bannerView.button.setImage(nil, for: .normal) cell.bannerView.button.setTitle(numberOfApps.description, for: .normal) cell.bannerView.button.tintColor = .white.withAlphaComponent(0.2) let action = UIAction(identifier: .showDetails) { _ in self.showSourceDetails(for: source) } cell.bannerView.button.addAction(action, for: .primaryActionTriggered) cell.bannerView.button.removeAction(identifiedBy: .showError, for: .primaryActionTriggered) } let dateText: String if let lastUpdatedDate = source.lastUpdatedDate { dateText = Date().relativeDateString(since: lastUpdatedDate, dateFormatter: self.dateFormatter) } else { dateText = NSLocalizedString("Never", comment: "") } let text = String(format: NSLocalizedString("Last Updated: %@", comment: ""), dateText) cell.bannerView.subtitleLabel.text = text cell.bannerView.subtitleLabel.numberOfLines = 1 let numberOfAppsText: String if #available(iOS 15, *) { let attributedOutput = AttributedString(localized: "^[\(numberOfApps) app](inflect: true)") numberOfAppsText = String(attributedOutput.characters) } else { numberOfAppsText = "" } let accessibilityLabel = source.name + "\n" + text + ".\n" + numberOfAppsText cell.bannerView.accessibilityLabel = accessibilityLabel if source.identifier != Source.altStoreIdentifier { cell.accessories = [.delete(displayed: .whenEditing)] } else { cell.accessories = [] } cell.bannerView.accessibilityTraits.remove(.button) // Make sure refresh button is correct size. cell.layoutIfNeeded() } dataSource.prefetchHandler = { (source, indexPath, completionHandler) in guard let imageURL = source.effectiveIconURL else { return nil } return RSTAsyncBlockOperation() { (operation) in ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in guard !operation.isCancelled else { return operation.finish() } switch result { case .success(let response): completionHandler(response.image, nil) case .failure(let error): completionHandler(nil, error) } } } } dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in let cell = cell as! AppBannerCollectionViewCell cell.bannerView.iconImageView.isIndicatingActivity = false cell.bannerView.iconImageView.image = image if let error = error { print("Error loading image:", error) } } return dataSource } @IBSegueAction func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? { guard let source = sender as? Source else { return nil } let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder) return sourceDetailViewController } } private extension SourcesViewController { func handleAddSourceDeepLink() { let alertController = UIAlertController(title: NSLocalizedString("Add Source", comment: ""), message: nil, preferredStyle: .alert) alertController.addTextField { (textField) in textField.placeholder = "https://apps.sidestore.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 else { return } guard var sourceURL = URL(string: text) else { return } if sourceURL.scheme == nil { guard let httpsSourceURL = URL(string: "https://" + text) else { return } sourceURL = httpsSourceURL } self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true self.addSource(url: sourceURL) { _ in self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false } }) guard let url = self.deepLinkSourceURL, self.view.window != nil else { return } // Only handle deep link once. self.deepLinkSourceURL = nil self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true func finish(_ result: Result) { DispatchQueue.main.async { switch result { case .success: break case .failure(OperationError.cancelled): break case .failure(var error as SourceError): let title = String(format: NSLocalizedString("“%@” could not be added to AltStore.", comment: ""), error.$source.name) error.errorTitle = title self.present(error) case .failure(let error as NSError): self.present(error.withLocalizedTitle(NSLocalizedString("Unable to Add Source", comment: ""))) } self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false } } AppManager.shared.fetchSource(sourceURL: url) { (result) in do { // Use @Managed before calling perform() to keep // strong reference to source.managedObjectContext. @Managed var source = try result.get() DispatchQueue.main.async { self.showSourceDetails(for: source) } finish(.success(())) } catch { finish(.failure(error)) } } } func present(_ error: Error) { if let transitionCoordinator = self.transitionCoordinator { transitionCoordinator.animate(alongsideTransition: nil) { _ in self.present(error) } return } let nsError = error as NSError let title = nsError.localizedTitle // OK if nil. let message = [nsError.localizedDescription, nsError.localizedDebugDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n") let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addAction(.ok) self.present(alertController, animated: true, completion: nil) } func remove(_ source: Source, completionHandler: ((Bool) -> Void)? = nil) { Task { do { try await AppManager.shared.remove(source, presentingViewController: self) completionHandler?(true) } catch is CancellationError { completionHandler?(false) } catch { completionHandler?(false) 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 { print(error) // 1 error doesn't mean all trusted sources failed to load! Riley, why did you do this??????? // finish(.failure(error)) } let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] } finish(.success(sources)) } self.present(error) } } } func showSourceDetails(for source: Source) { self.performSegue(withIdentifier: "showSourceDetails", sender: source) } func update() { if self.dataSource.itemCount < 2 { // Show placeholder view self.placeholderView.isHidden = false self.collectionView.alwaysBounceVertical = false self.setEditing(false, animated: true) self.editButtonItem.isEnabled = false } else { self.placeholderView.isHidden = true self.collectionView.alwaysBounceVertical = true self.editButtonItem.isEnabled = true } } } extension SourcesViewController { override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { self.collectionView.deselectItem(at: indexPath, animated: true) let source = self.dataSource.item(at: indexPath) self.showSourceDetails(for: source) } override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell var configuation = UIListContentConfiguration.cell() configuation.text = NSLocalizedString("Sources control what apps are available to download through AltStore.", comment: "") configuation.textProperties.color = .secondaryLabel configuation.textProperties.alignment = .natural headerView.contentConfiguration = configuation 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 SideStore.", 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("SideStore has reviewed these sources to make sure they meet our safety standards.", 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://SideStore.io/")! // let joinPatreonText = NSAttributedString( // string: NSLocalizedString("", 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 } } extension SourcesViewController: NSFetchedResultsControllerDelegate { func controllerWillChangeContent(_ controller: NSFetchedResultsController) { self.dataSource.controllerWillChangeContent(controller) } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { self.update() self.dataSource.controllerDidChangeContent(controller) } func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { self.dataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath) } func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { self.dataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type) } } @available(iOS 17, *) #Preview(traits: .portrait) { DatabaseManager.shared.startForPreview() let storyboard = UIStoryboard(name: "Sources", bundle: nil) let sourcesViewController = storyboard.instantiateInitialViewController()! let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() context.performAndWait { _ = Source.make(name: "OatmealDome's AltStore Source", identifier: "me.oatmealdome.altstore", sourceURL: URL(string: "https://altstore.oatmealdome.me")!, context: context) _ = Source.make(name: "UTM Repository", identifier: "com.utmapp.repos.UTM", sourceURL: URL(string: "https://alt.getutm.app")!, context: context) _ = Source.make(name: "Flyinghead", identifier: "com.flyinghead.source", sourceURL: URL(string: "https://flyinghead.github.io/flycast-builds/altstore.json")!, context: context) _ = Source.make(name: "Provenance", identifier: "org.provenance-emu.AltStore", sourceURL: URL(string: "https://provenance-emu.com/apps.json")!, context: context) _ = Source.make(name: "PojavLauncher Repository", identifier: "dev.crystall1ne.repos.PojavLauncher", sourceURL: URL(string: "http://alt.crystall1ne.dev")!, context: context) try! context.save() } AppManager.shared.fetchSources { result in do { let (sources, context) = try result.get() try context.save() } catch { print("Preview failed to fetch sources:", error) } } return sourcesViewController }