mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 07:13:28 +01:00
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ,
|
||||
|
||||
You’re 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user