Files
SideStore/AltStore/Sources/SourcesViewController.swift

685 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
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!
}
extension SourcesViewController
{
private enum Section: Int, CaseIterable
{
case added
case trusted
}
}
final class SourcesViewController: UICollectionViewController
{
var deepLinkSourceURL: URL? {
didSet {
guard let sourceURL = self.deepLinkSourceURL else { return }
self.addSource(url: sourceURL)
}
}
private lazy var dataSource = self.makeDataSource()
private lazy var addedSourcesDataSource = self.makeAddedSourcesDataSource()
private lazy var trustedSourcesDataSource = self.makeTrustedSourcesDataSource()
private var fetchTrustedSourcesOperation: UpdateKnownSourcesOperation?
private var fetchTrustedSourcesResult: Result<Void, Error>?
private var _fetchTrustedSourcesContext: NSManagedObjectContext?
override func viewDidLoad()
{
super.viewDidLoad()
self.view.tintColor = .altPrimary
self.navigationController?.view.tintColor = .altPrimary
if let navigationBar = self.navigationController?.navigationBar as? NavigationBar
{
// Don't automatically adjust item positions when being presented non-full screen,
// or else the navigation bar content won't be vertically centered.
navigationBar.automaticallyAdjustsItemPositions = false
}
self.collectionView.dataSource = self.dataSource
#if !BETA
// Hide "Add Source" button for public version while in beta.
self.navigationItem.leftBarButtonItem = nil
#endif
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
if self.deepLinkSourceURL != nil
{
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
}
if self.fetchTrustedSourcesOperation == nil
{
self.fetchTrustedSources()
}
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
if let sourceURL = self.deepLinkSourceURL
{
self.addSource(url: sourceURL)
}
}
}
private extension SourcesViewController
{
func makeDataSource() -> RSTCompositeCollectionViewDataSource<Source>
{
let dataSource = RSTCompositeCollectionViewDataSource<Source>(dataSources: [self.addedSourcesDataSource, self.trustedSourcesDataSource])
dataSource.proxy = self
dataSource.cellConfigurationHandler = { [weak self] (cell, source, indexPath) in
guard let self else { return }
let tintColor = UIColor.altPrimary
let cell = cell as! AppBannerCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = tintColor
cell.bannerView.iconImageView.isHidden = true
cell.bannerView.buttonLabel.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
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
cell.bannerView.subtitleLabel.numberOfLines = 2
cell.errorBadge?.isHidden = (source.error == nil)
let attributedLabel = NSAttributedString(string: source.name + "\n" + source.sourceURL.absoluteString, attributes: [.accessibilitySpeechPunctuation: true])
cell.bannerView.accessibilityAttributedLabel = attributedLabel
cell.bannerView.accessibilityTraits.remove(.button)
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
}
return dataSource
}
func makeAddedSourcesDataSource() -> RSTFetchedResultsCollectionViewDataSource<Source>
{
let fetchRequest = Source.fetchRequest() as NSFetchRequest<Source>
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 dataSource = RSTFetchedResultsCollectionViewDataSource<Source>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
return dataSource
}
func makeTrustedSourcesDataSource() -> RSTArrayCollectionViewDataSource<Source>
{
let dataSource = RSTArrayCollectionViewDataSource<Source>(items: [])
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
{
@IBAction func addSource()
{
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
}
})
self.present(alertController, animated: true, completion: nil)
}
func addSource(url: URL, completionHandler: ((Result<Void, Error>) -> Void)? = nil)
{
guard self.view.window != nil else { return }
if url == self.deepLinkSourceURL
{
// Only handle deep link once.
self.deepLinkSourceURL = nil
}
func finish(_ result: Result<Void, Error>)
{
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.collectionView.reloadSections([Section.trusted.rawValue])
completionHandler?(result)
}
}
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
{
// Use @Managed before calling perform() to keep
// strong reference to source.managedObjectContext.
@Managed var source = try result.get()
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
backgroundContext.perform {
do
{
let predicate = NSPredicate(format: "%K == %@", #keyPath(Source.identifier), $source.identifier)
if let existingSource = Source.first(satisfying: predicate, in: backgroundContext)
{
throw SourceError.duplicate(source, existingSource: existingSource)
}
DispatchQueue.main.async {
self.showSourceDetails(for: source)
}
finish(.success(()))
}
catch
{
finish(.failure(error))
}
}
}
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 fetchTrustedSources()
{
// Closure instead of local function so we can capture `self` weakly.
let finish: (Result<[Source], Error>) -> Void = { [weak self] result in
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.updateKnownSources { [weak self] result in
switch result
{
case .failure(let error): finish(.failure(error))
case .success((let trustedSources, _)):
// 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
{
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))
}
}
}
}
@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) { _ 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)
}
func showSourceDetails(for source: Source)
{
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
}
}
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)
}
}
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
}
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 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
/* Changing NSLayoutConstraint priorities from required to optional (and vice versa) isnt supported, and crashes on iOS 12. */
// 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 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
{
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
{
let source = self.dataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in
let viewErrorAction = UIAction(title: NSLocalizedString("View Error", comment: ""), image: UIImage(systemName: "exclamationmark.circle")) { (action) in
guard let error = source.error else { return }
self.present(error)
}
let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in
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)
}
var actions: [UIAction] = []
if source.error != nil
{
actions.append(viewErrorAction)
}
switch Section.allCases[indexPath.section]
{
case .added:
if source.identifier != Source.altStoreIdentifier
{
actions.append(deleteAction)
}
case .trusted:
if let cell = collectionView.cellForItem(at: indexPath) as? AppBannerCollectionViewCell, !cell.bannerView.button.isHidden
{
actions.append(addAction)
}
}
guard !actions.isEmpty else { return nil }
let menu = UIMenu(title: "", children: actions)
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? AppBannerCollectionViewCell 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)
}
}
extension SourcesViewController: UITextViewDelegate
{
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool
{
return true
}
}