Signed-off-by: Joseph Mattello <mail@joemattiello.com>
This commit is contained in:
Joseph Mattello
2023-04-02 02:28:12 -04:00
parent 2c829895c9
commit c4c2d17ffc
126 changed files with 1639 additions and 124 deletions

View File

@@ -0,0 +1,47 @@
//
// ErrorLogTableViewCell.swift
// AltStore
//
// Created by Riley Testut on 9/9/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import UIKit
@objc(ErrorLogTableViewCell)
final class ErrorLogTableViewCell: UITableViewCell {
@IBOutlet var appIconImageView: AppIconImageView!
@IBOutlet var dateLabel: UILabel!
@IBOutlet var errorFailureLabel: UILabel!
@IBOutlet var errorCodeLabel: UILabel!
@IBOutlet var errorDescriptionTextView: CollapsingTextView!
@IBOutlet var menuButton: UIButton!
private var didLayoutSubviews = false
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let moreButtonFrame = convert(errorDescriptionTextView.moreButton.frame, from: errorDescriptionTextView)
guard moreButtonFrame.contains(point) else { return super.hitTest(point, with: event) }
// Pass touches through menuButton so user can press moreButton.
return errorDescriptionTextView.moreButton
}
override func layoutSubviews() {
super.layoutSubviews()
didLayoutSubviews = true
}
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
if !didLayoutSubviews {
// Ensure cell is laid out so it will report correct size.
layoutIfNeeded()
}
let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
return size
}
}

View File

@@ -0,0 +1,287 @@
//
// ErrorLogViewController.swift
// AltStore
//
// Created by Riley Testut on 9/6/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import SafariServices
import UIKit
import SideStoreCore
import RoxasUIKit
import SideKit
import Nuke
import QuickLook
import OSLog
#if canImport(Logging)
import Logging
#endif
final class ErrorLogViewController: UITableViewController {
private lazy var dataSource = self.makeDataSource()
private var expandedErrorIDs = Set<NSManagedObjectID>()
private lazy var timeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .short
return dateFormatter
}()
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = dataSource
tableView.prefetchDataSource = dataSource
}
}
private extension ErrorLogViewController {
func makeDataSource() -> RSTFetchedResultsTableViewPrefetchingDataSource<LoggedError, UIImage> {
let fetchRequest = LoggedError.fetchRequest() as NSFetchRequest<LoggedError>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LoggedError.date, ascending: false)]
fetchRequest.returnsObjectsAsFaults = false
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(LoggedError.localizedDateString), cacheName: nil)
let dataSource = RSTFetchedResultsTableViewPrefetchingDataSource<LoggedError, UIImage>(fetchedResultsController: fetchedResultsController)
dataSource.proxy = self
dataSource.rowAnimation = .fade
dataSource.cellConfigurationHandler = { [weak self] cell, loggedError, _ in
guard let self else { return }
let cell = cell as! ErrorLogTableViewCell
cell.dateLabel.text = self.timeFormatter.string(from: loggedError.date)
cell.errorFailureLabel.text = loggedError.localizedFailure ?? NSLocalizedString("Operation Failed", comment: "")
switch loggedError.domain {
case AltServerErrorDomain: cell.errorCodeLabel?.text = String(format: NSLocalizedString("AltServer Error %@", comment: ""), NSNumber(value: loggedError.code))
case OperationError.domain: cell.errorCodeLabel?.text = String(format: NSLocalizedString("AltStore Error %@", comment: ""), NSNumber(value: loggedError.code))
default: cell.errorCodeLabel?.text = loggedError.error.localizedErrorCode
}
let nsError = loggedError.error as NSError
let errorDescription = [nsError.localizedDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
cell.errorDescriptionTextView.text = errorDescription
cell.errorDescriptionTextView.maximumNumberOfLines = 5
cell.errorDescriptionTextView.isCollapsed = !self.expandedErrorIDs.contains(loggedError.objectID)
cell.errorDescriptionTextView.moreButton.addTarget(self, action: #selector(ErrorLogViewController.toggleCollapsingCell(_:)), for: .primaryActionTriggered)
cell.appIconImageView.image = nil
cell.appIconImageView.isIndicatingActivity = true
cell.appIconImageView.layer.borderColor = UIColor.gray.cgColor
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale // 0.0 == "unspecified"
cell.appIconImageView.layer.borderWidth = 1.0 / displayScale
if #available(iOS 14, *) {
let menu = UIMenu(title: "", children: [
UIAction(title: NSLocalizedString("Copy Error Message", comment: ""), image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.copyErrorMessage(for: loggedError)
},
UIAction(title: NSLocalizedString("Copy Error Code", comment: ""), image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in
self?.copyErrorCode(for: loggedError)
},
UIAction(title: NSLocalizedString("Search FAQ", comment: ""), image: UIImage(systemName: "magnifyingglass")) { [weak self] _ in
self?.searchFAQ(for: loggedError)
}
])
cell.menuButton.menu = menu
}
// Include errorDescriptionTextView's text in cell summary.
cell.accessibilityLabel = [cell.errorFailureLabel.text, cell.dateLabel.text, cell.errorCodeLabel.text, cell.errorDescriptionTextView.text].compactMap { $0 }.joined(separator: ". ")
// Group all paragraphs together into single accessibility element (otherwise, each paragraph is independently selectable).
cell.errorDescriptionTextView.accessibilityLabel = cell.errorDescriptionTextView.text
}
dataSource.prefetchHandler = { loggedError, _, completion in
RSTAsyncBlockOperation { operation in
loggedError.managedObjectContext?.perform {
if let installedApp = loggedError.installedApp {
installedApp.loadIcon { result in
switch result {
case let .failure(error): completion(nil, error)
case let .success(image): completion(image, nil)
}
}
} else if let storeApp = loggedError.storeApp {
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { response, error in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image {
completion(image, nil)
} else {
completion(nil, error)
}
}
} else {
completion(nil, nil)
}
}
}
}
dataSource.prefetchCompletionHandler = { cell, image, _, _ in
let cell = cell as! ErrorLogTableViewCell
cell.appIconImageView.image = image
cell.appIconImageView.isIndicatingActivity = false
}
let placeholderView = RSTPlaceholderView()
placeholderView.textLabel.text = NSLocalizedString("No Errors", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("Errors that occur when sideloading or refreshing apps will appear here.", comment: "")
dataSource.placeholderView = placeholderView
return dataSource
}
}
private extension ErrorLogViewController {
@IBAction func toggleCollapsingCell(_ sender: UIButton) {
let point = tableView.convert(sender.center, from: sender.superview)
guard let indexPath = tableView.indexPathForRow(at: point), let cell = tableView.cellForRow(at: indexPath) as? ErrorLogTableViewCell else { return }
let loggedError = dataSource.item(at: indexPath)
if cell.errorDescriptionTextView.isCollapsed {
expandedErrorIDs.remove(loggedError.objectID)
} else {
expandedErrorIDs.insert(loggedError.objectID)
}
tableView.performBatchUpdates {
cell.layoutIfNeeded()
}
}
@IBAction func showMinimuxerLogs(_: UIBarButtonItem) {
// Show minimuxer.log
let previewController = QLPreviewController()
previewController.dataSource = self
let navigationController = UINavigationController(rootViewController: previewController)
present(navigationController, animated: true, completion: nil)
}
@IBAction func clearLoggedErrors(_ sender: UIBarButtonItem) {
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to clear the error log?", comment: ""), message: nil, preferredStyle: .actionSheet)
alertController.popoverPresentationController?.barButtonItem = sender
alertController.addAction(.cancel)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Clear Error Log", comment: ""), style: .destructive) { _ in
self.clearLoggedErrors()
})
present(alertController, animated: true)
}
func clearLoggedErrors() {
DatabaseManager.shared.purgeLoggedErrors { result in
do {
try result.get()
} catch {
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Failed to Clear Error Log", comment: ""), message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(.ok)
self.present(alertController, animated: true)
}
}
}
}
func copyErrorMessage(for loggedError: LoggedError) {
let nsError = loggedError.error as NSError
let errorMessage = [nsError.localizedDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
UIPasteboard.general.string = errorMessage
}
func copyErrorCode(for loggedError: LoggedError) {
let errorCode = loggedError.error.localizedErrorCode
UIPasteboard.general.string = errorCode
}
func searchFAQ(for loggedError: LoggedError) {
let baseURL = URL(string: "https://faq.altstore.io/getting-started/troubleshooting-guide")!
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
let query = [loggedError.domain, "\(loggedError.code)"].joined(separator: "+")
components.queryItems = [URLQueryItem(name: "q", value: query)]
let safariViewController = SFSafariViewController(url: components.url ?? baseURL)
safariViewController.preferredControlTintColor = .altPrimary
present(safariViewController, animated: true)
}
}
extension ErrorLogViewController {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let loggedError = dataSource.item(at: indexPath)
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Copy Error Message", comment: ""), style: .default) { [weak self] _ in
self?.copyErrorMessage(for: loggedError)
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Copy Error Code", comment: ""), style: .default) { [weak self] _ in
self?.copyErrorCode(for: loggedError)
tableView.deselectRow(at: indexPath, animated: true)
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Search FAQ", comment: ""), style: .default) { [weak self] _ in
self?.searchFAQ(for: loggedError)
tableView.deselectRow(at: indexPath, animated: true)
})
present(alertController, animated: true)
}
override func tableView(_: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "")) { _, _, completion in
let loggedError = self.dataSource.item(at: indexPath)
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
do {
let loggedError = context.object(with: loggedError.objectID) as! LoggedError
context.delete(loggedError)
try context.save()
completion(true)
} catch {
os_log("[ALTLog] Failed to delete LoggedError %@: %@", type: .error , loggedError.objectID, error.localizedDescription)
completion(false)
}
}
}
let configuration = UISwipeActionsConfiguration(actions: [deleteAction])
configuration.performsFirstActionWithFullSwipe = false
return configuration
}
override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? {
let indexPath = IndexPath(row: 0, section: section)
let loggedError = dataSource.item(at: indexPath)
if Calendar.current.isDateInToday(loggedError.date) {
return NSLocalizedString("Today", comment: "")
} else {
return loggedError.localizedDateString
}
}
}
extension ErrorLogViewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in _: QLPreviewController) -> Int {
1
}
func previewController(_: QLPreviewController, previewItemAt _: Int) -> QLPreviewItem {
let fileURL = FileManager.default.documentsDirectory.appendingPathComponent("minimuxer.log")
return fileURL as QLPreviewItem
}
}

View File

@@ -0,0 +1,114 @@
//
// InsetGroupTableViewCell.swift
// AltStore
//
// Created by Riley Testut on 8/31/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
extension InsetGroupTableViewCell {
@objc enum Style: Int {
case single
case top
case middle
case bottom
}
}
final class InsetGroupTableViewCell: UITableViewCell {
#if !TARGET_INTERFACE_BUILDER
@IBInspectable var style: Style = .single {
didSet {
self.update()
}
}
#else
@IBInspectable var style: Int = 0
#endif
@IBInspectable var isSelectable: Bool = false
private let separatorView = UIView()
private let insetView = UIView()
override func awakeFromNib() {
super.awakeFromNib()
selectionStyle = .none
separatorView.translatesAutoresizingMaskIntoConstraints = false
separatorView.backgroundColor = UIColor.white.withAlphaComponent(0.25)
addSubview(separatorView)
insetView.layer.masksToBounds = true
insetView.layer.cornerRadius = 16
// Get the preferred background color from Interface Builder.
insetView.backgroundColor = backgroundColor
backgroundColor = nil
addSubview(insetView, pinningEdgesWith: UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15))
sendSubviewToBack(insetView)
NSLayoutConstraint.activate([separatorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 30),
separatorView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -30),
separatorView.bottomAnchor.constraint(equalTo: bottomAnchor),
separatorView.heightAnchor.constraint(equalToConstant: 1)])
update()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if animated {
UIView.animate(withDuration: 0.4) {
self.update()
}
} else {
update()
}
}
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
if animated {
UIView.animate(withDuration: 0.4) {
self.update()
}
} else {
update()
}
}
}
private extension InsetGroupTableViewCell {
func update() {
switch style {
case .single:
insetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
separatorView.isHidden = true
case .top:
insetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
separatorView.isHidden = false
case .middle:
insetView.layer.maskedCorners = []
separatorView.isHidden = false
case .bottom:
insetView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
separatorView.isHidden = true
}
if isSelectable && (isHighlighted || isSelected) {
insetView.backgroundColor = UIColor.white.withAlphaComponent(0.55)
} else {
insetView.backgroundColor = UIColor.white.withAlphaComponent(0.25)
}
}
}

View File

@@ -0,0 +1,48 @@
//
// LicensesViewController.swift
// AltStore
//
// Created by Riley Testut on 9/6/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
final class LicensesViewController: UIViewController {
private var _didAppear = false
@IBOutlet private var textView: UITextView!
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
view.setNeedsLayout()
view.layoutIfNeeded()
// Fix incorrect initial offset on iPhone SE.
textView.contentOffset.y = 0
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
_didAppear = true
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
textView.textContainerInset.left = view.layoutMargins.left
textView.textContainerInset.right = view.layoutMargins.right
textView.textContainer.lineFragmentPadding = 0
if !_didAppear {
// Fix incorrect initial offset on iPhone SE.
textView.contentOffset.y = 0
}
}
}

View File

@@ -0,0 +1,88 @@
//
// PatreonComponents.swift
// AltStore
//
// Created by Riley Testut on 9/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
@objc
final class PatronCollectionViewCell: UICollectionViewCell {
@IBOutlet var textLabel: UILabel!
}
final class PatronsHeaderView: UICollectionReusableView {
let textLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
textLabel.font = UIFont.boldSystemFont(ofSize: 17)
textLabel.textColor = .white
addSubview(textLabel, pinningEdgesWith: UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20))
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
final class PatronsFooterView: UICollectionReusableView {
let button = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
button.translatesAutoresizingMaskIntoConstraints = false
button.activityIndicatorView.style = .medium
button.titleLabel?.textColor = .white
addSubview(button)
NSLayoutConstraint.activate([button.centerXAnchor.constraint(equalTo: centerXAnchor),
button.centerYAnchor.constraint(equalTo: centerYAnchor)])
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
final class AboutPatreonHeaderView: UICollectionReusableView {
@IBOutlet var supportButton: UIButton!
@IBOutlet var accountButton: UIButton!
@IBOutlet var textView: UITextView!
@IBOutlet private var rileyLabel: UILabel!
@IBOutlet private var shaneLabel: UILabel!
@IBOutlet private var rileyImageView: UIImageView!
@IBOutlet private var shaneImageView: UIImageView!
override func awakeFromNib() {
super.awakeFromNib()
textView.clipsToBounds = true
textView.layer.cornerRadius = 20
textView.textContainer.lineFragmentPadding = 0
for imageView in [rileyImageView, shaneImageView].compactMap({ $0 }) {
imageView.clipsToBounds = true
imageView.layer.cornerRadius = imageView.bounds.midY
}
for button in [supportButton, accountButton].compactMap({ $0 }) {
button.clipsToBounds = true
button.layer.cornerRadius = 16
}
}
override func layoutMarginsDidChange() {
super.layoutMarginsDidChange()
textView.textContainerInset = UIEdgeInsets(top: layoutMargins.left, left: layoutMargins.left, bottom: layoutMargins.right, right: layoutMargins.right)
}
}

View File

@@ -0,0 +1,288 @@
//
// PatreonViewController.swift
// AltStore
//
// Created by Riley Testut on 9/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import AuthenticationServices
import SafariServices
import UIKit
import SideStoreCore
import RoxasUIKit
extension PatreonViewController {
private enum Section: Int, CaseIterable {
case about
case patrons
}
}
final class PatreonViewController: UICollectionViewController {
private lazy var dataSource = self.makeDataSource()
private lazy var patronsDataSource = self.makePatronsDataSource()
private var prototypeAboutHeader: AboutPatreonHeaderView!
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
override func viewDidLoad() {
super.viewDidLoad()
let aboutHeaderNib = UINib(nibName: "AboutPatreonHeaderView", bundle: Bundle(for: PatronsHeaderView.self))
prototypeAboutHeader = aboutHeaderNib.instantiate(withOwner: nil, options: nil)[0] as? AboutPatreonHeaderView
collectionView.dataSource = dataSource
collectionView.register(aboutHeaderNib, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "AboutHeader")
collectionView.register(PatronsHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "PatronsHeader")
// self.collectionView.register(PatronsFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "PatronsFooter")
// NotificationCenter.default.addObserver(self, selector: #selector(PatreonViewController.didUpdatePatrons(_:)), name: AppManager.didUpdatePatronsNotification, object: nil)
update()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// self.fetchPatrons()
update()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let layout = collectionViewLayout as! UICollectionViewFlowLayout
var itemWidth = (collectionView.bounds.width - (layout.sectionInset.left + layout.sectionInset.right + layout.minimumInteritemSpacing)) / 2
itemWidth.round(.down)
// TODO: if the intention here is to hide the cells, we should just modify the data source. @JoeMatt
layout.itemSize = CGSize(width: 0, height: 0)
}
}
private extension PatreonViewController {
func makeDataSource() -> RSTCompositeCollectionViewDataSource<ManagedPatron> {
let aboutDataSource = RSTDynamicCollectionViewDataSource<ManagedPatron>()
aboutDataSource.numberOfSectionsHandler = { 1 }
aboutDataSource.numberOfItemsHandler = { _ in 0 }
let dataSource = RSTCompositeCollectionViewDataSource<ManagedPatron>(dataSources: [aboutDataSource, patronsDataSource])
dataSource.proxy = self
return dataSource
}
func makePatronsDataSource() -> RSTFetchedResultsCollectionViewDataSource<ManagedPatron> {
let fetchRequest: NSFetchRequest<ManagedPatron> = ManagedPatron.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(ManagedPatron.name), ascending: true, selector: #selector(NSString.caseInsensitiveCompare(_:)))]
let patronsDataSource = RSTFetchedResultsCollectionViewDataSource<ManagedPatron>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
patronsDataSource.cellConfigurationHandler = { cell, patron, _ in
let cell = cell as! PatronCollectionViewCell
cell.textLabel.text = patron.name
}
return patronsDataSource
}
func update() {
collectionView.reloadData()
}
func prepare(_ headerView: AboutPatreonHeaderView) {
headerView.layoutMargins = view.layoutMargins
headerView.supportButton.addTarget(self, action: #selector(PatreonViewController.openPatreonURL(_:)), for: .primaryActionTriggered)
let defaultSupportButtonTitle = NSLocalizedString("Become a patron", comment: "")
let isPatronSupportButtonTitle = NSLocalizedString("View Patreon", comment: "")
let defaultText = NSLocalizedString("""
Hello, thank you for using SideStore!
If you would subscribe to the patreon that would support us and make sure we can continue developing SideStore for you.
-SideTeam
""", comment: "")
let isPatronText = NSLocalizedString("""
Hey ,
Youre the best. Your account was linked successfully, so you now have access to the beta versions of all of our apps. You can find them all in the Browse tab.
Thanks for all of your support. Enjoy!
- SideTeam
""", comment: "")
if let account = DatabaseManager.shared.patreonAccount(), PatreonAPI.shared.isAuthenticated {
headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered)
headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal)
if account.isPatron {
headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal)
let font = UIFont.systemFont(ofSize: 16)
let attributedText = NSMutableAttributedString(string: isPatronText, attributes: [.font: font,
.foregroundColor: UIColor.white])
let boldedName = NSAttributedString(string: account.firstName ?? account.name,
attributes: [.font: UIFont.boldSystemFont(ofSize: font.pointSize),
.foregroundColor: UIColor.white])
attributedText.insert(boldedName, at: 4)
headerView.textView.attributedText = attributedText
} else {
headerView.supportButton.setTitle(defaultSupportButtonTitle, for: .normal)
headerView.textView.text = defaultText
}
}
}
}
private extension PatreonViewController {
@objc func fetchPatrons() {
AppManager.shared.updatePatronsIfNeeded()
update()
}
@objc func openPatreonURL(_: UIButton) {
let patreonURL = URL(string: "https://www.patreon.com/SideStore")!
let safariViewController = SFSafariViewController(url: patreonURL)
safariViewController.preferredControlTintColor = view.tintColor
present(safariViewController, animated: true, completion: nil)
}
@IBAction func authenticate(_: UIBarButtonItem) {
PatreonAPI.shared.authenticate { result in
do {
let account = try result.get()
try account.managedObjectContext?.save()
DispatchQueue.main.async {
self.update()
}
} catch ASWebAuthenticationSessionError.canceledLogin {
// Ignore
} catch {
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
}
}
@IBAction func signOut(_: UIBarButtonItem) {
func signOut() {
PatreonAPI.shared.signOut { result in
do {
try result.get()
DispatchQueue.main.async {
self.update()
}
} catch {
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
}
}
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to unlink your Patreon account?", comment: ""), message: NSLocalizedString("You will no longer have access to beta versions of apps.", comment: ""), preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Unlink Patreon Account", comment: ""), style: .destructive) { _ in signOut() })
alertController.addAction(.cancel)
present(alertController, animated: true, completion: nil)
}
@objc func didUpdatePatrons(_: Notification) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// Wait short delay before reloading or else footer won't properly update if it's already visible 🤷
self.collectionView.reloadData()
}
}
}
extension PatreonViewController {
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let section = Section.allCases[indexPath.section]
switch section {
case .about:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "AboutHeader", for: indexPath) as! AboutPatreonHeaderView
prepare(headerView)
return headerView
case .patrons:
if kind == UICollectionView.elementKindSectionHeader {
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "PatronsHeader", for: indexPath) as! PatronsHeaderView
headerView.textLabel.text = NSLocalizedString("Special thanks to...", comment: "")
return headerView
} else {
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "PatronsFooter", for: indexPath) as! PatronsFooterView
footerView.button.isIndicatingActivity = false
footerView.button.isHidden = false
// footerView.button.addTarget(self, action: #selector(PatreonViewController.fetchPatrons), for: .primaryActionTriggered)
switch AppManager.shared.updatePatronsResult {
case .none: footerView.button.isIndicatingActivity = true
case .success?: footerView.button.isHidden = true
case .failure?:
#if DEBUG
let debug = true
#else
let debug = false
#endif
if patronsDataSource.itemCount == 0 || debug {
// Only show error message if there aren't any cached Patrons (or if this is a debug build).
footerView.button.isHidden = false
footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal)
} else {
footerView.button.isHidden = true
}
}
return footerView
}
}
}
}
extension PatreonViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
let section = Section.allCases[section]
switch section {
case .about:
let widthConstraint = prototypeAboutHeader.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
prepare(prototypeAboutHeader)
let size = prototypeAboutHeader.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return size
case .patrons:
return CGSize(width: 0, height: 0)
}
}
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
let section = Section.allCases[section]
switch section {
case .about: return .zero
case .patrons: return CGSize(width: 0, height: 0)
}
}
}

View File

@@ -0,0 +1,66 @@
//
// RefreshAttemptsViewController.swift
// AltStore
//
// Created by Riley Testut on 7/31/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import SideStoreCore
import RoxasUIKit
@objc(RefreshAttemptTableViewCell)
private final class RefreshAttemptTableViewCell: UITableViewCell {
@IBOutlet var successLabel: UILabel!
@IBOutlet var dateLabel: UILabel!
@IBOutlet var errorDescriptionLabel: UILabel!
}
final class RefreshAttemptsViewController: UITableViewController {
private lazy var dataSource = self.makeDataSource()
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
return dateFormatter
}()
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = dataSource
}
}
private extension RefreshAttemptsViewController {
func makeDataSource() -> RSTFetchedResultsTableViewDataSource<RefreshAttempt> {
let fetchRequest = RefreshAttempt.fetchRequest() as NSFetchRequest<RefreshAttempt>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \RefreshAttempt.date, ascending: false)]
fetchRequest.returnsObjectsAsFaults = false
let dataSource = RSTFetchedResultsTableViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellConfigurationHandler = { [weak self] cell, attempt, _ in
let cell = cell as! RefreshAttemptTableViewCell
cell.dateLabel.text = self?.dateFormatter.string(from: attempt.date)
cell.errorDescriptionLabel.text = attempt.errorDescription
if attempt.isSuccess {
cell.successLabel.text = NSLocalizedString("Success", comment: "")
cell.successLabel.textColor = .refreshGreen
} else {
cell.successLabel.text = NSLocalizedString("Failure", comment: "")
cell.successLabel.textColor = .refreshRed
}
}
let placeholderView = RSTPlaceholderView()
placeholderView.textLabel.text = NSLocalizedString("No Refresh Attempts", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("The more you use SideStore, the more often iOS will allow it to refresh apps in the background.", comment: "")
dataSource.placeholderView = placeholderView
return dataSource
}
}

View File

@@ -0,0 +1,34 @@
//
// SettingsHeaderFooterView.swift
// AltStore
//
// Created by Riley Testut on 8/31/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import RoxasUIKit
final class SettingsHeaderFooterView: UITableViewHeaderFooterView {
@IBOutlet var primaryLabel: UILabel!
@IBOutlet var secondaryLabel: UILabel!
@IBOutlet var button: UIButton!
@IBOutlet private var stackView: UIStackView!
override func awakeFromNib() {
super.awakeFromNib()
contentView.layoutMargins = .zero
contentView.preservesSuperviewLayoutMargins = true
stackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stackView)
NSLayoutConstraint.activate([stackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
stackView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
stackView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor)])
}
}

View File

@@ -0,0 +1,510 @@
//
// SettingsViewController.swift
// AltStore
//
// Created by Riley Testut on 8/31/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Intents
import IntentsUI
import MessageUI
import SafariServices
import UIKit
import SideStoreCore
private extension SettingsViewController {
enum Section: Int, CaseIterable {
case signIn
case account
case patreon
case appRefresh
case instructions
case credits
case debug
}
enum AppRefreshRow: Int, CaseIterable {
case backgroundRefresh
@available(iOS 14, *)
case addToSiri
static var allCases: [AppRefreshRow] {
guard #available(iOS 14, *) else { return [.backgroundRefresh] }
return [.backgroundRefresh, .addToSiri]
}
}
enum CreditsRow: Int, CaseIterable {
case developer
case operations
case designer
case softwareLicenses
}
enum DebugRow: Int, CaseIterable {
case sendFeedback
case refreshAttempts
case errorLog
case resetPairingFile
case advancedSettings
}
}
final class SettingsViewController: UITableViewController {
private var activeTeam: Team?
private var prototypeHeaderFooterView: SettingsHeaderFooterView!
private var debugGestureCounter = 0
private weak var debugGestureTimer: Timer?
@IBOutlet private var accountNameLabel: UILabel!
@IBOutlet private var accountEmailLabel: UILabel!
@IBOutlet private var accountTypeLabel: UILabel!
@IBOutlet private var backgroundRefreshSwitch: UISwitch!
@IBOutlet private var versionLabel: UILabel!
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
NotificationCenter.default.addObserver(self, selector: #selector(SettingsViewController.openPatreonSettings(_:)), name: SideStoreAppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
let nib = UINib(nibName: "SettingsHeaderFooterView", bundle: Bundle(for: SettingsHeaderFooterView.self))
prototypeHeaderFooterView = nib.instantiate(withOwner: nil, options: nil)[0] as? SettingsHeaderFooterView
tableView.register(nib, forHeaderFooterViewReuseIdentifier: "HeaderFooterView")
let debugModeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(SettingsViewController.handleDebugModeGesture(_:)))
debugModeGestureRecognizer.delegate = self
debugModeGestureRecognizer.direction = .up
debugModeGestureRecognizer.numberOfTouchesRequired = 3
tableView.addGestureRecognizer(debugModeGestureRecognizer)
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
versionLabel.text = NSLocalizedString(String(format: "SideStore %@", version), comment: "SideStore Version")
} else {
versionLabel.text = NSLocalizedString("SideStore", comment: "")
}
tableView.contentInset.bottom = 20
update()
if #available(iOS 15, *), let appearance = tabBarController?.tabBar.standardAppearance {
appearance.stackedLayoutAppearance.normal.badgeBackgroundColor = .altPrimary
self.navigationController?.tabBarItem.scrollEdgeAppearance = appearance
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
update()
}
}
private extension SettingsViewController {
func update() {
if let team = DatabaseManager.shared.activeTeam() {
accountNameLabel.text = team.name
accountEmailLabel.text = team.account.appleID
accountTypeLabel.text = team.type.localizedDescription
activeTeam = team
} else {
activeTeam = nil
}
backgroundRefreshSwitch.isOn = UserDefaults.standard.isBackgroundRefreshEnabled
if isViewLoaded {
tableView.reloadData()
}
}
func prepare(_ settingsHeaderFooterView: SettingsHeaderFooterView, for section: Section, isHeader: Bool) {
settingsHeaderFooterView.primaryLabel.isHidden = !isHeader
settingsHeaderFooterView.secondaryLabel.isHidden = isHeader
settingsHeaderFooterView.button.isHidden = true
settingsHeaderFooterView.layoutMargins.bottom = isHeader ? 0 : 8
switch section {
case .signIn:
if isHeader {
settingsHeaderFooterView.primaryLabel.text = NSLocalizedString("ACCOUNT", comment: "")
} else {
settingsHeaderFooterView.secondaryLabel.text = NSLocalizedString("Sign in with your Apple ID to download apps from SideStore.", comment: "")
}
case .patreon:
if isHeader {
settingsHeaderFooterView.primaryLabel.text = NSLocalizedString("PATREON", comment: "")
} else {
settingsHeaderFooterView.secondaryLabel.text = NSLocalizedString("Support the SideStore Team by becoming a patron!", comment: "")
}
case .account:
settingsHeaderFooterView.primaryLabel.text = NSLocalizedString("ACCOUNT", comment: "")
settingsHeaderFooterView.button.setTitle(NSLocalizedString("SIGN OUT", comment: ""), for: .normal)
settingsHeaderFooterView.button.addTarget(self, action: #selector(SettingsViewController.signOut(_:)), for: .primaryActionTriggered)
settingsHeaderFooterView.button.isHidden = false
case .appRefresh:
if isHeader {
settingsHeaderFooterView.primaryLabel.text = NSLocalizedString("REFRESHING APPS", comment: "")
} else {
settingsHeaderFooterView.secondaryLabel.text = NSLocalizedString("Enable Background Refresh to automatically refresh apps in the background when connected to Wi-Fi.", comment: "")
}
case .instructions:
break
case .credits:
settingsHeaderFooterView.primaryLabel.text = NSLocalizedString("CREDITS", comment: "")
case .debug:
settingsHeaderFooterView.primaryLabel.text = NSLocalizedString("DEBUG", comment: "")
}
}
func preferredHeight(for settingsHeaderFooterView: SettingsHeaderFooterView, in section: Section, isHeader: Bool) -> CGFloat {
let widthConstraint = settingsHeaderFooterView.contentView.widthAnchor.constraint(equalToConstant: tableView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
prepare(settingsHeaderFooterView, for: section, isHeader: isHeader)
let size = settingsHeaderFooterView.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return size.height
}
}
private extension SettingsViewController {
func signIn() {
AppManager.shared.authenticate(presentingViewController: self) { result in
DispatchQueue.main.async {
switch result {
case .failure(OperationError.cancelled):
// Ignore
break
case let .failure(error):
let toastView = ToastView(error: error)
toastView.show(in: self)
case .success: break
}
self.update()
}
}
}
@objc func signOut(_ sender: UIBarButtonItem) {
func signOut() {
DatabaseManager.shared.signOut { error in
DispatchQueue.main.async {
if let error = error {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
self.update()
}
}
}
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to sign out?", comment: ""), message: NSLocalizedString("You will no longer be able to install or refresh apps once you sign out.", comment: ""), preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Sign Out", comment: ""), style: .destructive) { _ in signOut() })
alertController.addAction(.cancel)
// Fix crash on iPad
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true, completion: nil)
}
@IBAction func toggleIsBackgroundRefreshEnabled(_ sender: UISwitch) {
UserDefaults.standard.isBackgroundRefreshEnabled = sender.isOn
}
@available(iOS 14, *)
@IBAction func addRefreshAppsShortcut() {
guard let shortcut = INShortcut(intent: INInteraction.refreshAllApps().intent) else { return }
let viewController = INUIAddVoiceShortcutViewController(shortcut: shortcut)
viewController.delegate = self
viewController.modalPresentationStyle = .formSheet
present(viewController, animated: true, completion: nil)
}
@IBAction func handleDebugModeGesture(_: UISwipeGestureRecognizer) {
debugGestureCounter += 1
debugGestureTimer?.invalidate()
if debugGestureCounter >= 3 {
debugGestureCounter = 0
UserDefaults.standard.isDebugModeEnabled.toggle()
tableView.reloadData()
} else {
debugGestureTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: false) { [weak self] _ in
self?.debugGestureCounter = 0
}
}
}
func openTwitter(username: String) {
let twitterAppURL = URL(string: "twitter://user?screen_name=" + username)!
UIApplication.shared.open(twitterAppURL, options: [:]) { success in
if success {
if let selectedIndexPath = self.tableView.indexPathForSelectedRow {
self.tableView.deselectRow(at: selectedIndexPath, animated: true)
}
} else {
let safariURL = URL(string: "https://twitter.com/" + username)!
let safariViewController = SFSafariViewController(url: safariURL)
safariViewController.preferredControlTintColor = .altPrimary
self.present(safariViewController, animated: true, completion: nil)
}
}
}
}
private extension SettingsViewController {
@objc func openPatreonSettings(_: Notification) {
guard presentedViewController == nil else { return }
UIView.performWithoutAnimation {
self.navigationController?.popViewController(animated: false)
self.performSegue(withIdentifier: "showPatreon", sender: nil)
}
}
}
extension SettingsViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
var numberOfSections = super.numberOfSections(in: tableView)
if !UserDefaults.standard.isDebugModeEnabled {
numberOfSections -= 1
}
return numberOfSections
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section = Section.allCases[section]
switch section {
case .signIn: return (activeTeam == nil) ? 1 : 0
case .account: return (activeTeam == nil) ? 0 : 3
case .appRefresh: return AppRefreshRow.allCases.count
default: return super.tableView(tableView, numberOfRowsInSection: section.rawValue)
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath)
if #available(iOS 14, *) {} else if let cell = cell as? InsetGroupTableViewCell,
indexPath.section == Section.appRefresh.rawValue,
indexPath.row == AppRefreshRow.backgroundRefresh.rawValue {
// Only one row is visible pre-iOS 14.
cell.style = .single
}
return cell
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section = Section.allCases[section]
switch section {
case .signIn where activeTeam != nil: return nil
case .account where activeTeam == nil: return nil
case .signIn, .account, .patreon, .appRefresh, .credits, .debug:
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "HeaderFooterView") as! SettingsHeaderFooterView
prepare(headerView, for: section, isHeader: true)
return headerView
case .instructions: return nil
}
}
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let section = Section.allCases[section]
switch section {
case .signIn where activeTeam != nil: return nil
case .signIn, .patreon, .appRefresh:
let footerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "HeaderFooterView") as! SettingsHeaderFooterView
prepare(footerView, for: section, isHeader: false)
return footerView
case .account, .credits, .debug, .instructions: return nil
}
}
override func tableView(_: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section = Section.allCases[section]
switch section {
case .signIn where activeTeam != nil: return 1.0
case .account where activeTeam == nil: return 1.0
case .signIn, .account, .patreon, .appRefresh, .credits, .debug:
let height = preferredHeight(for: prototypeHeaderFooterView, in: section, isHeader: true)
return height
case .instructions: return 0.0
}
}
override func tableView(_: UITableView, heightForFooterInSection section: Int) -> CGFloat {
let section = Section.allCases[section]
switch section {
case .signIn where activeTeam != nil: return 1.0
case .account where activeTeam == nil: return 1.0
case .signIn, .patreon, .appRefresh:
let height = preferredHeight(for: prototypeHeaderFooterView, in: section, isHeader: false)
return height
case .account, .credits, .debug, .instructions: return 0.0
}
}
}
extension SettingsViewController {
override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
let section = Section.allCases[indexPath.section]
switch section {
case .signIn: signIn()
case .instructions: break
case .appRefresh:
let row = AppRefreshRow.allCases[indexPath.row]
switch row {
case .backgroundRefresh: break
case .addToSiri:
guard #available(iOS 14, *) else { return }
addRefreshAppsShortcut()
}
case .credits:
let row = CreditsRow.allCases[indexPath.row]
switch row {
case .developer: openTwitter(username: "sidestore_io")
case .operations: openTwitter(username: "sidestore_io")
case .designer: openTwitter(username: "lit_ritt")
case .softwareLicenses: break
}
case .debug:
let row = DebugRow.allCases[indexPath.row]
switch row {
case .sendFeedback:
if MFMailComposeViewController.canSendMail() {
let mailViewController = MFMailComposeViewController()
mailViewController.mailComposeDelegate = self
mailViewController.setToRecipients(["support@sidestore.io"])
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
mailViewController.setSubject("SideStore Beta \(version) Feedback")
} else {
mailViewController.setSubject("SideStore Beta Feedback")
}
present(mailViewController, animated: true, completion: nil)
} else {
let toastView = ToastView(text: NSLocalizedString("Cannot Send Mail", comment: ""), detailText: nil)
toastView.show(in: self)
}
case .resetPairingFile:
let filename = "ALTPairingFile.mobiledevicepairing"
let fm = FileManager.default
let documentsPath = fm.documentsDirectory.appendingPathComponent("/\(filename)")
let alertController = UIAlertController(
title: NSLocalizedString("Are you sure to reset the pairing file?", comment: ""),
message: NSLocalizedString("You can reset the pairing file when you cannot sideload apps or enable JIT. You need to restart SideStore.", comment: ""),
preferredStyle: UIAlertController.Style.actionSheet
)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Delete and Reset", comment: ""), style: .destructive) { _ in
if fm.fileExists(atPath: documentsPath.path), let contents = try? String(contentsOf: documentsPath), !contents.isEmpty {
try? fm.removeItem(atPath: documentsPath.path)
NSLog("Pairing File Reseted")
}
self.tableView.deselectRow(at: indexPath, animated: true)
let dialogMessage = UIAlertController(title: NSLocalizedString("Pairing File Reseted", comment: ""), message: NSLocalizedString("Please restart SideStore", comment: ""), preferredStyle: .alert)
self.present(dialogMessage, animated: true, completion: nil)
})
alertController.addAction(.cancel)
// Fix crash on iPad
alertController.popoverPresentationController?.sourceView = tableView
alertController.popoverPresentationController?.sourceRect = tableView.rectForRow(at: indexPath)
present(alertController, animated: true)
tableView.deselectRow(at: indexPath, animated: true)
case .advancedSettings:
// Create the URL that deep links to your app's custom settings.
if let url = URL(string: UIApplication.openSettingsURLString) {
// Ask the system to open that URL.
UIApplication.shared.open(url)
} else {
ELOG("UIApplication.openSettingsURLString invalid")
}
case .refreshAttempts, .errorLog: break
}
default: break
}
}
}
extension SettingsViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith _: MFMailComposeResult, error: Error?) {
if let error = error {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
controller.dismiss(animated: true, completion: nil)
}
}
extension SettingsViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer) -> Bool {
true
}
}
extension SettingsViewController: INUIAddVoiceShortcutViewControllerDelegate {
func addVoiceShortcutViewController(_ controller: INUIAddVoiceShortcutViewController, didFinishWith _: INVoiceShortcut?, error: Error?) {
if let indexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: indexPath, animated: true)
}
controller.dismiss(animated: true, completion: nil)
guard let error = error else { return }
let toastView = ToastView(error: error)
toastView.show(in: self)
}
func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
if let indexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: indexPath, animated: true)
}
controller.dismiss(animated: true, completion: nil)
}
}