2019-07-16 14:25:09 -07:00
|
|
|
//
|
|
|
|
|
// BrowseViewController.swift
|
|
|
|
|
// AltStore
|
|
|
|
|
//
|
|
|
|
|
// Created by Riley Testut on 7/15/19.
|
|
|
|
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import UIKit
|
2023-12-07 17:30:46 -06:00
|
|
|
import Combine
|
2019-07-16 14:25:09 -07:00
|
|
|
|
2023-10-20 21:43:51 -04:00
|
|
|
import minimuxer
|
2020-09-03 16:39:08 -07:00
|
|
|
import AltStoreCore
|
2019-07-16 14:25:09 -07:00
|
|
|
import Roxas
|
|
|
|
|
|
2019-08-20 19:06:03 -05:00
|
|
|
import Nuke
|
|
|
|
|
|
2023-03-02 15:48:33 -06:00
|
|
|
class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
2023-04-04 16:17:38 -05:00
|
|
|
// Nil == Show apps from all sources.
|
2023-12-07 18:11:25 -06:00
|
|
|
let source: Source?
|
|
|
|
|
|
|
|
|
|
private(set) var category: StoreCategory? {
|
|
|
|
|
didSet {
|
|
|
|
|
self.updateDataSource()
|
|
|
|
|
self.update()
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-04-04 16:17:38 -05:00
|
|
|
|
2019-07-16 14:25:09 -07:00
|
|
|
private lazy var dataSource = self.makeDataSource()
|
2019-09-19 15:18:21 -07:00
|
|
|
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
2019-07-16 14:25:09 -07:00
|
|
|
|
2023-10-19 16:11:57 -05:00
|
|
|
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
|
2019-07-30 12:41:50 -07:00
|
|
|
|
2023-12-07 17:54:59 -06:00
|
|
|
private var sortButton: UIBarButtonItem?
|
|
|
|
|
private var preferredAppSorting: AppSorting = UserDefaults.shared.preferredAppSorting
|
|
|
|
|
|
2023-12-07 17:30:46 -06:00
|
|
|
private var cancellables = Set<AnyCancellable>()
|
2019-09-19 15:18:21 -07:00
|
|
|
|
2023-04-04 16:17:38 -05:00
|
|
|
init?(source: Source?, coder: NSCoder)
|
|
|
|
|
{
|
|
|
|
|
self.source = source
|
2023-12-07 18:11:25 -06:00
|
|
|
self.category = nil
|
|
|
|
|
|
|
|
|
|
super.init(coder: coder)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init?(category: StoreCategory?, coder: NSCoder)
|
|
|
|
|
{
|
|
|
|
|
self.source = nil
|
|
|
|
|
self.category = category
|
2023-04-04 16:17:38 -05:00
|
|
|
|
|
|
|
|
super.init(coder: coder)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
required init?(coder: NSCoder)
|
|
|
|
|
{
|
2023-12-07 18:11:25 -06:00
|
|
|
self.source = nil
|
|
|
|
|
self.category = nil
|
|
|
|
|
|
2023-04-04 16:17:38 -05:00
|
|
|
super.init(coder: coder)
|
|
|
|
|
}
|
|
|
|
|
|
2019-07-30 12:41:50 -07:00
|
|
|
private var cachedItemSizes = [String: CGSize]()
|
|
|
|
|
|
2020-10-05 14:48:48 -07:00
|
|
|
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
|
|
|
|
|
|
2019-07-16 14:25:09 -07:00
|
|
|
override func viewDidLoad()
|
|
|
|
|
{
|
|
|
|
|
super.viewDidLoad()
|
|
|
|
|
|
2023-10-19 13:39:15 -05:00
|
|
|
self.collectionView.backgroundColor = .altBackground
|
2023-12-07 17:30:46 -06:00
|
|
|
self.collectionView.alwaysBounceVertical = true
|
2023-10-19 13:39:15 -05:00
|
|
|
|
2023-12-07 17:42:02 -06:00
|
|
|
self.dataSource.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
|
|
|
|
#keyPath(StoreApp.subtitle),
|
|
|
|
|
#keyPath(StoreApp.developerName),
|
|
|
|
|
#keyPath(StoreApp.bundleIdentifier)]
|
2020-03-30 13:46:15 -07:00
|
|
|
self.navigationItem.searchController = self.dataSource.searchController
|
|
|
|
|
|
2019-07-30 12:41:50 -07:00
|
|
|
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
2023-10-19 16:11:57 -05:00
|
|
|
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
2019-07-30 12:41:50 -07:00
|
|
|
|
2019-07-16 14:25:09 -07:00
|
|
|
self.collectionView.dataSource = self.dataSource
|
|
|
|
|
self.collectionView.prefetchDataSource = self.dataSource
|
2019-07-30 12:41:50 -07:00
|
|
|
|
2023-10-19 16:13:36 -05:00
|
|
|
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
|
|
|
|
|
collectionViewLayout.minimumLineSpacing = 30
|
|
|
|
|
|
2023-03-02 15:48:33 -06:00
|
|
|
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
2019-09-19 15:18:21 -07:00
|
|
|
|
2023-12-07 17:30:46 -06:00
|
|
|
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction { [weak self] _ in
|
|
|
|
|
self?.updateSources()
|
|
|
|
|
})
|
|
|
|
|
self.collectionView.refreshControl = refreshControl
|
|
|
|
|
|
2023-04-04 16:17:38 -05:00
|
|
|
if let source = self.source
|
|
|
|
|
{
|
|
|
|
|
let tintColor = source.effectiveTintColor ?? .altPrimary
|
|
|
|
|
self.view.tintColor = tintColor
|
|
|
|
|
|
|
|
|
|
let appearance = NavigationBarAppearance()
|
|
|
|
|
appearance.configureWithTintColor(tintColor)
|
|
|
|
|
appearance.configureWithDefaultBackground()
|
|
|
|
|
|
|
|
|
|
let edgeAppearance = appearance.copy()
|
|
|
|
|
edgeAppearance.configureWithTransparentBackground()
|
|
|
|
|
|
|
|
|
|
self.navigationItem.standardAppearance = appearance
|
|
|
|
|
self.navigationItem.scrollEdgeAppearance = edgeAppearance
|
|
|
|
|
}
|
2023-12-07 18:11:25 -06:00
|
|
|
else if self.category != nil, #available(iOS 16, *)
|
|
|
|
|
{
|
|
|
|
|
let menu = UIMenu(children: [
|
|
|
|
|
UIDeferredMenuElement.uncached { [weak self] completion in
|
|
|
|
|
let actions = self?.makeCategoryActions() ?? []
|
|
|
|
|
completion(actions)
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
self.navigationItem.titleMenuProvider = { _ in menu }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.navigationItem.largeTitleDisplayMode = .never
|
2023-04-04 16:17:38 -05:00
|
|
|
|
2023-12-07 17:42:02 -06:00
|
|
|
if #available(iOS 16, *)
|
|
|
|
|
{
|
|
|
|
|
self.navigationItem.preferredSearchBarPlacement = .inline
|
|
|
|
|
}
|
2023-12-07 17:30:46 -06:00
|
|
|
|
2023-12-07 17:54:59 -06:00
|
|
|
if #available(iOS 15, *)
|
|
|
|
|
{
|
|
|
|
|
self.prepareAppSorting()
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-07 17:30:46 -06:00
|
|
|
self.preparePipeline()
|
|
|
|
|
|
2023-12-07 17:54:59 -06:00
|
|
|
self.updateDataSource()
|
2019-09-19 15:18:21 -07:00
|
|
|
self.update()
|
2019-07-16 14:25:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func viewWillAppear(_ animated: Bool)
|
|
|
|
|
{
|
|
|
|
|
super.viewWillAppear(animated)
|
|
|
|
|
|
2020-10-05 14:48:48 -07:00
|
|
|
self.update()
|
2019-07-16 14:25:09 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension BrowseViewController
|
|
|
|
|
{
|
2023-12-07 17:30:46 -06:00
|
|
|
func preparePipeline()
|
|
|
|
|
{
|
|
|
|
|
AppManager.shared.$updateSourcesResult
|
|
|
|
|
.receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value).
|
|
|
|
|
.sink { [weak self] result in
|
|
|
|
|
self?.update()
|
|
|
|
|
}
|
|
|
|
|
.store(in: &self.cancellables)
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-07 17:54:59 -06:00
|
|
|
func makeFetchRequest() -> NSFetchRequest<StoreApp>
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
2019-07-31 14:07:00 -07:00
|
|
|
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
2019-07-16 14:25:09 -07:00
|
|
|
fetchRequest.returnsObjectsAsFaults = false
|
2019-07-30 17:00:04 -07:00
|
|
|
|
2023-11-29 18:08:42 -06:00
|
|
|
let predicate = StoreApp.visibleAppsPredicate
|
|
|
|
|
|
2023-04-04 16:17:38 -05:00
|
|
|
if let source = self.source
|
|
|
|
|
{
|
|
|
|
|
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
|
|
|
|
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [filterPredicate, predicate])
|
|
|
|
|
}
|
2023-12-07 18:11:25 -06:00
|
|
|
else if let category = self.category
|
|
|
|
|
{
|
|
|
|
|
let categoryPredicate = switch category {
|
|
|
|
|
case .other: StoreApp.otherCategoryPredicate
|
|
|
|
|
default: NSPredicate(format: "%K == %@", #keyPath(StoreApp._category), category.rawValue)
|
|
|
|
|
}
|
|
|
|
|
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [categoryPredicate, predicate])
|
|
|
|
|
}
|
2023-04-04 16:17:38 -05:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
fetchRequest.predicate = predicate
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-07 17:54:59 -06:00
|
|
|
var sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
|
|
|
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
|
|
|
|
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
|
|
|
|
|
|
|
|
|
switch self.preferredAppSorting
|
|
|
|
|
{
|
|
|
|
|
case .default:
|
|
|
|
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: self.preferredAppSorting.isAscending)
|
|
|
|
|
sortDescriptors.insert(descriptor, at: 0)
|
|
|
|
|
|
|
|
|
|
case .name:
|
|
|
|
|
// Already sorting by name, no need to prepend additional sort descriptor.
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
case .developer:
|
|
|
|
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.developerName, ascending: self.preferredAppSorting.isAscending)
|
|
|
|
|
sortDescriptors.insert(descriptor, at: 0)
|
|
|
|
|
|
|
|
|
|
case .lastUpdated:
|
|
|
|
|
let descriptor = NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: self.preferredAppSorting.isAscending)
|
|
|
|
|
sortDescriptors.insert(descriptor, at: 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchRequest.sortDescriptors = sortDescriptors
|
|
|
|
|
|
|
|
|
|
return fetchRequest
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
|
|
|
|
{
|
|
|
|
|
let fetchRequest = self.makeFetchRequest()
|
|
|
|
|
|
2023-04-04 16:17:38 -05:00
|
|
|
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
|
|
|
|
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
|
2023-12-07 18:19:42 -06:00
|
|
|
dataSource.placeholderView = self.placeholderView
|
|
|
|
|
dataSource.cellConfigurationHandler = { [weak self] (cell, app, indexPath) in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
2023-10-19 16:11:57 -05:00
|
|
|
let cell = cell as! AppCardCollectionViewCell
|
2019-10-22 12:23:03 -07:00
|
|
|
cell.layoutMargins.left = self.view.layoutMargins.left
|
|
|
|
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
|
|
|
|
|
2023-12-07 18:19:42 -06:00
|
|
|
let showSourceIcon = (self.source == nil) // Hide source icon if redundant
|
|
|
|
|
cell.configure(for: app, showSourceIcon: showSourceIcon)
|
2019-10-22 12:23:03 -07:00
|
|
|
|
|
|
|
|
cell.bannerView.iconImageView.image = nil
|
|
|
|
|
cell.bannerView.iconImageView.isIndicatingActivity = true
|
2019-07-16 14:25:09 -07:00
|
|
|
|
2019-10-22 12:23:03 -07:00
|
|
|
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
2022-12-17 03:13:30 -05:00
|
|
|
cell.bannerView.button.activityIndicatorView.style = .medium
|
2023-03-02 14:48:20 -06:00
|
|
|
cell.bannerView.button.activityIndicatorView.color = .white
|
2019-07-16 14:25:09 -07:00
|
|
|
|
2019-09-19 11:29:10 -07:00
|
|
|
let tintColor = app.tintColor ?? .altPrimary
|
2019-07-16 14:25:09 -07:00
|
|
|
cell.tintColor = tintColor
|
|
|
|
|
}
|
2019-08-20 19:06:03 -05:00
|
|
|
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
|
|
|
|
let iconURL = storeApp.iconURL
|
|
|
|
|
|
|
|
|
|
return RSTAsyncBlockOperation() { (operation) in
|
2023-03-01 15:00:27 -06:00
|
|
|
ImagePipeline.shared.loadImage(with: iconURL, progress: nil) { result in
|
2019-08-27 16:00:59 -07:00
|
|
|
guard !operation.isCancelled else { return operation.finish() }
|
2019-08-20 19:06:03 -05:00
|
|
|
|
2023-03-01 15:00:27 -06:00
|
|
|
switch result
|
2019-08-20 19:06:03 -05:00
|
|
|
{
|
2023-03-01 15:00:27 -06:00
|
|
|
case .success(let response): completionHandler(response.image, nil)
|
|
|
|
|
case .failure(let error): completionHandler(nil, error)
|
2019-08-20 19:06:03 -05:00
|
|
|
}
|
2023-03-01 15:00:27 -06:00
|
|
|
}
|
2019-08-20 19:06:03 -05:00
|
|
|
}
|
|
|
|
|
}
|
2023-12-07 18:19:42 -06:00
|
|
|
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
2023-10-19 16:11:57 -05:00
|
|
|
let cell = cell as! AppCardCollectionViewCell
|
2019-10-22 12:23:03 -07:00
|
|
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
|
|
|
|
cell.bannerView.iconImageView.image = image
|
2019-08-20 19:06:03 -05:00
|
|
|
|
2023-12-07 18:19:42 -06:00
|
|
|
if let error = error, let dataSource
|
2019-08-20 19:06:03 -05:00
|
|
|
{
|
2023-12-07 18:19:42 -06:00
|
|
|
let app = dataSource.item(at: indexPath)
|
|
|
|
|
Logger.main.debug("Failed to load app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
2019-08-20 19:06:03 -05:00
|
|
|
}
|
|
|
|
|
}
|
2019-07-16 14:25:09 -07:00
|
|
|
|
|
|
|
|
return dataSource
|
|
|
|
|
}
|
|
|
|
|
|
2019-08-28 11:13:22 -07:00
|
|
|
func updateDataSource()
|
|
|
|
|
{
|
2023-12-07 17:54:59 -06:00
|
|
|
self.dataSource.predicate = nil
|
|
|
|
|
let fetchRequest = self.makeFetchRequest()
|
|
|
|
|
|
|
|
|
|
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
|
|
|
|
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
|
|
|
|
|
self.dataSource.fetchedResultsController = fetchedResultsController
|
2019-08-28 11:13:22 -07:00
|
|
|
}
|
|
|
|
|
|
2023-12-07 17:30:46 -06:00
|
|
|
func updateSources()
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
2023-12-07 17:30:46 -06:00
|
|
|
AppManager.shared.updateAllSources { result in
|
|
|
|
|
self.collectionView.refreshControl?.endRefreshing()
|
|
|
|
|
|
|
|
|
|
guard case .failure(let error) = result else { return }
|
|
|
|
|
|
|
|
|
|
if self.dataSource.itemCount > 0
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
2023-12-07 17:30:46 -06:00
|
|
|
let toastView = ToastView(error: error)
|
|
|
|
|
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
|
|
|
|
toastView.show(in: self)
|
2019-07-16 14:25:09 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-09-19 15:18:21 -07:00
|
|
|
|
|
|
|
|
func update()
|
|
|
|
|
{
|
2023-12-07 17:30:46 -06:00
|
|
|
switch AppManager.shared.updateSourcesResult
|
2019-09-19 15:18:21 -07:00
|
|
|
{
|
2023-12-07 17:30:46 -06:00
|
|
|
case nil:
|
2019-09-19 15:18:21 -07:00
|
|
|
self.placeholderView.textLabel.isHidden = true
|
|
|
|
|
self.placeholderView.detailTextLabel.isHidden = false
|
|
|
|
|
|
|
|
|
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
|
|
|
|
|
|
|
|
|
self.placeholderView.activityIndicatorView.startAnimating()
|
|
|
|
|
|
2023-12-07 17:30:46 -06:00
|
|
|
case .failure(let error):
|
2019-09-19 15:18:21 -07:00
|
|
|
self.placeholderView.textLabel.isHidden = false
|
|
|
|
|
self.placeholderView.detailTextLabel.isHidden = false
|
|
|
|
|
|
|
|
|
|
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
|
|
|
|
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
|
|
|
|
|
|
|
|
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
|
|
|
|
2023-12-07 17:30:46 -06:00
|
|
|
case .success:
|
|
|
|
|
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
|
|
|
|
self.placeholderView.textLabel.isHidden = false
|
2019-09-19 15:18:21 -07:00
|
|
|
self.placeholderView.detailTextLabel.isHidden = true
|
|
|
|
|
|
|
|
|
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
|
|
|
}
|
2023-12-07 18:11:25 -06:00
|
|
|
|
|
|
|
|
if let category = self.category
|
|
|
|
|
{
|
|
|
|
|
self.title = category.localizedName
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
self.title = NSLocalizedString("Browse", comment: "")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func makeCategoryActions() -> [UIAction]
|
|
|
|
|
{
|
|
|
|
|
let handler = { [weak self] (category: StoreCategory) in
|
|
|
|
|
self?.category = category
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fetchRequest = NSFetchRequest(entityName: StoreApp.entity().name!) as NSFetchRequest<NSDictionary>
|
|
|
|
|
fetchRequest.resultType = .dictionaryResultType
|
|
|
|
|
fetchRequest.returnsDistinctResults = true
|
|
|
|
|
fetchRequest.propertiesToFetch = [#keyPath(StoreApp._category)]
|
|
|
|
|
fetchRequest.predicate = StoreApp.visibleAppsPredicate
|
|
|
|
|
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
let dictionaries = try DatabaseManager.shared.viewContext.fetch(fetchRequest)
|
|
|
|
|
|
|
|
|
|
// Keep nil values
|
|
|
|
|
let categories = dictionaries.map { $0[#keyPath(StoreApp._category)] as? String? ?? nil }.map { rawCategory -> StoreCategory in
|
|
|
|
|
guard let rawCategory else { return .other }
|
|
|
|
|
return StoreCategory(rawValue: rawCategory) ?? .other
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let sortedCategories = Set(categories).sorted(by: { $0.localizedName.localizedStandardCompare($1.localizedName) == .orderedAscending })
|
|
|
|
|
|
|
|
|
|
let actions = sortedCategories.map { category in
|
|
|
|
|
let state: UIAction.State = (category == self.category) ? .on : .off
|
|
|
|
|
return UIAction(title: category.localizedName, image: UIImage(systemName: category.symbolName), state: state) { _ in
|
|
|
|
|
handler(category)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return actions
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
Logger.main.error("Failed to fetch categories. \(error.localizedDescription, privacy: .public)")
|
|
|
|
|
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-12-07 17:54:59 -06:00
|
|
|
|
|
|
|
|
@available(iOS 15, *)
|
|
|
|
|
func prepareAppSorting()
|
|
|
|
|
{
|
|
|
|
|
if self.preferredAppSorting == .default && self.source == nil
|
|
|
|
|
{
|
|
|
|
|
// Only allow `default` sorting if source is non-nil.
|
|
|
|
|
// Otherwise, fall back to `lastUpdated` sorting.
|
|
|
|
|
self.preferredAppSorting = .lastUpdated
|
|
|
|
|
|
|
|
|
|
// Don't update UserDefaults unless explicitly changed by user.
|
|
|
|
|
// UserDefaults.shared.preferredAppSorting = .lastUpdated
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let children = UIDeferredMenuElement.uncached { [weak self] completion in
|
|
|
|
|
guard let self else { return completion([]) }
|
|
|
|
|
|
|
|
|
|
var sortingOptions = AppSorting.allCases
|
|
|
|
|
if self.source == nil
|
|
|
|
|
{
|
|
|
|
|
// Only allow `default` sorting when source is non-nil.
|
|
|
|
|
sortingOptions = sortingOptions.filter { $0 != .default }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let actions = sortingOptions.map { sorting in
|
|
|
|
|
let state: UIMenuElement.State = (sorting == self.preferredAppSorting) ? .on : .off
|
|
|
|
|
let action = UIAction(title: sorting.localizedName, image: nil, state: state) { action in
|
|
|
|
|
self.preferredAppSorting = sorting
|
|
|
|
|
UserDefaults.shared.preferredAppSorting = sorting // Update separately to save change.
|
|
|
|
|
|
|
|
|
|
self.updateDataSource()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return action
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
completion(actions)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let sortMenu = UIMenu(title: NSLocalizedString("Sort by…", comment: ""), options: [.singleSelection], children: [children])
|
|
|
|
|
let sortIcon = UIImage(systemName: "arrow.up.arrow.down")
|
|
|
|
|
|
|
|
|
|
let sortButton = UIBarButtonItem(title: NSLocalizedString("Sort by…", comment: ""), image: sortIcon, primaryAction: nil, menu: sortMenu)
|
|
|
|
|
self.sortButton = sortButton
|
|
|
|
|
|
|
|
|
|
self.navigationItem.rightBarButtonItems = [sortButton, .flexibleSpace()] // flexibleSpace() required to prevent showing full search bar inline.
|
2019-09-19 15:18:21 -07:00
|
|
|
}
|
2019-07-16 14:25:09 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension BrowseViewController
|
|
|
|
|
{
|
2019-07-19 16:42:40 -07:00
|
|
|
@IBAction func performAppAction(_ sender: PillButton)
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
2019-07-19 16:42:40 -07:00
|
|
|
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
|
|
|
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
|
|
|
|
|
2019-07-16 14:25:09 -07:00
|
|
|
let app = self.dataSource.item(at: indexPath)
|
|
|
|
|
|
2023-11-30 18:50:54 -06:00
|
|
|
if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
|
|
|
|
self.open(installedApp)
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
self.install(app, at: indexPath)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-07-31 14:07:00 -07:00
|
|
|
func install(_ app: StoreApp, at indexPath: IndexPath)
|
2019-07-16 14:25:09 -07:00
|
|
|
{
|
|
|
|
|
let previousProgress = AppManager.shared.installationProgress(for: app)
|
|
|
|
|
guard previousProgress == nil else {
|
|
|
|
|
previousProgress?.cancel()
|
|
|
|
|
return
|
|
|
|
|
}
|
2023-10-20 21:43:51 -04:00
|
|
|
|
|
|
|
|
if !minimuxer.ready() {
|
|
|
|
|
let toastView = ToastView(error: MinimuxerError.NoConnection)
|
|
|
|
|
toastView.show(in: self)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-30 18:50:54 -06:00
|
|
|
if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
|
|
|
|
{
|
|
|
|
|
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
AppManager.shared.install(app, presentingViewController: self, completionHandler: finish(_:))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UIView.performWithoutAnimation {
|
|
|
|
|
self.collectionView.reloadItems(at: [indexPath])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func finish(_ result: Result<InstalledApp, Error>)
|
|
|
|
|
{
|
2019-07-16 14:25:09 -07:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
switch result
|
|
|
|
|
{
|
|
|
|
|
case .failure(OperationError.cancelled): break // Ignore
|
|
|
|
|
case .failure(let error):
|
2024-08-06 10:43:52 +09:00
|
|
|
let toastView = ToastView(error: error, opensLog: true)
|
2020-01-24 14:14:08 -08:00
|
|
|
toastView.show(in: self)
|
2023-11-30 18:50:54 -06:00
|
|
|
|
2019-07-28 15:08:13 -07:00
|
|
|
case .success: print("Installed app:", app.bundleIdentifier)
|
2019-07-16 14:25:09 -07:00
|
|
|
}
|
|
|
|
|
|
2023-11-30 18:50:54 -06:00
|
|
|
UIView.performWithoutAnimation {
|
|
|
|
|
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
|
|
|
|
|
{
|
|
|
|
|
self.collectionView.reloadItems(at: [indexPath])
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-07-16 14:25:09 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func open(_ installedApp: InstalledApp)
|
|
|
|
|
{
|
|
|
|
|
UIApplication.shared.open(installedApp.openAppURL)
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-07-30 12:41:50 -07:00
|
|
|
|
|
|
|
|
extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
|
|
|
|
{
|
|
|
|
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
|
|
|
|
{
|
|
|
|
|
let item = self.dataSource.item(at: indexPath)
|
2023-10-19 16:11:57 -05:00
|
|
|
let itemID = item.globallyUniqueID ?? item.bundleIdentifier
|
2019-07-30 12:41:50 -07:00
|
|
|
|
2023-10-19 16:11:57 -05:00
|
|
|
if let previousSize = self.cachedItemSizes[itemID]
|
2019-07-30 12:41:50 -07:00
|
|
|
{
|
|
|
|
|
return previousSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
2023-10-19 16:11:57 -05:00
|
|
|
|
|
|
|
|
let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right)
|
2019-07-30 12:41:50 -07:00
|
|
|
|
2023-10-19 16:11:57 -05:00
|
|
|
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets)
|
2019-07-30 12:41:50 -07:00
|
|
|
widthConstraint.isActive = true
|
|
|
|
|
defer { widthConstraint.isActive = false }
|
|
|
|
|
|
2019-10-22 12:23:03 -07:00
|
|
|
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
|
|
|
|
|
self.prototypeCell.frame.size.width = widthConstraint.constant
|
2019-07-30 12:41:50 -07:00
|
|
|
self.prototypeCell.layoutIfNeeded()
|
|
|
|
|
|
|
|
|
|
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
2023-10-19 16:11:57 -05:00
|
|
|
self.cachedItemSizes[itemID] = itemSize
|
2019-07-30 12:41:50 -07:00
|
|
|
return itemSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
|
|
|
{
|
|
|
|
|
let app = self.dataSource.item(at: indexPath)
|
|
|
|
|
|
|
|
|
|
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
|
|
|
self.navigationController?.pushViewController(appViewController, animated: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
|
|
|
|
{
|
2023-03-02 15:48:33 -06:00
|
|
|
@available(iOS, deprecated: 13.0)
|
2019-07-30 12:41:50 -07:00
|
|
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
|
|
|
|
{
|
|
|
|
|
guard
|
|
|
|
|
let indexPath = self.collectionView.indexPathForItem(at: location),
|
|
|
|
|
let cell = self.collectionView.cellForItem(at: indexPath)
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
previewingContext.sourceRect = cell.frame
|
|
|
|
|
|
|
|
|
|
let app = self.dataSource.item(at: indexPath)
|
|
|
|
|
|
|
|
|
|
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
|
|
|
return appViewController
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-02 15:48:33 -06:00
|
|
|
@available(iOS, deprecated: 13.0)
|
2019-07-30 12:41:50 -07:00
|
|
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
|
|
|
|
{
|
|
|
|
|
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-10-19 16:11:57 -05:00
|
|
|
|
|
|
|
|
@available(iOS 17, *)
|
|
|
|
|
#Preview(traits: .portrait) {
|
|
|
|
|
DatabaseManager.shared.startForPreview()
|
|
|
|
|
|
|
|
|
|
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
|
|
|
|
let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
|
|
|
|
BrowseViewController(source: nil, coder: coder)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let navigationController = UINavigationController(rootViewController: browseViewController)
|
|
|
|
|
return navigationController
|
|
|
|
|
}
|