diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 7949abd2..c454693f 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import Combine import minimuxer import AltStoreCore @@ -24,11 +25,7 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing private let prototypeCell = AppCardCollectionViewCell(frame: .zero) - private var loadingState: LoadingState = .loading { - didSet { - self.update() - } - } + private var cancellables = Set() init?(source: Source?, coder: NSCoder) { @@ -51,6 +48,7 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing super.viewDidLoad() self.collectionView.backgroundColor = .altBackground + self.collectionView.alwaysBounceVertical = true #if BETA self.dataSource.searchController.searchableKeyPaths = [#keyPath(InstalledApp.name)] @@ -69,6 +67,11 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing (self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView) + let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction { [weak self] _ in + self?.updateSources() + }) + self.collectionView.refreshControl = refreshControl + if let source = self.source { let tintColor = source.effectiveTintColor ?? .altPrimary @@ -85,6 +88,9 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing self.navigationItem.scrollEdgeAppearance = edgeAppearance } + + self.preparePipeline() + self.update() } @@ -92,14 +98,22 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing { super.viewWillAppear(animated) - self.fetchSource() - self.update() } } private extension BrowseViewController { + 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) + } + func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest @@ -176,75 +190,27 @@ private extension BrowseViewController self.dataSource.predicate = nil } - func fetchSource() + func updateSources() { - self.loadingState = .loading - - AppManager.shared.fetchSources() { (result) in - do + AppManager.shared.updateAllSources { result in + self.collectionView.refreshControl?.endRefreshing() + + guard case .failure(let error) = result else { return } + + if self.dataSource.itemCount > 0 { - do - { - let (_, context) = try result.get() - try context.save() - - DispatchQueue.main.async { - self.loadingState = .finished(.success(())) - } - } - catch let error as AppManager.FetchSourcesError - { - try error.managedObjectContext?.save() - throw error - } - catch let mergeError as MergeError - { - guard let sourceID = mergeError.sourceID else { throw mergeError } - - let sanitizedError = (mergeError as NSError).sanitizedForSerialization() - DatabaseManager.shared.persistentContainer.performBackgroundTask { context in - do - { - guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), sourceID), in: context) else { return } - - source.error = sanitizedError - try context.save() - } - catch - { - print("[ALTLog] Failed to assign error \(sanitizedError.localizedErrorCode) to source \(sourceID).", error) - } - } - - throw mergeError - } - } - catch var error as NSError - { - if error.localizedTitle == nil - { - error = error.withLocalizedTitle(NSLocalizedString("Unable to Refresh Store", comment: "")) - } - - DispatchQueue.main.async { - if self.dataSource.itemCount > 0 - { - let toastView = ToastView(error: error) - toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside) - toastView.show(in: self) - } - - self.loadingState = .finished(.failure(error)) - } + let toastView = ToastView(error: error) + toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside) + toastView.show(in: self) } } } func update() { - switch self.loadingState + switch AppManager.shared.updateSourcesResult { - case .loading: + case nil: self.placeholderView.textLabel.isHidden = true self.placeholderView.detailTextLabel.isHidden = false @@ -252,7 +218,7 @@ private extension BrowseViewController self.placeholderView.activityIndicatorView.startAnimating() - case .finished(.failure(let error)): + case .failure(let error): self.placeholderView.textLabel.isHidden = false self.placeholderView.detailTextLabel.isHidden = false @@ -261,8 +227,9 @@ private extension BrowseViewController self.placeholderView.activityIndicatorView.stopAnimating() - case .finished(.success): - self.placeholderView.textLabel.isHidden = true + case .success: + self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "") + self.placeholderView.textLabel.isHidden = false self.placeholderView.detailTextLabel.isHidden = true self.placeholderView.activityIndicatorView.stopAnimating() diff --git a/AltStore/LaunchViewController.swift b/AltStore/LaunchViewController.swift index 2e0791a0..e5bf85cf 100644 --- a/AltStore/LaunchViewController.swift +++ b/AltStore/LaunchViewController.swift @@ -304,6 +304,11 @@ extension LaunchViewController AppManager.shared.updatePatronsIfNeeded() PatreonAPI.shared.refreshPatreonAccount() + AppManager.shared.updateAllSources { result in + guard case .failure(let error) = result else { return } + Logger.main.error("Failed to update sources on launch. \(error.localizedDescription, privacy: .public)") + } + self.updateKnownSources() WidgetCenter.shared.reloadAllTimelines() diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 98613f64..6b6cbe29 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -41,25 +41,21 @@ final class AppManagerPublisher: ObservableObject fileprivate(set) var refreshProgress = [String: Progress]() } -final class AppManager +class AppManager: ObservableObject { static let shared = AppManager() private(set) var updatePatronsResult: Result? - + + @Published + private(set) var updateSourcesResult: Result? // nil == loading + private let operationQueue = OperationQueue() private let serialOperationQueue = OperationQueue() - private var installationProgress = [String: Progress]() { - didSet { - self.publisher.installationProgress = self.installationProgress - } - } - private var refreshProgress = [String: Progress]() { - didSet { - self.publisher.refreshProgress = self.refreshProgress - } - } + @Published private var installationProgress = [String: Progress]() + @Published private var refreshProgress = [String: Progress]() + private var cancellables: Set = [] private lazy var progressLock: UnsafeMutablePointer = { // Can't safely pass &os_unfair_lock to os_unfair_lock functions in Swift, @@ -70,9 +66,6 @@ final class AppManager return lock }() - private let publisher = AppManagerPublisher() - private var cancellables: Set = [] - private init() { self.operationQueue.name = "com.altstore.AppManager.operationQueue" @@ -95,7 +88,7 @@ final class AppManager /// Every time refreshProgress is changed, update all InstalledApps in memory /// so that app.isRefreshing == refreshProgress.keys.contains(app.bundleID) - self.publisher.$refreshProgress + self.$refreshProgress .receive(on: RunLoop.main) .map(\.keys) .flatMap { (bundleIDs) in @@ -547,6 +540,68 @@ extension AppManager self.run([updatePatronsOperation], context: nil) } + func updateAllSources(completion: @escaping (Result) -> Void) + { + self.updateSourcesResult = nil + + self.fetchSources() { (result) in + do + { + do + { + let (_, context) = try result.get() + try context.save() + + DispatchQueue.main.async { + self.updateSourcesResult = .success(()) + completion(.success(())) + } + } + catch let error as AppManager.FetchSourcesError + { + try error.managedObjectContext?.save() + throw error + } + catch let mergeError as MergeError + { + guard let sourceID = mergeError.sourceID else { throw mergeError } + + let sanitizedError = (mergeError as NSError).sanitizedForSerialization() + DatabaseManager.shared.persistentContainer.performBackgroundTask { context in + do + { + guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), sourceID), in: context) else { return } + + source.error = sanitizedError + try context.save() + } + catch + { + Logger.main.error("Failed to assign error \(sanitizedError.localizedErrorCode) to source \(sourceID, privacy: .public). \(error.localizedDescription, privacy: .public)") + } + } + + throw mergeError + } + } + catch var error as NSError + { + if error.localizedTitle == nil + { + error = error.withLocalizedTitle(NSLocalizedString("Unable to Refresh Store", comment: "")) + } + + DispatchQueue.main.async { + self.updateSourcesResult = .failure(error) + completion(.failure(error)) + } + } + } + } +} + +extension AppManager +{ @discardableResult func install(_ app: T, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result) -> Void) -> RefreshGroup { diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index 64fc43a2..791647ad 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -8,6 +8,7 @@ import UIKit import SafariServices +import Combine import AltStoreCore import Roxas @@ -48,17 +49,13 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing private lazy var dataSource = self.makeDataSource() private lazy var placeholderView = RSTPlaceholderView(frame: .zero) + private var retryButton: UIButton! private var prototypeCell: NewsCollectionViewCell! - private var loadingState: LoadingState = .loading { - didSet { - self.update() - } - } - // Cache private var cachedCellSizes = [String: CGSize]() + private var cancellables = Set() init?(source: Source?, coder: NSCoder) { @@ -108,6 +105,16 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing (self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView) + let refreshControl = UIRefreshControl(frame: .zero) + refreshControl.addTarget(self, action: #selector(NewsViewController.updateSources), for: .primaryActionTriggered) + self.collectionView.refreshControl = refreshControl + + self.retryButton = UIButton(type: .system) + self.retryButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + self.retryButton.setTitle(NSLocalizedString("Try Again", comment: ""), for: .normal) + self.retryButton.addTarget(self, action: #selector(NewsViewController.updateSources), for: .primaryActionTriggered) + self.placeholderView.stackView.addArrangedSubview(self.retryButton) + if let source = self.source { let tintColor = source.effectiveTintColor ?? .altPrimary @@ -124,16 +131,10 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing self.navigationItem.scrollEdgeAppearance = edgeAppearance } + self.preparePipeline() self.update() } - override func viewWillAppear(_ animated: Bool) - { - super.viewWillAppear(animated) - - self.fetchSource() - } - override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() @@ -149,6 +150,16 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing private extension NewsViewController { + func preparePipeline() + { + AppManager.shared.$updateSourcesResult + .receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value). + .sink { result in + self.update() + } + .store(in: &self.cancellables) + } + func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = NewsItem.sortedFetchRequest(for: self.source) @@ -225,96 +236,51 @@ private extension NewsViewController return dataSource } - - func fetchSource() + + @objc func updateSources() { - self.loadingState = .loading - - AppManager.shared.fetchSources() { (result) in - do + AppManager.shared.updateAllSources() { result in + self.collectionView.refreshControl?.endRefreshing() + + guard case .failure(let error) = result else { return } + + if self.dataSource.itemCount > 0 { - do - { - let (_, context) = try result.get() - try context.save() - - DispatchQueue.main.async { - self.loadingState = .finished(.success(())) - } - } - catch let error as AppManager.FetchSourcesError - { - try error.managedObjectContext?.save() - throw error - } - catch let mergeError as MergeError - { - guard let sourceID = mergeError.sourceID else { throw mergeError } - - let sanitizedError = (mergeError as NSError).sanitizedForSerialization() - DatabaseManager.shared.persistentContainer.performBackgroundTask { context in - do - { - guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), sourceID), in: context) else { return } - - source.error = sanitizedError - try context.save() - } - catch - { - print("[ALTLog] Failed to assign error \(sanitizedError.localizedErrorCode) to source \(sourceID).", error) - } - } - - throw mergeError - } - } - catch var error as NSError - { - if error.localizedTitle == nil - { - error = error.withLocalizedTitle(NSLocalizedString("Unable to Refresh Store", comment: "")) - } - - DispatchQueue.main.async { - if self.dataSource.itemCount > 0 - { - let toastView = ToastView(error: error) - toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside) - toastView.show(in: self) - } - - self.loadingState = .finished(.failure(error)) - } + let toastView = ToastView(error: error) + toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside) + toastView.show(in: self) } } } func update() { - switch self.loadingState + switch AppManager.shared.updateSourcesResult { - case .loading: + case nil: self.placeholderView.textLabel.isHidden = true self.placeholderView.detailTextLabel.isHidden = false self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "") + self.retryButton.isHidden = true self.placeholderView.activityIndicatorView.startAnimating() - case .finished(.failure(let error)): + case .failure(let error): self.placeholderView.textLabel.isHidden = false self.placeholderView.detailTextLabel.isHidden = false self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch News", comment: "") self.placeholderView.detailTextLabel.text = error.localizedDescription + self.retryButton.isHidden = false self.placeholderView.activityIndicatorView.stopAnimating() - case .finished(.success): + case .success: self.placeholderView.textLabel.isHidden = true self.placeholderView.detailTextLabel.isHidden = true + self.retryButton.isHidden = true self.placeholderView.activityIndicatorView.stopAnimating() } } @@ -453,7 +419,9 @@ extension NewsViewController footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered) footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:))) - Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView) + Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView) { result in + footerView.bannerView.iconImageView.isIndicatingActivity = false + } return footerView }