Files
SideStore/AltStore/Sources/SourcesViewController.swift

705 lines
29 KiB
Swift

//
// 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<Source, UIImage>
{
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 fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
fetchedResultsController.delegate = self
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<Source, UIImage>(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<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.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<Void, Never> {
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<NSFetchRequestResult>)
{
self.dataSource.controllerWillChangeContent(controller)
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
{
self.update()
self.dataSource.controllerDidChangeContent(controller)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, 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<NSFetchRequestResult>, 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
}