Files
SideStore/AltStore/Sources/SourcesViewController.swift

710 lines
29 KiB
Swift
Raw Normal View History

//
// 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
merge AltStore 1.6.3, add dynamic anisette lists, merge SideJITServer integration * Change error from Swift.Error to NSError * Adds ResultOperation.localizedFailure * Finish Riley's monster commit 3b38d725d7e8e45fb2c0cb465a3968828616c209 May the Gods have mercy on my soul. * Fix format strings I broke * Include "Enable JIT" errors in Error Log * Fix minimuxer status checking * [skip ci] Update the no wifi message to include VPN * Opens Error Log when tapping ToastView * Fixes Error Log context menu covering cell content * Fixes Error Log context menu appearing while scrolling * Fixes incorrect Search FAQ URL * Fix Error Log showing UIAlertController on iOS 14+ * Fix Error Log not showing UIAlertController on iOS <=13 * Fix wrong color in AuthenticationViewController * Fix typo * Fixes logging non-AltServerErrors as AltServerError.underlyingError * Limits quitting other AltStore/SideStore processes to database migrations * Skips logging cancelled errors * Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors. However, we kept the underlying Core Data property name the same to avoid extra migration. * Conforms OperatingSystemVersion to Comparable * Parses AppVersion.minOSVersion/maxOSVersion from source JSON * Supports non-NSManagedObjects for @Managed properties This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before. * Supports optional @Managed properties * Conforms AppVersion to AppProtocol * Verifies min/max OS version before downloading app + asks user to download older app version if necessary * Improves error message when file does not exist at AppVersion.downloadURL * Removes unnecessary StoreApp convenience properties * Removes unnecessary StoreApp convenience properties as well as fix other issues * Remove Settings bundle, add SwiftUI view instead Fix refresh all shortcut intent * Update AuthenticationOperation.swift Signed-off-by: June Park <rjp2030@outlook.com> * Fix build issues given by develop * Add availability check to fix CI build(?) * If it's gonna be that way... --------- Signed-off-by: June Park <rjp2030@outlook.com> Co-authored-by: nythepegasus <nythepegasus84@gmail.com> Co-authored-by: Riley Testut <riley@rileytestut.com> Co-authored-by: ny <me@nythepegas.us>
2024-08-06 10:43:52 +09:00
struct SourceError: ALTLocalizedError
{
merge AltStore 1.6.3, add dynamic anisette lists, merge SideJITServer integration * Change error from Swift.Error to NSError * Adds ResultOperation.localizedFailure * Finish Riley's monster commit 3b38d725d7e8e45fb2c0cb465a3968828616c209 May the Gods have mercy on my soul. * Fix format strings I broke * Include "Enable JIT" errors in Error Log * Fix minimuxer status checking * [skip ci] Update the no wifi message to include VPN * Opens Error Log when tapping ToastView * Fixes Error Log context menu covering cell content * Fixes Error Log context menu appearing while scrolling * Fixes incorrect Search FAQ URL * Fix Error Log showing UIAlertController on iOS 14+ * Fix Error Log not showing UIAlertController on iOS <=13 * Fix wrong color in AuthenticationViewController * Fix typo * Fixes logging non-AltServerErrors as AltServerError.underlyingError * Limits quitting other AltStore/SideStore processes to database migrations * Skips logging cancelled errors * Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors. However, we kept the underlying Core Data property name the same to avoid extra migration. * Conforms OperatingSystemVersion to Comparable * Parses AppVersion.minOSVersion/maxOSVersion from source JSON * Supports non-NSManagedObjects for @Managed properties This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before. * Supports optional @Managed properties * Conforms AppVersion to AppProtocol * Verifies min/max OS version before downloading app + asks user to download older app version if necessary * Improves error message when file does not exist at AppVersion.downloadURL * Removes unnecessary StoreApp convenience properties * Removes unnecessary StoreApp convenience properties as well as fix other issues * Remove Settings bundle, add SwiftUI view instead Fix refresh all shortcut intent * Update AuthenticationOperation.swift Signed-off-by: June Park <rjp2030@outlook.com> * Fix build issues given by develop * Add availability check to fix CI build(?) * If it's gonna be that way... --------- Signed-off-by: June Park <rjp2030@outlook.com> Co-authored-by: nythepegasus <nythepegasus84@gmail.com> Co-authored-by: Riley Testut <riley@rileytestut.com> Co-authored-by: ny <me@nythepegas.us>
2024-08-06 10:43:52 +09:00
enum Code: Int, ALTErrorCode
{
merge AltStore 1.6.3, add dynamic anisette lists, merge SideJITServer integration * Change error from Swift.Error to NSError * Adds ResultOperation.localizedFailure * Finish Riley's monster commit 3b38d725d7e8e45fb2c0cb465a3968828616c209 May the Gods have mercy on my soul. * Fix format strings I broke * Include "Enable JIT" errors in Error Log * Fix minimuxer status checking * [skip ci] Update the no wifi message to include VPN * Opens Error Log when tapping ToastView * Fixes Error Log context menu covering cell content * Fixes Error Log context menu appearing while scrolling * Fixes incorrect Search FAQ URL * Fix Error Log showing UIAlertController on iOS 14+ * Fix Error Log not showing UIAlertController on iOS <=13 * Fix wrong color in AuthenticationViewController * Fix typo * Fixes logging non-AltServerErrors as AltServerError.underlyingError * Limits quitting other AltStore/SideStore processes to database migrations * Skips logging cancelled errors * Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors. However, we kept the underlying Core Data property name the same to avoid extra migration. * Conforms OperatingSystemVersion to Comparable * Parses AppVersion.minOSVersion/maxOSVersion from source JSON * Supports non-NSManagedObjects for @Managed properties This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before. * Supports optional @Managed properties * Conforms AppVersion to AppProtocol * Verifies min/max OS version before downloading app + asks user to download older app version if necessary * Improves error message when file does not exist at AppVersion.downloadURL * Removes unnecessary StoreApp convenience properties * Removes unnecessary StoreApp convenience properties as well as fix other issues * Remove Settings bundle, add SwiftUI view instead Fix refresh all shortcut intent * Update AuthenticationOperation.swift Signed-off-by: June Park <rjp2030@outlook.com> * Fix build issues given by develop * Add availability check to fix CI build(?) * If it's gonna be that way... --------- Signed-off-by: June Park <rjp2030@outlook.com> Co-authored-by: nythepegasus <nythepegasus84@gmail.com> Co-authored-by: Riley Testut <riley@rileytestut.com> Co-authored-by: ny <me@nythepegas.us>
2024-08-06 10:43:52 +09:00
typealias Error = SourceError
case unsupported
}
var code: Code
merge AltStore 1.6.3, add dynamic anisette lists, merge SideJITServer integration * Change error from Swift.Error to NSError * Adds ResultOperation.localizedFailure * Finish Riley's monster commit 3b38d725d7e8e45fb2c0cb465a3968828616c209 May the Gods have mercy on my soul. * Fix format strings I broke * Include "Enable JIT" errors in Error Log * Fix minimuxer status checking * [skip ci] Update the no wifi message to include VPN * Opens Error Log when tapping ToastView * Fixes Error Log context menu covering cell content * Fixes Error Log context menu appearing while scrolling * Fixes incorrect Search FAQ URL * Fix Error Log showing UIAlertController on iOS 14+ * Fix Error Log not showing UIAlertController on iOS <=13 * Fix wrong color in AuthenticationViewController * Fix typo * Fixes logging non-AltServerErrors as AltServerError.underlyingError * Limits quitting other AltStore/SideStore processes to database migrations * Skips logging cancelled errors * Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors. However, we kept the underlying Core Data property name the same to avoid extra migration. * Conforms OperatingSystemVersion to Comparable * Parses AppVersion.minOSVersion/maxOSVersion from source JSON * Supports non-NSManagedObjects for @Managed properties This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before. * Supports optional @Managed properties * Conforms AppVersion to AppProtocol * Verifies min/max OS version before downloading app + asks user to download older app version if necessary * Improves error message when file does not exist at AppVersion.downloadURL * Removes unnecessary StoreApp convenience properties * Removes unnecessary StoreApp convenience properties as well as fix other issues * Remove Settings bundle, add SwiftUI view instead Fix refresh all shortcut intent * Update AuthenticationOperation.swift Signed-off-by: June Park <rjp2030@outlook.com> * Fix build issues given by develop * Add availability check to fix CI build(?) * If it's gonna be that way... --------- Signed-off-by: June Park <rjp2030@outlook.com> Co-authored-by: nythepegasus <nythepegasus84@gmail.com> Co-authored-by: Riley Testut <riley@rileytestut.com> Co-authored-by: ny <me@nythepegas.us>
2024-08-06 10:43:52 +09:00
var errorTitle: String?
var errorFailure: String?
@Managed var source: Source
merge AltStore 1.6.3, add dynamic anisette lists, merge SideJITServer integration * Change error from Swift.Error to NSError * Adds ResultOperation.localizedFailure * Finish Riley's monster commit 3b38d725d7e8e45fb2c0cb465a3968828616c209 May the Gods have mercy on my soul. * Fix format strings I broke * Include "Enable JIT" errors in Error Log * Fix minimuxer status checking * [skip ci] Update the no wifi message to include VPN * Opens Error Log when tapping ToastView * Fixes Error Log context menu covering cell content * Fixes Error Log context menu appearing while scrolling * Fixes incorrect Search FAQ URL * Fix Error Log showing UIAlertController on iOS 14+ * Fix Error Log not showing UIAlertController on iOS <=13 * Fix wrong color in AuthenticationViewController * Fix typo * Fixes logging non-AltServerErrors as AltServerError.underlyingError * Limits quitting other AltStore/SideStore processes to database migrations * Skips logging cancelled errors * Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors. However, we kept the underlying Core Data property name the same to avoid extra migration. * Conforms OperatingSystemVersion to Comparable * Parses AppVersion.minOSVersion/maxOSVersion from source JSON * Supports non-NSManagedObjects for @Managed properties This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before. * Supports optional @Managed properties * Conforms AppVersion to AppProtocol * Verifies min/max OS version before downloading app + asks user to download older app version if necessary * Improves error message when file does not exist at AppVersion.downloadURL * Removes unnecessary StoreApp convenience properties * Removes unnecessary StoreApp convenience properties as well as fix other issues * Remove Settings bundle, add SwiftUI view instead Fix refresh all shortcut intent * Update AuthenticationOperation.swift Signed-off-by: June Park <rjp2030@outlook.com> * Fix build issues given by develop * Add availability check to fix CI build(?) * If it's gonna be that way... --------- Signed-off-by: June Park <rjp2030@outlook.com> Co-authored-by: nythepegasus <nythepegasus84@gmail.com> Co-authored-by: Riley Testut <riley@rileytestut.com> Co-authored-by: ny <me@nythepegas.us>
2024-08-06 10:43:52 +09:00
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
2020-08-27 15:23:21 -07:00
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
}
@IBAction
func unwindFromAddSource(_ segue: UIStoryboardSegue)
{
}
}
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
}