mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
685 lines
29 KiB
Swift
685 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
|
||
|
||
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) isn’t 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
|
||
}
|
||
}
|