mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 23:03:27 +01:00
@@ -0,0 +1,225 @@
|
||||
//
|
||||
// AppContentViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/22/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
import Nuke
|
||||
|
||||
extension AppContentViewController {
|
||||
private enum Row: Int, CaseIterable {
|
||||
case subtitle
|
||||
case screenshots
|
||||
case description
|
||||
case versionDescription
|
||||
case permissions
|
||||
}
|
||||
}
|
||||
|
||||
final class AppContentViewController: UITableViewController {
|
||||
var app: StoreApp!
|
||||
|
||||
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
||||
private lazy var permissionsDataSource = self.makePermissionsDataSource()
|
||||
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
private lazy var byteCountFormatter: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@IBOutlet private var subtitleLabel: UILabel!
|
||||
@IBOutlet private var descriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionLabel: UILabel!
|
||||
@IBOutlet private var versionDateLabel: UILabel!
|
||||
@IBOutlet private var sizeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
||||
@IBOutlet private var permissionsCollectionView: UICollectionView!
|
||||
|
||||
var preferredScreenshotSize: CGSize? {
|
||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
|
||||
|
||||
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
|
||||
|
||||
let itemWidth = width / 1.5
|
||||
let itemHeight = itemWidth * aspectRatio
|
||||
|
||||
return CGSize(width: itemWidth, height: itemHeight)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.contentInset.bottom = 20
|
||||
|
||||
screenshotsCollectionView.dataSource = screenshotsDataSource
|
||||
screenshotsCollectionView.prefetchDataSource = screenshotsDataSource
|
||||
|
||||
permissionsCollectionView.dataSource = permissionsDataSource
|
||||
|
||||
subtitleLabel.text = app.subtitle
|
||||
descriptionTextView.text = app.localizedDescription
|
||||
|
||||
if let version = app.latestVersion {
|
||||
versionDescriptionTextView.text = version.localizedDescription
|
||||
versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version)
|
||||
versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: dateFormatter)
|
||||
sizeLabel.text = byteCountFormatter.string(fromByteCount: version.size)
|
||||
} else {
|
||||
versionDescriptionTextView.text = nil
|
||||
versionLabel.text = nil
|
||||
versionDateLabel.text = nil
|
||||
sizeLabel.text = byteCountFormatter.string(fromByteCount: 0)
|
||||
}
|
||||
|
||||
descriptionTextView.maximumNumberOfLines = 5
|
||||
descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
|
||||
versionDescriptionTextView.maximumNumberOfLines = 3
|
||||
versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
guard var size = preferredScreenshotSize else { return }
|
||||
size.height = min(size.height, screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
|
||||
|
||||
let layout = screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
layout.itemSize = size
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
guard segue.identifier == "showPermission" else { return }
|
||||
|
||||
guard let cell = sender as? UICollectionViewCell, let indexPath = permissionsCollectionView.indexPath(for: cell) else { return }
|
||||
|
||||
let permission = permissionsDataSource.item(at: indexPath)
|
||||
|
||||
let maximumWidth = view.bounds.width - 20
|
||||
|
||||
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
|
||||
permissionPopoverViewController.permission = permission
|
||||
permissionPopoverViewController.view.widthAnchor.constraint(lessThanOrEqualToConstant: maximumWidth).isActive = true
|
||||
|
||||
let size = permissionPopoverViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
permissionPopoverViewController.preferredContentSize = size
|
||||
|
||||
permissionPopoverViewController.popoverPresentationController?.delegate = self
|
||||
permissionPopoverViewController.popoverPresentationController?.sourceRect = cell.frame
|
||||
permissionPopoverViewController.popoverPresentationController?.sourceView = permissionsCollectionView
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppContentViewController {
|
||||
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage> {
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: app.screenshotURLs as [NSURL])
|
||||
dataSource.cellConfigurationHandler = { cell, _, _ in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { imageURL, _, completionHandler in
|
||||
RSTAsyncBlockOperation { operation in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error {
|
||||
os_log("Error loading image: %@", type: .error, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission> {
|
||||
let dataSource = RSTArrayCollectionViewDataSource(items: app.permissions)
|
||||
dataSource.cellConfigurationHandler = { cell, permission, _ in
|
||||
let cell = cell as! PermissionCollectionViewCell
|
||||
cell.button.setImage(permission.type.icon, for: .normal)
|
||||
cell.button.tintColor = .label
|
||||
cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppContentViewController {
|
||||
@objc func toggleCollapsingSection(_ sender: UIButton) {
|
||||
let indexPath: IndexPath
|
||||
|
||||
switch sender {
|
||||
case descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
||||
case versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
||||
default: return
|
||||
}
|
||||
|
||||
// Disable animations to prevent some potentially strange ones.
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentViewController {
|
||||
override func tableView(_: UITableView, willDisplay cell: UITableViewCell, forRowAt _: IndexPath) {
|
||||
cell.tintColor = app.tintColor
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
switch Row.allCases[indexPath.row] {
|
||||
case .screenshots:
|
||||
guard let size = preferredScreenshotSize else { return 0.0 }
|
||||
return size.height
|
||||
|
||||
case .permissions:
|
||||
guard !app.permissions.isEmpty else { return 0.0 }
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
|
||||
default:
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentViewController: UIPopoverPresentationControllerDelegate {
|
||||
func adaptivePresentationStyle(for _: UIPresentationController, traitCollection _: UITraitCollection) -> UIModalPresentationStyle {
|
||||
.none
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// AppContentViewControllerCells.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/24/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
final class PermissionCollectionViewCell: UICollectionViewCell {
|
||||
@IBOutlet var button: UIButton!
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
button.layer.cornerRadius = button.bounds.midY
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
button.backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
textLabel.textColor = tintColor
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
final class AppContentTableViewCell: UITableViewCell {
|
||||
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
|
||||
// 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,505 @@
|
||||
//
|
||||
// AppViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/22/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
import Nuke
|
||||
|
||||
@objc
|
||||
@objcMembers
|
||||
public final class AppViewController: UIViewController {
|
||||
var app: StoreApp!
|
||||
|
||||
private var contentViewController: AppContentViewController!
|
||||
private var contentViewControllerShadowView: UIView!
|
||||
|
||||
private var blurAnimator: UIViewPropertyAnimator?
|
||||
private var navigationBarAnimator: UIViewPropertyAnimator?
|
||||
|
||||
private var contentSizeObservation: NSKeyValueObservation?
|
||||
|
||||
@IBOutlet private var scrollView: UIScrollView!
|
||||
@IBOutlet private var contentView: UIView!
|
||||
|
||||
@IBOutlet private var bannerView: AppBannerView!
|
||||
|
||||
@IBOutlet private var backButton: UIButton!
|
||||
@IBOutlet private var backButtonContainerView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var backgroundAppIconImageView: UIImageView!
|
||||
@IBOutlet private var backgroundBlurView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var navigationBarTitleView: UIView!
|
||||
@IBOutlet private var navigationBarDownloadButton: PillButton!
|
||||
@IBOutlet private var navigationBarAppIconImageView: UIImageView!
|
||||
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
||||
|
||||
private var _shouldResetLayout = false
|
||||
private var _backgroundBlurEffect: UIBlurEffect?
|
||||
private var _backgroundBlurTintColor: UIColor?
|
||||
|
||||
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||
|
||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
_preferredStatusBarStyle
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationBarTitleView.sizeToFit()
|
||||
navigationItem.titleView = navigationBarTitleView
|
||||
|
||||
contentViewControllerShadowView = UIView()
|
||||
contentViewControllerShadowView.backgroundColor = .white
|
||||
contentViewControllerShadowView.layer.cornerRadius = 38
|
||||
contentViewControllerShadowView.layer.shadowColor = UIColor.black.cgColor
|
||||
contentViewControllerShadowView.layer.shadowOffset = CGSize(width: 0, height: -1)
|
||||
contentViewControllerShadowView.layer.shadowRadius = 10
|
||||
contentViewControllerShadowView.layer.shadowOpacity = 0.3
|
||||
contentViewController.view.superview?.insertSubview(contentViewControllerShadowView, at: 0)
|
||||
|
||||
contentView.addGestureRecognizer(scrollView.panGestureRecognizer)
|
||||
|
||||
contentViewController.view.layer.cornerRadius = 38
|
||||
contentViewController.view.layer.masksToBounds = true
|
||||
|
||||
contentViewController.tableView.panGestureRecognizer.require(toFail: scrollView.panGestureRecognizer)
|
||||
contentViewController.tableView.showsVerticalScrollIndicator = false
|
||||
|
||||
// Bring to front so the scroll indicators are visible.
|
||||
view.bringSubviewToFront(scrollView)
|
||||
scrollView.isUserInteractionEnabled = false
|
||||
|
||||
bannerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
|
||||
bannerView.backgroundEffectView.effect = UIBlurEffect(style: .regular)
|
||||
bannerView.backgroundEffectView.backgroundColor = .clear
|
||||
bannerView.iconImageView.image = nil
|
||||
bannerView.iconImageView.tintColor = app.tintColor
|
||||
bannerView.button.tintColor = app.tintColor
|
||||
bannerView.tintColor = app.tintColor
|
||||
|
||||
bannerView.configure(for: app)
|
||||
bannerView.accessibilityTraits.remove(.button)
|
||||
|
||||
bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
|
||||
backButtonContainerView.tintColor = app.tintColor
|
||||
|
||||
navigationController?.navigationBar.tintColor = app.tintColor
|
||||
navigationBarDownloadButton.tintColor = app.tintColor
|
||||
navigationBarAppNameLabel.text = app.name
|
||||
navigationBarAppIconImageView.tintColor = app.tintColor
|
||||
|
||||
contentSizeObservation = contentViewController.tableView.observe(\.contentSize) { [weak self] _, _ in
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didChangeApp(_:)), name: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
|
||||
_backgroundBlurEffect = backgroundBlurView.effect as? UIBlurEffect
|
||||
_backgroundBlurTintColor = backgroundBlurView.contentView.backgroundColor
|
||||
|
||||
// Load Images
|
||||
for imageView in [bannerView.iconImageView!, backgroundAppIconImageView!, navigationBarAppIconImageView!] {
|
||||
imageView.isIndicatingActivity = true
|
||||
|
||||
Nuke.loadImage(with: app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] response, _ in
|
||||
if response?.image != nil {
|
||||
imageView?.isIndicatingActivity = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
prepareBlur()
|
||||
|
||||
// Update blur immediately.
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
|
||||
transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||
self.hideNavigationBar()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
_shouldResetLayout = true
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Guard against "dismissing" when presenting via 3D Touch pop.
|
||||
guard self.navigationController != nil else { return }
|
||||
|
||||
// Store reference since self.navigationController will be nil after disappearing.
|
||||
let navigationController = self.navigationController
|
||||
navigationController?.navigationBar.barStyle = .default // Don't animate, or else status bar might appear messed-up.
|
||||
|
||||
transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||
self.showNavigationBar(for: navigationController)
|
||||
}, completion: { context in
|
||||
if !context.isCancelled {
|
||||
self.showNavigationBar(for: navigationController)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if navigationController == nil {
|
||||
resetNavigationBarAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
public override func prepare(for segue: UIStoryboardSegue, sender _: Any?) {
|
||||
guard segue.identifier == "embedAppContentViewController" else { return }
|
||||
|
||||
contentViewController = segue.destination as? AppContentViewController
|
||||
contentViewController.app = app
|
||||
|
||||
if #available(iOS 15, *) {
|
||||
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||
self.setContentScrollView(self.scrollView)
|
||||
self.navigationItem.scrollEdgeAppearance = self.navigationController?.navigationBar.standardAppearance
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
if _shouldResetLayout {
|
||||
// Various events can cause UI to mess up, so reset affected components now.
|
||||
|
||||
if navigationController?.topViewController == self {
|
||||
hideNavigationBar()
|
||||
}
|
||||
|
||||
prepareBlur()
|
||||
|
||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||
resetNavigationBarAnimation()
|
||||
|
||||
_shouldResetLayout = false
|
||||
}
|
||||
|
||||
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
||||
let cornerRadius = contentViewControllerShadowView.layer.cornerRadius
|
||||
|
||||
let inset = 12 as CGFloat
|
||||
let padding = 20 as CGFloat
|
||||
|
||||
let backButtonSize = backButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
||||
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
|
||||
width: backButtonSize.width + 20, height: backButtonSize.height + 20)
|
||||
|
||||
var headerFrame = CGRect(x: inset, y: 0, width: view.bounds.width - inset * 2, height: bannerView.bounds.height)
|
||||
var contentFrame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)
|
||||
var backgroundIconFrame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.width)
|
||||
|
||||
let minimumHeaderY = backButtonFrame.maxY + 8
|
||||
|
||||
let minimumContentY = minimumHeaderY + headerFrame.height + padding
|
||||
let maximumContentY = view.bounds.width * 0.667
|
||||
|
||||
// A full blur is too much, so we reduce the visible blur by 0.3, resulting in 70% blur.
|
||||
let minimumBlurFraction = 0.3 as CGFloat
|
||||
|
||||
contentFrame.origin.y = maximumContentY - scrollView.contentOffset.y
|
||||
headerFrame.origin.y = contentFrame.origin.y - padding - headerFrame.height
|
||||
|
||||
// Stretch the app icon image to fill additional vertical space if necessary.
|
||||
let height = max(contentFrame.origin.y + cornerRadius * 2, backgroundIconFrame.height)
|
||||
backgroundIconFrame.size.height = height
|
||||
|
||||
let blurThreshold = 0 as CGFloat
|
||||
if scrollView.contentOffset.y < blurThreshold {
|
||||
// Determine how much to lessen blur by.
|
||||
|
||||
let range = 75 as CGFloat
|
||||
let difference = -scrollView.contentOffset.y
|
||||
|
||||
let fraction = min(difference, range) / range
|
||||
|
||||
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
||||
blurAnimator?.fractionComplete = fractionComplete
|
||||
} else {
|
||||
// Set blur to default.
|
||||
|
||||
blurAnimator?.fractionComplete = minimumBlurFraction
|
||||
}
|
||||
|
||||
// Animate navigation bar.
|
||||
let showNavigationBarThreshold = (maximumContentY - minimumContentY) + backButtonFrame.origin.y
|
||||
if scrollView.contentOffset.y > showNavigationBarThreshold {
|
||||
if navigationBarAnimator == nil {
|
||||
prepareNavigationBarAnimation()
|
||||
}
|
||||
|
||||
let difference = scrollView.contentOffset.y - showNavigationBarThreshold
|
||||
let range = (headerFrame.height + padding) - (navigationController?.navigationBar.bounds.height ?? view.safeAreaInsets.top)
|
||||
|
||||
let fractionComplete = min(difference, range) / range
|
||||
navigationBarAnimator?.fractionComplete = fractionComplete
|
||||
} else {
|
||||
resetNavigationBarAnimation()
|
||||
}
|
||||
|
||||
let beginMovingBackButtonThreshold = (maximumContentY - minimumContentY)
|
||||
if scrollView.contentOffset.y > beginMovingBackButtonThreshold {
|
||||
let difference = scrollView.contentOffset.y - beginMovingBackButtonThreshold
|
||||
backButtonFrame.origin.y -= difference
|
||||
}
|
||||
|
||||
let pinContentToTopThreshold = maximumContentY
|
||||
if scrollView.contentOffset.y > pinContentToTopThreshold {
|
||||
contentFrame.origin.y = 0
|
||||
backgroundIconFrame.origin.y = 0
|
||||
|
||||
let difference = scrollView.contentOffset.y - pinContentToTopThreshold
|
||||
contentViewController.tableView.contentOffset.y = difference
|
||||
} else {
|
||||
// Keep content table view's content offset at the top.
|
||||
contentViewController.tableView.contentOffset.y = 0
|
||||
}
|
||||
|
||||
// Keep background app icon centered in gap between top of content and top of screen.
|
||||
backgroundIconFrame.origin.y = (contentFrame.origin.y / 2) - backgroundIconFrame.height / 2
|
||||
|
||||
// Set frames.
|
||||
contentViewController.view.superview?.frame = contentFrame
|
||||
bannerView.frame = headerFrame
|
||||
backgroundAppIconImageView.frame = backgroundIconFrame
|
||||
backgroundBlurView.frame = backgroundIconFrame
|
||||
backButtonContainerView.frame = backButtonFrame
|
||||
|
||||
contentViewControllerShadowView.frame = contentViewController.view.frame
|
||||
|
||||
backButtonContainerView.layer.cornerRadius = backButtonContainerView.bounds.midY
|
||||
|
||||
scrollView.scrollIndicatorInsets.top = statusBarHeight
|
||||
|
||||
// Adjust content offset + size.
|
||||
let contentOffset = scrollView.contentOffset
|
||||
|
||||
var contentSize = contentViewController.tableView.contentSize
|
||||
contentSize.height += maximumContentY
|
||||
|
||||
scrollView.contentSize = contentSize
|
||||
scrollView.contentOffset = contentOffset
|
||||
|
||||
bannerView.backgroundEffectView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
_shouldResetLayout = true
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.blurAnimator?.stopAnimation(true)
|
||||
self.navigationBarAnimator?.stopAnimation(true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppViewController {
|
||||
final class func makeAppViewController(app: StoreApp) -> AppViewController {
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: Bundle.init(for: AppViewController.self))
|
||||
|
||||
let appViewController = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
|
||||
appViewController.app = app
|
||||
return appViewController
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppViewController {
|
||||
func update() {
|
||||
for button in [bannerView.button!, navigationBarDownloadButton!] {
|
||||
button.tintColor = app.tintColor
|
||||
button.isIndicatingActivity = false
|
||||
|
||||
if app.installedApp == nil {
|
||||
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
||||
} else {
|
||||
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
}
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
button.progress = progress
|
||||
}
|
||||
|
||||
if let versionDate = app.latestVersion?.date, versionDate > Date() {
|
||||
bannerView.button.countdownDate = versionDate
|
||||
navigationBarDownloadButton.countdownDate = versionDate
|
||||
} else {
|
||||
bannerView.button.countdownDate = nil
|
||||
navigationBarDownloadButton.countdownDate = nil
|
||||
}
|
||||
|
||||
let barButtonItem = navigationItem.rightBarButtonItem
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
navigationItem.rightBarButtonItem = barButtonItem
|
||||
}
|
||||
|
||||
func showNavigationBar(for navigationController: UINavigationController? = nil) {
|
||||
let navigationController = navigationController ?? self.navigationController
|
||||
navigationController?.navigationBar.alpha = 1.0
|
||||
navigationController?.navigationBar.tintColor = .altPrimary
|
||||
navigationController?.navigationBar.setNeedsLayout()
|
||||
|
||||
if traitCollection.userInterfaceStyle == .dark {
|
||||
_preferredStatusBarStyle = .lightContent
|
||||
} else {
|
||||
_preferredStatusBarStyle = .default
|
||||
}
|
||||
|
||||
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
func hideNavigationBar(for navigationController: UINavigationController? = nil) {
|
||||
let navigationController = navigationController ?? self.navigationController
|
||||
navigationController?.navigationBar.alpha = 0.0
|
||||
|
||||
_preferredStatusBarStyle = .lightContent
|
||||
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
func prepareBlur() {
|
||||
if let animator = blurAnimator {
|
||||
animator.stopAnimation(true)
|
||||
}
|
||||
|
||||
backgroundBlurView.effect = _backgroundBlurEffect
|
||||
backgroundBlurView.contentView.backgroundColor = _backgroundBlurTintColor
|
||||
|
||||
blurAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.backgroundBlurView.effect = nil
|
||||
self?.backgroundBlurView.contentView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
blurAnimator?.startAnimation()
|
||||
blurAnimator?.pauseAnimation()
|
||||
}
|
||||
|
||||
func prepareNavigationBarAnimation() {
|
||||
resetNavigationBarAnimation()
|
||||
|
||||
navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.showNavigationBar()
|
||||
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
|
||||
self?.navigationController?.navigationBar.barTintColor = nil
|
||||
self?.contentViewController.view.layer.cornerRadius = 0
|
||||
}
|
||||
|
||||
navigationBarAnimator?.startAnimation()
|
||||
navigationBarAnimator?.pauseAnimation()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
func resetNavigationBarAnimation() {
|
||||
navigationBarAnimator?.stopAnimation(true)
|
||||
navigationBarAnimator = nil
|
||||
|
||||
hideNavigationBar()
|
||||
|
||||
contentViewController.view.layer.cornerRadius = contentViewControllerShadowView.layer.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
extension AppViewController {
|
||||
@IBAction func popViewController(_: UIButton) {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
@IBAction func performAppAction(_: PillButton) {
|
||||
if let installedApp = app.installedApp {
|
||||
open(installedApp)
|
||||
} else {
|
||||
downloadApp()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadApp() {
|
||||
guard app.installedApp == nil else { return }
|
||||
|
||||
let group = AppManager.shared.install(app, presentingViewController: self) { result in
|
||||
do {
|
||||
_ = try result.get()
|
||||
} catch OperationError.cancelled {
|
||||
// Ignore
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.bannerView.button.progress = nil
|
||||
self.navigationBarDownloadButton.progress = nil
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
bannerView.button.progress = group.progress
|
||||
navigationBarDownloadButton.progress = group.progress
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp) {
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppViewController {
|
||||
@objc func didChangeApp(_: Notification) {
|
||||
// Async so that AppManager.installationProgress(for:) is nil when we update.
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func willEnterForeground(_: Notification) {
|
||||
guard let navigationController = navigationController, navigationController.topViewController == self else { return }
|
||||
|
||||
_shouldResetLayout = true
|
||||
view.setNeedsLayout()
|
||||
}
|
||||
|
||||
@objc func didBecomeActive(_: Notification) {
|
||||
guard let navigationController = navigationController, navigationController.topViewController == self else { return }
|
||||
|
||||
// Fixes Navigation Bar appearing after app becomes inactive -> active again.
|
||||
_shouldResetLayout = true
|
||||
view.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppViewController: UIScrollViewDelegate {
|
||||
public func scrollViewDidScroll(_: UIScrollView) {
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// PermissionPopoverViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
final class PermissionPopoverViewController: UIViewController {
|
||||
var permission: AppPermission!
|
||||
|
||||
@IBOutlet private var nameLabel: UILabel!
|
||||
@IBOutlet private var descriptionLabel: UILabel!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
nameLabel.text = permission.type.localizedName
|
||||
descriptionLabel.text = permission.usageDescription
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//
|
||||
// AppIDsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 1/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import Down
|
||||
|
||||
final class AppIDsViewController: UICollectionViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
private var didInitialFetch = false
|
||||
private var isLoading = false {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
|
||||
activityIndicatorBarButtonItem.isIndicatingActivity = true
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
|
||||
collectionView.refreshControl = refreshControl
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if !didInitialFetch {
|
||||
fetchAppIDs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppIDsViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<AppID> {
|
||||
let fetchRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AppID.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
if let team = DatabaseManager.shared.activeTeam() {
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
|
||||
} else {
|
||||
fetchRequest.predicate = NSPredicate(value: false)
|
||||
}
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.proxy = self
|
||||
dataSource.cellConfigurationHandler = { cell, appID, _ in
|
||||
let tintColor = UIColor.altPrimary
|
||||
|
||||
let cell = cell as! BannerCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
cell.tintColor = tintColor
|
||||
|
||||
cell.bannerView.iconImageView.isHidden = true
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
||||
|
||||
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
|
||||
|
||||
if let expirationDate = appID.expirationDate {
|
||||
cell.bannerView.button.isHidden = false
|
||||
cell.bannerView.button.isUserInteractionEnabled = false
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
|
||||
let currentDate = Date()
|
||||
|
||||
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
|
||||
let numberOfDaysText = (numberOfDays == 1) ? NSLocalizedString("1 day", comment: "") : String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
|
||||
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
|
||||
|
||||
attributedAccessibilityLabel.mutableString.append(String(format: NSLocalizedString("Expires in %@.", comment: ""), numberOfDaysText) + " ")
|
||||
} else {
|
||||
cell.bannerView.button.isHidden = true
|
||||
cell.bannerView.button.isUserInteractionEnabled = true
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = true
|
||||
}
|
||||
|
||||
cell.bannerView.titleLabel.text = appID.name
|
||||
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
||||
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||
|
||||
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
||||
|
||||
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *) {
|
||||
// Prefer to speak the team ID one character at a time.
|
||||
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
||||
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
|
||||
}
|
||||
|
||||
attributedAccessibilityLabel.append(attributedBundleIdentifier)
|
||||
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
cell.layoutIfNeeded()
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
@objc func fetchAppIDs() {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
|
||||
AppManager.shared.fetchAppIDs { result in
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
if !isLoading {
|
||||
collectionView.refreshControl?.endRefreshing()
|
||||
activityIndicatorBarButtonItem.isIndicatingActivity = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppIDsViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
||||
CGSize(width: collectionView.bounds.width, height: 80)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
|
||||
let indexPath = IndexPath(row: 0, section: section)
|
||||
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
||||
|
||||
// Use this view to calculate the optimal size based on the collection view's width
|
||||
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
||||
withHorizontalFittingPriority: .required, // Width is fixed
|
||||
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||
return size
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection _: Int) -> CGSize {
|
||||
CGSize(width: collectionView.bounds.width, height: 50)
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
switch kind {
|
||||
case UICollectionView.elementKindSectionHeader:
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView
|
||||
headerView.layoutMargins.left = view.layoutMargins.left
|
||||
headerView.layoutMargins.right = view.layoutMargins.right
|
||||
|
||||
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free {
|
||||
let text = NSLocalizedString(
|
||||
"""
|
||||
Each app and app extension installed with SideStore must register an App ID with Apple. Apple limits non-developer Apple IDs to 10 App IDs at a time.
|
||||
|
||||
**App IDs can't be deleted**, but they do expire after one week. SideStore will automatically renew App IDs for all active apps once they've expired.
|
||||
""", comment: "")
|
||||
|
||||
let mdown = Down(markdownString: text)
|
||||
let labelFont: DownFont = headerView.textLabel.font
|
||||
let fonts: FontCollection = StaticFontCollection(
|
||||
heading1: labelFont,
|
||||
heading2: labelFont,
|
||||
heading3: labelFont,
|
||||
heading4: labelFont,
|
||||
heading5: labelFont,
|
||||
heading6: labelFont,
|
||||
body: labelFont,
|
||||
code: labelFont,
|
||||
listItemPrefix: labelFont)
|
||||
let config: DownStylerConfiguration = .init(fonts: fonts)
|
||||
let styler: Styler = DownStyler.init(configuration: config)
|
||||
let options: DownOptions = .default
|
||||
headerView.textLabel.attributedText = try? mdown.toAttributedString(options,
|
||||
styler: styler) ?? NSAttributedString(string: text)
|
||||
} else {
|
||||
headerView.textLabel.text = NSLocalizedString("""
|
||||
Each app and app extension installed with SideStore must register an App ID with Apple.
|
||||
|
||||
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
|
||||
""", comment: "")
|
||||
}
|
||||
|
||||
return headerView
|
||||
|
||||
case UICollectionView.elementKindSectionFooter:
|
||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
|
||||
|
||||
let count = dataSource.itemCount
|
||||
if count == 1 {
|
||||
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
|
||||
} else {
|
||||
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
|
||||
}
|
||||
|
||||
return footerView
|
||||
|
||||
default: fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate enum MarkdownStyledBlock: Equatable {
|
||||
case generic
|
||||
case headline(Int)
|
||||
case paragraph
|
||||
case unorderedListElement
|
||||
case orderedListElement(Int)
|
||||
case blockquote
|
||||
case code(String?)
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
@available(iOS 15, *)
|
||||
extension AttributedString {
|
||||
|
||||
init(styledMarkdown markdownString: String, fontSize: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize) throws {
|
||||
|
||||
var s = try AttributedString(markdown: markdownString, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: "en"), baseURL: nil)
|
||||
|
||||
// Looking at the AttributedString’s raw structure helps with understanding the following code.
|
||||
print(s)
|
||||
|
||||
// Set base font and paragraph style for the whole string
|
||||
s.font = .systemFont(ofSize: fontSize)
|
||||
s.paragraphStyle = defaultParagraphStyle
|
||||
|
||||
// Will respect dark mode automatically
|
||||
s.foregroundColor = .label
|
||||
|
||||
// MARK: Inline Intents
|
||||
let inlineIntents: [InlinePresentationIntent] = [.emphasized, .stronglyEmphasized, .code, .strikethrough, .softBreak, .lineBreak, .inlineHTML, .blockHTML]
|
||||
|
||||
for inlineIntent in inlineIntents {
|
||||
|
||||
var sourceAttributeContainer = AttributeContainer()
|
||||
sourceAttributeContainer.inlinePresentationIntent = inlineIntent
|
||||
|
||||
var targetAttributeContainer = AttributeContainer()
|
||||
switch inlineIntent {
|
||||
case .emphasized:
|
||||
targetAttributeContainer.font = .italicSystemFont(ofSize: fontSize)
|
||||
case .stronglyEmphasized:
|
||||
targetAttributeContainer.font = .systemFont(ofSize: fontSize, weight: .bold)
|
||||
case .code:
|
||||
targetAttributeContainer.font = .monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||
targetAttributeContainer.backgroundColor = .secondarySystemBackground
|
||||
case .strikethrough:
|
||||
targetAttributeContainer.strikethroughStyle = .single
|
||||
case .softBreak:
|
||||
break // TODO: Implement
|
||||
case .lineBreak:
|
||||
break // TODO: Implement
|
||||
case .inlineHTML:
|
||||
break // TODO: Implement
|
||||
case .blockHTML:
|
||||
break // TODO: Implement
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
s = s.replacingAttributes(sourceAttributeContainer, with: targetAttributeContainer)
|
||||
}
|
||||
|
||||
// MARK: Blocks
|
||||
// Accessing via dynamic lookup key path (\.presentationIntent) triggers a warning on Xcode 13.1, so we use the verbose way: AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self
|
||||
// We use .reversed() iteration to be able to add characters to the string without breaking ranges.
|
||||
var previousListID = 0
|
||||
|
||||
for (intentBlock, intentRange) in s.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
||||
guard let intentBlock = intentBlock else { continue }
|
||||
|
||||
var block: MarkdownStyledBlock = .generic
|
||||
var currentElementOrdinal: Int = 0
|
||||
|
||||
var currentListID = 0
|
||||
|
||||
for intent in intentBlock.components {
|
||||
switch intent.kind {
|
||||
case .paragraph:
|
||||
if block == .generic {
|
||||
block = .paragraph
|
||||
}
|
||||
case .header(level: let level):
|
||||
block = .headline(level)
|
||||
case .orderedList:
|
||||
block = .orderedListElement(currentElementOrdinal)
|
||||
currentListID = intent.identity
|
||||
case .unorderedList:
|
||||
block = .unorderedListElement
|
||||
currentListID = intent.identity
|
||||
case .listItem(ordinal: let ordinal):
|
||||
currentElementOrdinal = ordinal
|
||||
if block != .unorderedListElement {
|
||||
block = .orderedListElement(ordinal)
|
||||
}
|
||||
case .codeBlock(languageHint: let languageHint):
|
||||
block = .code(languageHint)
|
||||
case .blockQuote:
|
||||
block = .blockquote
|
||||
case .thematicBreak:
|
||||
break // This is ---- in Markdown.
|
||||
case .table(columns: _):
|
||||
break
|
||||
case .tableHeaderRow:
|
||||
break
|
||||
case .tableRow(rowIndex: _):
|
||||
break
|
||||
case .tableCell(columnIndex: _):
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch block {
|
||||
case .generic:
|
||||
assertionFailure(intentBlock.debugDescription)
|
||||
case .headline(let level):
|
||||
switch level {
|
||||
case 1:
|
||||
s[intentRange].font = .systemFont(ofSize: 30, weight: .heavy)
|
||||
case 2:
|
||||
s[intentRange].font = .systemFont(ofSize: 20, weight: .heavy)
|
||||
case 3:
|
||||
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
|
||||
default:
|
||||
// TODO: Handle H4 to H6
|
||||
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
|
||||
}
|
||||
case .paragraph:
|
||||
break
|
||||
case .unorderedListElement:
|
||||
s.characters.insert(contentsOf: "•\t", at: intentRange.lowerBound)
|
||||
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
|
||||
case .orderedListElement(let ordinal):
|
||||
s.characters.insert(contentsOf: "\(ordinal).\t", at: intentRange.lowerBound)
|
||||
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
|
||||
case .blockquote:
|
||||
s[intentRange].paragraphStyle = defaultParagraphStyle
|
||||
s[intentRange].foregroundColor = .secondaryLabel
|
||||
case .code:
|
||||
s[intentRange].font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
||||
s[intentRange].paragraphStyle = codeParagraphStyle
|
||||
}
|
||||
|
||||
// Remember the list ID so we can check if it’s identical in the next block
|
||||
previousListID = currentListID
|
||||
|
||||
// MARK: Add line breaks to separate blocks
|
||||
if intentRange.lowerBound != s.startIndex {
|
||||
s.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
self = s
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let defaultParagraphStyle: NSParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.paragraphSpacing = 10.0
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
|
||||
fileprivate let listParagraphStyle: NSMutableParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
|
||||
paragraphStyle.headIndent = 20
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
fileprivate let lastElementListParagraphStyle: NSMutableParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
|
||||
paragraphStyle.headIndent = 20
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
paragraphStyle.paragraphSpacing = 20.0 // The last element in a list needs extra paragraph spacing
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
|
||||
fileprivate let codeParagraphStyle: NSParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
paragraphStyle.firstLineHeadIndent = 20
|
||||
paragraphStyle.headIndent = 20
|
||||
return paragraphStyle
|
||||
}()
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// AuthenticationViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/5/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
|
||||
final class AuthenticationViewController: UIViewController {
|
||||
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
|
||||
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
|
||||
|
||||
private weak var toastView: ToastView?
|
||||
|
||||
@IBOutlet private var appleIDTextField: UITextField!
|
||||
@IBOutlet private var passwordTextField: UITextField!
|
||||
@IBOutlet private var signInButton: UIButton!
|
||||
|
||||
@IBOutlet private var appleIDBackgroundView: UIView!
|
||||
@IBOutlet private var passwordBackgroundView: UIView!
|
||||
|
||||
@IBOutlet private var scrollView: UIScrollView!
|
||||
@IBOutlet private var contentStackView: UIStackView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
signInButton.activityIndicatorView.style = .medium
|
||||
|
||||
for view in [appleIDBackgroundView!, passwordBackgroundView!, signInButton!] {
|
||||
view.clipsToBounds = true
|
||||
view.layer.cornerRadius = 16
|
||||
}
|
||||
|
||||
if UIScreen.main.isExtraCompactHeight {
|
||||
contentStackView.spacing = 20
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: appleIDTextField)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
signInButton.isIndicatingActivity = false
|
||||
toastView?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationViewController {
|
||||
func update() {
|
||||
if let _ = validate() {
|
||||
signInButton.isEnabled = true
|
||||
signInButton.alpha = 1.0
|
||||
} else {
|
||||
signInButton.isEnabled = false
|
||||
signInButton.alpha = 0.6
|
||||
}
|
||||
}
|
||||
|
||||
func validate() -> (String, String)? {
|
||||
guard
|
||||
let emailAddress = appleIDTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
|
||||
let password = passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return (emailAddress, password)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationViewController {
|
||||
@IBAction func authenticate() {
|
||||
guard let (emailAddress, password) = validate() else { return }
|
||||
|
||||
appleIDTextField.resignFirstResponder()
|
||||
passwordTextField.resignFirstResponder()
|
||||
|
||||
signInButton.isIndicatingActivity = true
|
||||
|
||||
authenticationHandler?(emailAddress, password) { result in
|
||||
switch result {
|
||||
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
|
||||
// Ignore
|
||||
DispatchQueue.main.async {
|
||||
self.signInButton.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
case let .failure(error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
let error = error.withLocalizedFailure(NSLocalizedString("Failed to Log In", comment: ""))
|
||||
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.textLabel.textColor = .altPink
|
||||
toastView.detailTextLabel.textColor = .altPink
|
||||
toastView.show(in: self)
|
||||
self.toastView = toastView
|
||||
|
||||
self.signInButton.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
case let .success((account, session)):
|
||||
self.completionHandler?((account, session, password))
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.scrollView.setContentOffset(CGPoint(x: 0, y: -self.view.safeAreaInsets.top), animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func cancel(_: UIBarButtonItem) {
|
||||
completionHandler?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
switch textField {
|
||||
case appleIDTextField: passwordTextField.becomeFirstResponder()
|
||||
case passwordTextField: authenticate()
|
||||
default: break
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_: UITextField) {
|
||||
guard UIScreen.main.isExtraCompactHeight else { return }
|
||||
|
||||
// Position all the controls within visible frame.
|
||||
var contentOffset = scrollView.contentOffset
|
||||
contentOffset.y = 44
|
||||
scrollView.setContentOffset(contentOffset, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewController {
|
||||
@objc func textFieldDidChangeText(_: Notification) {
|
||||
update()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// InstructionsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/6/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class InstructionsViewController: UIViewController {
|
||||
var completionHandler: (() -> Void)?
|
||||
|
||||
var showsBottomButton: Bool = false
|
||||
|
||||
@IBOutlet private var contentStackView: UIStackView!
|
||||
@IBOutlet private var dismissButton: UIButton!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
if UIScreen.main.isExtraCompactHeight {
|
||||
contentStackView.layoutMargins.top = 0
|
||||
contentStackView.layoutMargins.bottom = contentStackView.layoutMargins.left
|
||||
}
|
||||
|
||||
dismissButton.clipsToBounds = true
|
||||
dismissButton.layer.cornerRadius = 16
|
||||
|
||||
if showsBottomButton {
|
||||
navigationItem.hidesBackButton = true
|
||||
} else {
|
||||
dismissButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension InstructionsViewController {
|
||||
@IBAction func dismiss() {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// RefreshAltStoreViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/26/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
final class RefreshAltStoreViewController: UIViewController {
|
||||
var context: AuthenticatedOperationContext!
|
||||
|
||||
var completionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
@IBOutlet private var placeholderView: RSTPlaceholderView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
placeholderView.textLabel.isHidden = true
|
||||
|
||||
placeholderView.detailTextLabel.textAlignment = .left
|
||||
placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("SideStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including SideStore.\n\nTo prevent SideStore from expiring early, please refresh the app now. SideStore will quit once refreshing is complete.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private extension RefreshAltStoreViewController {
|
||||
@IBAction func refreshAltStore(_ sender: PillButton) {
|
||||
guard let altStore = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext) else { return }
|
||||
|
||||
func refresh() {
|
||||
sender.isIndicatingActivity = true
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: altStore) {
|
||||
// Cancel pending AltStore installation so we can start a new one.
|
||||
progress.cancel()
|
||||
}
|
||||
|
||||
// Install, _not_ refresh, to ensure we are installing with a non-revoked certificate.
|
||||
let group = AppManager.shared.install(altStore, presentingViewController: self, context: context) { result in
|
||||
switch result {
|
||||
case .success: self.completionHandler?(.success(()))
|
||||
case let .failure(error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
sender.progress = nil
|
||||
sender.isIndicatingActivity = false
|
||||
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh SideStore", comment: ""), message: error.localizedFailureReason ?? error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: ""), style: .default, handler: { _ in
|
||||
refresh()
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh Later", comment: ""), style: .cancel, handler: { _ in
|
||||
self.completionHandler?(.failure(error))
|
||||
}))
|
||||
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sender.progress = group.progress
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
@IBAction func cancel(_: UIButton) {
|
||||
completionHandler?(.failure(OperationError.cancelled))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// SelectTeamViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Megarushing on 4/26/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Intents
|
||||
import IntentsUI
|
||||
import MessageUI
|
||||
import SafariServices
|
||||
import UIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
import AltSign
|
||||
|
||||
final class SelectTeamViewController: UITableViewController {
|
||||
public var teams: [ALTTeam]?
|
||||
public var completionHandler: ((Result<ALTTeam, Swift.Error>) -> Void)?
|
||||
|
||||
private var prototypeHeaderFooterView: SettingsHeaderFooterView!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
teams?.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
precondition(completionHandler != nil)
|
||||
precondition(teams != nil)
|
||||
precondition(teams!.count <= indexPath.row)
|
||||
|
||||
guard let completionHandler = completionHandler else {
|
||||
os_log("completionHandler was nil", type: .error)
|
||||
return
|
||||
}
|
||||
guard let teams = teams, teams.count <= indexPath.row else {
|
||||
os_log("teams nil or out of bounds", type: .error)
|
||||
return
|
||||
}
|
||||
completionHandler(.success(teams[indexPath.row]))
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "TeamCell", for: indexPath) as! InsetGroupTableViewCell
|
||||
|
||||
cell.textLabel?.text = teams?[indexPath.row].name
|
||||
cell.detailTextLabel?.text = teams?[indexPath.row].type.localizedDescription
|
||||
if indexPath.row == 0 {
|
||||
cell.style = InsetGroupTableViewCell.Style.top
|
||||
} else if indexPath.row == self.tableView(self.tableView, numberOfRowsInSection: indexPath.section) - 1 {
|
||||
cell.style = InsetGroupTableViewCell.Style.bottom
|
||||
} else {
|
||||
cell.style = InsetGroupTableViewCell.Style.middle
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, titleForHeaderInSection _: Int) -> String? {
|
||||
"Teams"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// BrowseCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
import Nuke
|
||||
|
||||
@objc final class BrowseCollectionViewCell: UICollectionViewCell {
|
||||
var imageURLs: [URL] = [] {
|
||||
didSet {
|
||||
dataSource.items = imageURLs as [NSURL]
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
|
||||
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷♂️.
|
||||
screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
screenshotsCollectionView.delegate = self
|
||||
screenshotsCollectionView.dataSource = dataSource
|
||||
screenshotsCollectionView.prefetchDataSource = dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseCollectionViewCell {
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage> {
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { cell, _, _ in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { imageURL, _, completionHandler in
|
||||
RSTAsyncBlockOperation { operation in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error {
|
||||
os_log("Error loading image: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
||||
// Assuming 9.0 / 16.0 ratio for now.
|
||||
let aspectRatio: CGFloat = 9.0 / 16.0
|
||||
|
||||
let itemHeight = collectionView.bounds.height
|
||||
let itemWidth = itemHeight * aspectRatio
|
||||
|
||||
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
|
||||
return size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//
|
||||
// BrowseViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
import Nuke
|
||||
|
||||
class BrowseViewController: UICollectionViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
|
||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
||||
|
||||
private var loadingState: LoadingState = .loading {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedItemSizes = [String: CGSize]()
|
||||
|
||||
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
#if BETA
|
||||
dataSource.searchController.searchableKeyPaths = [#keyPath(InstalledApp.name)]
|
||||
navigationItem.searchController = dataSource.searchController
|
||||
#endif
|
||||
|
||||
prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
collectionView.prefetchDataSource = dataSource
|
||||
|
||||
registerForPreviewing(with: self, sourceView: collectionView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
fetchSource()
|
||||
updateDataSource()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
@IBAction private func unwindFromSourcesViewController(_: UIStoryboardSegue) {
|
||||
fetchSource()
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage> {
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellConfigurationHandler = { cell, app, _ in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
cell.subtitleLabel.text = app.subtitle
|
||||
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
||||
|
||||
cell.bannerView.configure(for: app)
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
let tintColor = app.tintColor ?? .altPrimary
|
||||
cell.tintColor = tintColor
|
||||
|
||||
if app.installedApp == nil {
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = app.latestVersion?.date, versionDate > Date() {
|
||||
cell.bannerView.button.countdownDate = app.versionDate
|
||||
} else {
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
} else {
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = nil
|
||||
cell.bannerView.button.progress = nil
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
dataSource.prefetchHandler = { storeApp, _, completionHandler -> Foundation.Operation? in
|
||||
let iconURL = storeApp.iconURL
|
||||
|
||||
return RSTAsyncBlockOperation { operation in
|
||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
cell.bannerView.iconImageView.image = image
|
||||
|
||||
if let error = error {
|
||||
os_log("Error loading image: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.placeholderView = placeholderView
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func updateDataSource() {
|
||||
dataSource.predicate = nil
|
||||
}
|
||||
|
||||
func fetchSource() {
|
||||
loadingState = .loading
|
||||
|
||||
AppManager.shared.fetchSources { result in
|
||||
do {
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.loadingState = .finished(.success(()))
|
||||
}
|
||||
} catch let error as AppManager.FetchSourcesError {
|
||||
try error.managedObjectContext?.save()
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
if self.dataSource.itemCount > 0 {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
|
||||
self.loadingState = .finished(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
switch loadingState {
|
||||
case .loading:
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||
|
||||
placeholderView.activityIndicatorView.startAnimating()
|
||||
|
||||
case let .finished(.failure(error)):
|
||||
placeholderView.textLabel.isHidden = false
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||
placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
case .finished(.success):
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController {
|
||||
@IBAction func performAppAction(_ sender: PillButton) {
|
||||
let point = collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
if let installedApp = app.installedApp {
|
||||
open(installedApp)
|
||||
} else {
|
||||
install(app, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func install(_ app: StoreApp, at indexPath: IndexPath) {
|
||||
let previousProgress = AppManager.shared.installationProgress(for: app)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(app, presentingViewController: self) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .failure(OperationError.cancelled): break // Ignore
|
||||
case let .failure(error):
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
|
||||
case .success: os_log("Installed app: %@", type: .info , app.bundleIdentifier)
|
||||
}
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp) {
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let item = dataSource.item(at: indexPath)
|
||||
|
||||
if let previousSize = cachedItemSizes[item.bundleIdentifier] {
|
||||
return previousSize
|
||||
}
|
||||
|
||||
let maxVisibleScreenshots = 2 as CGFloat
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
let layout = prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
|
||||
|
||||
dataSource.cellConfigurationHandler(prototypeCell, item, indexPath)
|
||||
|
||||
let widthConstraint = prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
widthConstraint.isActive = true
|
||||
defer { widthConstraint.isActive = false }
|
||||
|
||||
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
|
||||
prototypeCell.frame.size.width = widthConstraint.constant
|
||||
prototypeCell.layoutIfNeeded()
|
||||
|
||||
let collectionViewWidth = prototypeCell.screenshotsCollectionView.bounds.width
|
||||
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
||||
let screenshotHeight = screenshotWidth * aspectRatio
|
||||
|
||||
let heightConstraint = prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
||||
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
|
||||
heightConstraint.isActive = true
|
||||
defer { heightConstraint.isActive = false }
|
||||
|
||||
let itemSize = prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
cachedItemSizes[item.bundleIdentifier] = itemSize
|
||||
return itemSize
|
||||
}
|
||||
|
||||
override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
navigationController?.pushViewController(appViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UIViewControllerPreviewingDelegate {
|
||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
|
||||
guard
|
||||
let indexPath = collectionView.indexPathForItem(at: location),
|
||||
let cell = collectionView.cellForItem(at: indexPath)
|
||||
else { return nil }
|
||||
|
||||
previewingContext.sourceRect = cell.frame
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
return appViewController
|
||||
}
|
||||
|
||||
func previewingContext(_: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
|
||||
navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// ScreenshotCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(ScreenshotCollectionViewCell)
|
||||
class ScreenshotCollectionViewCell: UICollectionViewCell {
|
||||
let imageView = UIImageView(image: nil)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
imageView.layer.masksToBounds = true
|
||||
addSubview(imageView, pinningEdgesWith: .zero)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
imageView.layer.cornerRadius = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// AppBannerView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/29/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
class AppBannerView: RSTNibView {
|
||||
override var accessibilityLabel: String? {
|
||||
get { self.accessibilityView?.accessibilityLabel }
|
||||
set { self.accessibilityView?.accessibilityLabel = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedLabel: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedLabel }
|
||||
set { self.accessibilityView?.accessibilityAttributedLabel = newValue }
|
||||
}
|
||||
|
||||
override var accessibilityValue: String? {
|
||||
get { self.accessibilityView?.accessibilityValue }
|
||||
set { self.accessibilityView?.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedValue: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedValue }
|
||||
set { self.accessibilityView?.accessibilityAttributedValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityTraits: UIAccessibilityTraits {
|
||||
get { accessibilityView?.accessibilityTraits ?? [] }
|
||||
set { accessibilityView?.accessibilityTraits = newValue }
|
||||
}
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
@IBOutlet var titleLabel: UILabel!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
@IBOutlet var iconImageView: AppIconImageView!
|
||||
@IBOutlet var button: PillButton!
|
||||
@IBOutlet var buttonLabel: UILabel!
|
||||
@IBOutlet var betaBadgeView: UIView!
|
||||
|
||||
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||
@IBOutlet private var accessibilityView: UIView!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
accessibilityView.accessibilityTraits.formUnion(.button)
|
||||
|
||||
isAccessibilityElement = false
|
||||
accessibilityElements = [accessibilityView, button].compactMap { $0 }
|
||||
|
||||
betaBadgeView.isHidden = true
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
if tintAdjustmentMode != .dimmed {
|
||||
originalTintColor = tintColor
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppBannerView {
|
||||
func configure(for app: AppProtocol) {
|
||||
struct AppValues {
|
||||
var name: String
|
||||
var developerName: String?
|
||||
var isBeta: Bool = false
|
||||
|
||||
init(app: AppProtocol) {
|
||||
name = app.name
|
||||
|
||||
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||
developerName = storeApp.developerName
|
||||
|
||||
if storeApp.isBeta {
|
||||
name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||
isBeta = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let values = AppValues(app: app)
|
||||
titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||
betaBadgeView.isHidden = !values.isBeta
|
||||
|
||||
if let developerName = values.developerName {
|
||||
subtitleLabel.text = developerName
|
||||
accessibilityLabel = String(format: NSLocalizedString("%@ by %@", comment: ""), values.name, developerName)
|
||||
} else {
|
||||
subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||
accessibilityLabel = values.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppBannerView {
|
||||
func update() {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 22
|
||||
|
||||
subtitleLabel.textColor = originalTintColor ?? tintColor
|
||||
backgroundEffectView.backgroundColor = originalTintColor ?? tintColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppIconImageView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class AppIconImageView: UIImageView {
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentMode = .scaleAspectFill
|
||||
clipsToBounds = true
|
||||
|
||||
backgroundColor = .white
|
||||
|
||||
if #available(iOS 13, *) {
|
||||
self.layer.cornerCurve = .continuous
|
||||
} else {
|
||||
if layer.responds(to: Selector(("continuousCorners"))) {
|
||||
layer.setValue(true, forKey: "continuousCorners")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Based off of 60pt icon having 12pt radius.
|
||||
let radius = bounds.height / 5
|
||||
layer.cornerRadius = radius
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// BackgroundTaskManager.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
public final class BackgroundTaskManager {
|
||||
public static let shared = BackgroundTaskManager()
|
||||
|
||||
private var isPlaying = false
|
||||
|
||||
private let audioEngine: AVAudioEngine
|
||||
private let player: AVAudioPlayerNode
|
||||
private let audioFile: AVAudioFile
|
||||
|
||||
private let audioEngineQueue: DispatchQueue
|
||||
|
||||
private init() {
|
||||
audioEngine = AVAudioEngine()
|
||||
audioEngine.mainMixerNode.outputVolume = 0.0
|
||||
|
||||
player = AVAudioPlayerNode()
|
||||
audioEngine.attach(player)
|
||||
|
||||
do {
|
||||
let audioFileURL = Bundle.main.url(forResource: "Silence", withExtension: "m4a")!
|
||||
|
||||
audioFile = try AVAudioFile(forReading: audioFileURL)
|
||||
audioEngine.connect(player, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
|
||||
} catch {
|
||||
fatalError("Error. \(error)")
|
||||
}
|
||||
|
||||
audioEngineQueue = DispatchQueue(label: "com.altstore.BackgroundTaskManager")
|
||||
}
|
||||
}
|
||||
|
||||
public extension BackgroundTaskManager {
|
||||
func performExtendedBackgroundTask(taskHandler: @escaping ((Result<Void, Error>, @escaping () -> Void) -> Void)) {
|
||||
func finish() {
|
||||
player.stop()
|
||||
audioEngine.stop()
|
||||
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
audioEngineQueue.sync {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
// Schedule audio file buffers.
|
||||
self.scheduleAudioFile()
|
||||
self.scheduleAudioFile()
|
||||
|
||||
let outputFormat = self.audioEngine.outputNode.outputFormat(forBus: 0)
|
||||
self.audioEngine.connect(self.audioEngine.mainMixerNode, to: self.audioEngine.outputNode, format: outputFormat)
|
||||
|
||||
try self.audioEngine.start()
|
||||
self.player.play()
|
||||
|
||||
self.isPlaying = true
|
||||
|
||||
taskHandler(.success(())) {
|
||||
finish()
|
||||
}
|
||||
} catch {
|
||||
taskHandler(.failure(error)) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackgroundTaskManager {
|
||||
func scheduleAudioFile() {
|
||||
player.scheduleFile(audioFile, at: nil) {
|
||||
self.audioEngineQueue.async {
|
||||
guard self.isPlaying else { return }
|
||||
self.scheduleAudioFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// BannerCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
final class BannerCollectionViewCell: UICollectionViewCell {
|
||||
private(set) var errorBadge: UIView?
|
||||
@IBOutlet private(set) var bannerView: AppBannerView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
let errorBadge = UIView()
|
||||
errorBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
errorBadge.isHidden = true
|
||||
self.addSubview(errorBadge)
|
||||
|
||||
// Solid background to make the X opaque white.
|
||||
let backgroundView = UIView()
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundView.backgroundColor = .white
|
||||
errorBadge.addSubview(backgroundView)
|
||||
|
||||
let badgeView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||
badgeView.tintColor = .systemRed
|
||||
errorBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
errorBadge.centerXAnchor.constraint(equalTo: self.bannerView.trailingAnchor, constant: -5),
|
||||
errorBadge.centerYAnchor.constraint(equalTo: self.bannerView.topAnchor, constant: 5),
|
||||
|
||||
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||
])
|
||||
|
||||
self.errorBadge = errorBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
57
SideStoreApp/Sources/SideStoreUIKit/Components/Button.swift
Normal file
57
SideStoreApp/Sources/SideStoreUIKit/Components/Button.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Button.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class Button: UIButton {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 20
|
||||
size.height += 10
|
||||
return size
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
setTitleColor(.white, for: .normal)
|
||||
|
||||
layer.masksToBounds = true
|
||||
layer.cornerRadius = 8
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
override var isEnabled: Bool {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Button {
|
||||
func update() {
|
||||
if isEnabled {
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
backgroundColor = .lightGray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// CollapsingTextView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class CollapsingTextView: UITextView {
|
||||
var isCollapsed = true {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var maximumNumberOfLines = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var lineSpacing: CGFloat = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
let moreButton = UIButton(type: .system)
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layoutManager.delegate = self
|
||||
|
||||
textContainerInset = .zero
|
||||
textContainer.lineFragmentPadding = 0
|
||||
textContainer.lineBreakMode = .byTruncatingTail
|
||||
textContainer.heightTracksTextView = true
|
||||
textContainer.widthTracksTextView = true
|
||||
|
||||
moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal)
|
||||
moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered)
|
||||
addSubview(moreButton)
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let font = font else { return }
|
||||
|
||||
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
|
||||
moreButton.titleLabel?.font = buttonFont
|
||||
|
||||
let buttonY = (font.lineHeight + lineSpacing) * CGFloat(maximumNumberOfLines - 1)
|
||||
let size = moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
||||
|
||||
let moreButtonFrame = CGRect(x: bounds.width - moreButton.bounds.width,
|
||||
y: buttonY,
|
||||
width: size.width,
|
||||
height: font.lineHeight)
|
||||
moreButton.frame = moreButtonFrame
|
||||
|
||||
if isCollapsed {
|
||||
textContainer.maximumNumberOfLines = maximumNumberOfLines
|
||||
|
||||
let boundingSize = attributedText.boundingRect(with: CGSize(width: textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(maximumNumberOfLines)
|
||||
|
||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded() {
|
||||
var exclusionFrame = moreButtonFrame
|
||||
exclusionFrame.origin.y += moreButton.bounds.midY
|
||||
exclusionFrame.size.width = bounds.width // Extra wide to make sure it wraps to next line.
|
||||
textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
||||
|
||||
moreButton.isHidden = false
|
||||
} else {
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
} else {
|
||||
textContainer.maximumNumberOfLines = 0
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CollapsingTextView {
|
||||
@objc func toggleCollapsed(_: UIButton) {
|
||||
isCollapsed.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
extension CollapsingTextView: NSLayoutManagerDelegate {
|
||||
func layoutManager(_: NSLayoutManager, lineSpacingAfterGlyphAt _: Int, withProposedLineFragmentRect _: CGRect) -> CGFloat {
|
||||
lineSpacing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// ForwardingNavigationController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ForwardingNavigationController: UINavigationController {
|
||||
override var childForStatusBarStyle: UIViewController? {
|
||||
self.topViewController
|
||||
}
|
||||
|
||||
override var childForStatusBarHidden: UIViewController? {
|
||||
topViewController
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// NavigationBar.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
@objc
|
||||
final class NavigationBar: UINavigationBar {
|
||||
@objc
|
||||
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
||||
|
||||
private let backgroundColorView = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
if #available(iOS 13, *) {
|
||||
let standardAppearance = UINavigationBarAppearance()
|
||||
standardAppearance.configureWithDefaultBackground()
|
||||
standardAppearance.shadowColor = nil
|
||||
|
||||
let edgeAppearance = UINavigationBarAppearance()
|
||||
edgeAppearance.configureWithOpaqueBackground()
|
||||
edgeAppearance.backgroundColor = self.barTintColor
|
||||
edgeAppearance.shadowColor = nil
|
||||
|
||||
if let tintColor = self.barTintColor {
|
||||
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
||||
|
||||
standardAppearance.backgroundColor = tintColor
|
||||
standardAppearance.titleTextAttributes = textAttributes
|
||||
standardAppearance.largeTitleTextAttributes = textAttributes
|
||||
|
||||
edgeAppearance.titleTextAttributes = textAttributes
|
||||
edgeAppearance.largeTitleTextAttributes = textAttributes
|
||||
} else {
|
||||
standardAppearance.backgroundColor = nil
|
||||
}
|
||||
|
||||
self.scrollEdgeAppearance = edgeAppearance
|
||||
self.standardAppearance = standardAppearance
|
||||
} else {
|
||||
shadowImage = UIImage()
|
||||
|
||||
if let tintColor = barTintColor {
|
||||
backgroundColorView.backgroundColor = tintColor
|
||||
|
||||
// Top = -50 to cover status bar area above navigation bar on any device.
|
||||
// Bottom = -1 to prevent a flickering gray line from appearing.
|
||||
addSubview(backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
|
||||
} else {
|
||||
barTintColor = .white
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
if backgroundColorView.superview != nil {
|
||||
insertSubview(backgroundColorView, at: 1)
|
||||
}
|
||||
|
||||
if automaticallyAdjustsItemPositions {
|
||||
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
||||
for contentView in subviews {
|
||||
guard NSStringFromClass(type(of: contentView)).contains("ContentView") else { continue }
|
||||
contentView.center.y -= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
SideStoreApp/Sources/SideStoreUIKit/Components/PillButton.swift
Normal file
172
SideStoreApp/Sources/SideStoreUIKit/Components/PillButton.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// PillButton.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class PillButton: UIButton {
|
||||
override var accessibilityValue: String? {
|
||||
get {
|
||||
guard progress != nil else { return super.accessibilityValue }
|
||||
return progressView.accessibilityValue
|
||||
}
|
||||
set { super.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
var progress: Progress? {
|
||||
didSet {
|
||||
progressView.progress = Float(progress?.fractionCompleted ?? 0)
|
||||
progressView.observedProgress = progress
|
||||
|
||||
let isUserInteractionEnabled = self.isUserInteractionEnabled
|
||||
isIndicatingActivity = (progress != nil)
|
||||
self.isUserInteractionEnabled = isUserInteractionEnabled
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
var progressTintColor: UIColor? {
|
||||
get {
|
||||
progressView.progressTintColor
|
||||
}
|
||||
set {
|
||||
progressView.progressTintColor = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var countdownDate: Date? {
|
||||
didSet {
|
||||
isEnabled = (countdownDate == nil)
|
||||
displayLink.isPaused = (countdownDate == nil)
|
||||
|
||||
if countdownDate == nil {
|
||||
setTitle(nil, for: .disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
|
||||
private lazy var displayLink: CADisplayLink = {
|
||||
let displayLink = CADisplayLink(target: self, selector: #selector(PillButton.updateCountdown))
|
||||
displayLink.preferredFramesPerSecond = 15
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .main, forMode: .common)
|
||||
return displayLink
|
||||
}()
|
||||
|
||||
private let dateComponentsFormatter: DateComponentsFormatter = {
|
||||
let dateComponentsFormatter = DateComponentsFormatter()
|
||||
dateComponentsFormatter.zeroFormattingBehavior = [.pad]
|
||||
dateComponentsFormatter.collapsesLargestUnit = false
|
||||
return dateComponentsFormatter
|
||||
}()
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 26
|
||||
size.height += 3
|
||||
return size
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layer.masksToBounds = true
|
||||
accessibilityTraits.formUnion([.updatesFrequently, .button])
|
||||
|
||||
activityIndicatorView.style = .medium
|
||||
activityIndicatorView.isUserInteractionEnabled = false
|
||||
|
||||
progressView.progress = 0
|
||||
progressView.trackImage = UIImage()
|
||||
progressView.isUserInteractionEnabled = false
|
||||
addSubview(progressView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
progressView.bounds.size.width = bounds.width
|
||||
|
||||
let scale = bounds.height / progressView.bounds.height
|
||||
|
||||
progressView.transform = CGAffineTransform.identity.scaledBy(x: 1, y: scale)
|
||||
progressView.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
|
||||
layer.cornerRadius = bounds.midY
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private extension PillButton {
|
||||
func update() {
|
||||
if progress == nil {
|
||||
setTitleColor(.white, for: .normal)
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
setTitleColor(tintColor, for: .normal)
|
||||
backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
}
|
||||
|
||||
progressView.progressTintColor = tintColor
|
||||
}
|
||||
|
||||
@objc func updateCountdown() {
|
||||
guard let endDate = countdownDate else { return }
|
||||
|
||||
let startDate = Date()
|
||||
|
||||
let interval = endDate.timeIntervalSince(startDate)
|
||||
guard interval > 0 else {
|
||||
isEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
let text: String?
|
||||
|
||||
if interval < (1 * 60 * 60) {
|
||||
dateComponentsFormatter.unitsStyle = .positional
|
||||
dateComponentsFormatter.allowedUnits = [.minute, .second]
|
||||
|
||||
text = dateComponentsFormatter.string(from: startDate, to: endDate)
|
||||
} else if interval < (2 * 24 * 60 * 60) {
|
||||
dateComponentsFormatter.unitsStyle = .positional
|
||||
dateComponentsFormatter.allowedUnits = [.hour, .minute, .second]
|
||||
|
||||
text = dateComponentsFormatter.string(from: startDate, to: endDate)
|
||||
} else {
|
||||
dateComponentsFormatter.unitsStyle = .full
|
||||
dateComponentsFormatter.allowedUnits = [.day]
|
||||
|
||||
let numberOfDays = endDate.numberOfCalendarDays(since: startDate)
|
||||
text = String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays))
|
||||
}
|
||||
|
||||
if let text = text {
|
||||
UIView.performWithoutAnimation {
|
||||
self.isEnabled = false
|
||||
self.setTitle(text, for: .disabled)
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
} else {
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// TextCollectionReusableView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
public class TextCollectionReusableView: UICollectionReusableView {
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
|
||||
@IBOutlet var topLayoutConstraint: NSLayoutConstraint!
|
||||
@IBOutlet var bottomLayoutConstraint: NSLayoutConstraint!
|
||||
@IBOutlet var leadingLayoutConstraint: NSLayoutConstraint!
|
||||
@IBOutlet var trailingLayoutConstraint: NSLayoutConstraint!
|
||||
}
|
||||
115
SideStoreApp/Sources/SideStoreUIKit/Components/ToastView.swift
Normal file
115
SideStoreApp/Sources/SideStoreUIKit/Components/ToastView.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// ToastView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
import SideStoreCore
|
||||
import SideKit
|
||||
import AltSign
|
||||
|
||||
extension TimeInterval {
|
||||
static let shortToastViewDuration = 4.0
|
||||
static let longToastViewDuration = 8.0
|
||||
}
|
||||
|
||||
final class ToastView: RSTToastView {
|
||||
var preferredDuration: TimeInterval
|
||||
|
||||
override init(text: String, detailText detailedText: String?) {
|
||||
if detailedText == nil {
|
||||
preferredDuration = .shortToastViewDuration
|
||||
} else {
|
||||
preferredDuration = .longToastViewDuration
|
||||
}
|
||||
|
||||
super.init(text: text, detailText: detailedText)
|
||||
|
||||
isAccessibilityElement = true
|
||||
|
||||
layoutMargins = UIEdgeInsets(top: 8, left: 16, bottom: 10, right: 16)
|
||||
setNeedsLayout()
|
||||
|
||||
if let stackView = textLabel.superview as? UIStackView {
|
||||
// RSTToastView does not expose stack view containing labels,
|
||||
// so we access it indirectly as the labels' superview.
|
||||
stackView.spacing = (detailedText != nil) ? 4.0 : 0.0
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(error: Error) {
|
||||
var error = error as NSError
|
||||
var underlyingError = error.underlyingError
|
||||
|
||||
var preferredDuration: TimeInterval?
|
||||
|
||||
if
|
||||
let unwrappedUnderlyingError = underlyingError,
|
||||
error.domain == AltServerErrorDomain && error.code == -1 //ALTServerError.underlyingError().rawValue
|
||||
{
|
||||
// Treat underlyingError as the primary error.
|
||||
|
||||
error = unwrappedUnderlyingError as NSError
|
||||
underlyingError = nil
|
||||
|
||||
preferredDuration = .longToastViewDuration
|
||||
}
|
||||
|
||||
let text: String
|
||||
let detailText: String?
|
||||
|
||||
if let failure = error.localizedFailure {
|
||||
text = failure
|
||||
detailText = error.localizedFailureReason ?? error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription ?? error.localizedDescription
|
||||
} else if let reason = error.localizedFailureReason {
|
||||
text = reason
|
||||
detailText = error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription
|
||||
} else {
|
||||
text = error.localizedDescription
|
||||
detailText = underlyingError?.localizedDescription ?? error.localizedRecoverySuggestion
|
||||
}
|
||||
|
||||
self.init(text: text, detailText: detailText)
|
||||
|
||||
if let preferredDuration = preferredDuration {
|
||||
self.preferredDuration = preferredDuration
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Rough calculation to determine height of ToastView with one-line textLabel.
|
||||
let minimumHeight = textLabel.font.lineHeight.rounded() + 18
|
||||
layer.cornerRadius = minimumHeight / 2
|
||||
}
|
||||
|
||||
func show(in viewController: UIViewController) {
|
||||
show(in: viewController.navigationController?.view ?? viewController.view, duration: preferredDuration)
|
||||
}
|
||||
|
||||
override func show(in view: UIView, duration: TimeInterval) {
|
||||
super.show(in: view, duration: duration)
|
||||
|
||||
let announcement = (textLabel.text ?? "") + ". " + (detailTextLabel.text ?? "")
|
||||
accessibilityLabel = announcement
|
||||
|
||||
// Minimum 0.75 delay to prevent announcement being cut off by VoiceOver.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
UIAccessibility.post(notification: .announcement, argument: announcement)
|
||||
}
|
||||
}
|
||||
|
||||
override func show(in view: UIView) {
|
||||
show(in: view, duration: preferredDuration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// FileManager+DirectorySize.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/31/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
extension FileManager {
|
||||
func directorySize(at directoryURL: URL) -> Int? {
|
||||
guard let enumerator = FileManager.default.enumerator(at: directoryURL, includingPropertiesForKeys: [.fileSizeKey]) else { return nil }
|
||||
|
||||
var total = 0
|
||||
|
||||
for case let fileURL as URL in enumerator {
|
||||
do {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
guard let fileSize = resourceValues.fileSize else { continue }
|
||||
|
||||
total += fileSize
|
||||
} catch {
|
||||
os_log("Failed to read file size for item: %@. %@", type: .error, fileURL.absoluteString, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// INInteraction+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/4/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Intents
|
||||
|
||||
// Requires iOS 14 in-app intent handling.
|
||||
@available(iOS 14, *)
|
||||
extension INInteraction {
|
||||
static func refreshAllApps() -> INInteraction {
|
||||
let refreshAllIntent = RefreshAllIntent()
|
||||
refreshAllIntent.suggestedInvocationPhrase = NSString.deferredLocalizedIntentsString(with: "Refresh my apps") as String
|
||||
|
||||
let interaction = INInteraction(intent: refreshAllIntent, response: nil)
|
||||
return interaction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// OSLog+SideStore.swift
|
||||
// SideStore
|
||||
//
|
||||
// Created by Joseph Mattiello on 11/16/22.
|
||||
// Copyright © 2022 SideStore. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
public let customLog = OSLog(subsystem: "org.sidestore.sidestore",
|
||||
category: "ios")
|
||||
|
||||
public extension OSLog {
|
||||
/// Error logger extension
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
static func error(_ message: StaticString, _ args: CVarArg...) {
|
||||
os_log(message, log: customLog, type: .error, args)
|
||||
}
|
||||
|
||||
/// Info logger extension
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
static func info(_ message: StaticString, _ args: CVarArg...) {
|
||||
os_log(message, log: customLog, type: .info, args)
|
||||
}
|
||||
|
||||
/// Debug logger extension
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
static func debug(_ message: StaticString, _ args: CVarArg...) {
|
||||
os_log(message, log: customLog, type: .debug, args)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add file,line,function to messages? -- @JoeMatt
|
||||
|
||||
/// Error logger convenience method for SideStore logging
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
public func ELOG(_ message: StaticString, file _: StaticString = #file, function _: StaticString = #function, line _: UInt = #line, _ args: CVarArg...) {
|
||||
OSLog.error(message, args)
|
||||
}
|
||||
|
||||
/// Info logger convenience method for SideStore logging
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
public func ILOG(_ message: StaticString, file _: StaticString = #file, function _: StaticString = #function, line _: UInt = #line, _ args: CVarArg...) {
|
||||
OSLog.info(message, args)
|
||||
}
|
||||
|
||||
/// Debug logger convenience method for SideStore logging
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
public func DLOG(_ message: StaticString, file _: StaticString = #file, function _: StaticString = #function, line _: UInt = #line, _ args: CVarArg...) {
|
||||
OSLog.debug(message, args)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// UIApplication+AppExtension.swift
|
||||
// DeltaCore
|
||||
//
|
||||
// Created by Riley Testut on 6/14/18.
|
||||
// Copyright © 2018 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIApplication {
|
||||
// Cannot normally use UIApplication.shared from extensions, so we get around this by calling value(forKey:).
|
||||
class var alt_shared: UIApplication? {
|
||||
UIApplication.value(forKey: "sharedApplication") as? UIApplication
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// UIColor+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SideStoreCore
|
||||
|
||||
public extension UIColor {
|
||||
private static let colorBundle = Bundle(for: DatabaseManager.self)
|
||||
|
||||
static let altPrimary = UIColor(named: "Primary", in: colorBundle, compatibleWith: nil)!
|
||||
static let deltaPrimary = UIColor(named: "DeltaPrimary", in: colorBundle, compatibleWith: nil)
|
||||
|
||||
static let altPink = UIColor(named: "Pink", in: colorBundle, compatibleWith: nil)!
|
||||
|
||||
static let refreshRed = UIColor(named: "RefreshRed", in: colorBundle, compatibleWith: nil)!
|
||||
static let refreshOrange = UIColor(named: "RefreshOrange", in: colorBundle, compatibleWith: nil)!
|
||||
static let refreshYellow = UIColor(named: "RefreshYellow", in: colorBundle, compatibleWith: nil)!
|
||||
static let refreshGreen = UIColor(named: "RefreshGreen", in: colorBundle, compatibleWith: nil)!
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// UIDevice+Jailbreak.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/5/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import ARKit
|
||||
import UIKit
|
||||
|
||||
extension UIDevice {
|
||||
var isJailbroken: Bool {
|
||||
if
|
||||
FileManager.default.fileExists(atPath: "/Applications/Cydia.app") ||
|
||||
FileManager.default.fileExists(atPath: "/Library/MobileSubstrate/MobileSubstrate.dylib") ||
|
||||
FileManager.default.fileExists(atPath: "/bin/bash") ||
|
||||
FileManager.default.fileExists(atPath: "/usr/sbin/sshd") ||
|
||||
FileManager.default.fileExists(atPath: "/etc/apt") ||
|
||||
FileManager.default.fileExists(atPath: "/private/var/lib/apt/") ||
|
||||
UIApplication.shared.canOpenURL(URL(string: "cydia://")!)
|
||||
{
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
var supportsFugu14: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return true
|
||||
#else
|
||||
// Fugu14 is supported on devices with an A12 processor or better.
|
||||
// ARKit 3 is only supported by devices with an A12 processor or better, according to the documentation.
|
||||
return ARBodyTrackingConfiguration.isSupported
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
var isUntetheredJailbreakRequired: Bool {
|
||||
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
||||
|
||||
let isUntetheredJailbreakRequired = ProcessInfo.processInfo.isOperatingSystemAtLeast(ios14_4)
|
||||
return isUntetheredJailbreakRequired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// UIDevice+Vibration.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/1/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioToolbox
|
||||
import CoreHaptics
|
||||
import UIKit
|
||||
|
||||
private extension SystemSoundID {
|
||||
static let pop = SystemSoundID(1520)
|
||||
static let cancelled = SystemSoundID(1521)
|
||||
static let tryAgain = SystemSoundID(1102)
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension UIDevice {
|
||||
enum VibrationPattern {
|
||||
case success
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension UIDevice {
|
||||
var isVibrationSupported: Bool {
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics
|
||||
}
|
||||
|
||||
func vibrate(pattern: VibrationPattern) {
|
||||
guard isVibrationSupported else { return }
|
||||
|
||||
switch pattern {
|
||||
case .success: AudioServicesPlaySystemSound(.tryAgain)
|
||||
case .error: AudioServicesPlaySystemSound(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// UIScreen+CompactHeight.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/6/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIScreen {
|
||||
var isExtraCompactHeight: Bool {
|
||||
fixedCoordinateSpace.bounds.height < 600
|
||||
}
|
||||
}
|
||||
1583
SideStoreApp/Sources/SideStoreUIKit/Managing Apps/AppManager.swift
Normal file
1583
SideStoreApp/Sources/SideStoreUIKit/Managing Apps/AppManager.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// AppManagerErrors.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
public extension AppManager {
|
||||
struct FetchSourcesError: LocalizedError, CustomNSError {
|
||||
public private(set) var primaryError: Error?
|
||||
|
||||
public private(set) var sources: Set<Source>?
|
||||
public private(set) var errors = [Source: Error]()
|
||||
|
||||
public private(set) var managedObjectContext: NSManagedObjectContext?
|
||||
|
||||
public var errorDescription: String? {
|
||||
if let error = primaryError {
|
||||
return error.localizedDescription
|
||||
} else {
|
||||
var localizedDescription: String?
|
||||
|
||||
managedObjectContext?.performAndWait {
|
||||
if self.sources?.count == 1 {
|
||||
localizedDescription = NSLocalizedString("Could not refresh store.", comment: "")
|
||||
} else if self.errors.count == 1 {
|
||||
guard let source = self.errors.keys.first else { return }
|
||||
localizedDescription = String(format: NSLocalizedString("Could not refresh source “%@”.", comment: ""), source.name)
|
||||
} else {
|
||||
localizedDescription = String(format: NSLocalizedString("Could not refresh %@ sources.", comment: ""), NSNumber(value: self.errors.count))
|
||||
}
|
||||
}
|
||||
|
||||
return localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
if let error = primaryError as NSError? {
|
||||
return error.localizedRecoverySuggestion
|
||||
} else if errors.count == 1 {
|
||||
return nil
|
||||
} else {
|
||||
return NSLocalizedString("Tap to view source errors.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
public var errorUserInfo: [String: Any] {
|
||||
guard let error = errors.values.first, errors.count == 1 else { return [:] }
|
||||
return [NSUnderlyingErrorKey: error]
|
||||
}
|
||||
|
||||
public init(_ error: Error) {
|
||||
primaryError = error
|
||||
}
|
||||
|
||||
public init(sources: Set<Source>, errors: [Source: Error], context: NSManagedObjectContext) {
|
||||
self.sources = sources
|
||||
self.errors = errors
|
||||
managedObjectContext = context
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// AppPermission+UIKit.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import SideStoreCore
|
||||
import UIKit
|
||||
|
||||
// ALTAppPermissionType UIKit Extensions
|
||||
public extension ALTAppPermissionType {
|
||||
var icon: UIImage? {
|
||||
switch self {
|
||||
case .photos: return UIImage(systemName: "photo.on.rectangle.angled")
|
||||
case .camera: return UIImage(systemName: "camera.fill")
|
||||
case .location: return UIImage(systemName: "location.fill")
|
||||
case .contacts: return UIImage(systemName: "person.2.fill")
|
||||
case .reminders: return UIImage(systemName: "checklist")
|
||||
case .appleMusic: return UIImage(systemName: "music.note")
|
||||
case .microphone: return UIImage(systemName: "mic.fill")
|
||||
case .speechRecognition: return UIImage(systemName: "waveform.and.mic")
|
||||
case .backgroundAudio: return UIImage(systemName: "speaker.fill")
|
||||
case .backgroundFetch: return UIImage(systemName: "square.and.arrow.down")
|
||||
case .bluetooth: return UIImage(systemName: "wave.3.right")
|
||||
case .network: return UIImage(systemName: "network")
|
||||
case .calendars: return UIImage(systemName: "calendar")
|
||||
case .touchID: return UIImage(systemName: "touchid")
|
||||
case .faceID: return UIImage(systemName: "faceid")
|
||||
case .siri: return UIImage(systemName: "mic.and.signal.meter.fill")
|
||||
case .motion: return UIImage(systemName: "figure.walk.motion")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// InstalledAppsCollectionHeaderView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/9/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class InstalledAppsCollectionHeaderView: UICollectionReusableView {
|
||||
let textLabel: UILabel
|
||||
let button: UIButton
|
||||
|
||||
override init(frame: CGRect) {
|
||||
textLabel = UILabel()
|
||||
textLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
textLabel.font = UIFont.systemFont(ofSize: 24, weight: .bold)
|
||||
textLabel.accessibilityTraits.insert(.header)
|
||||
|
||||
button = UIButton(type: .system)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
addSubview(textLabel)
|
||||
addSubview(button)
|
||||
|
||||
NSLayoutConstraint.activate([textLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
|
||||
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor)])
|
||||
|
||||
NSLayoutConstraint.activate([button.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
|
||||
button.firstBaselineAnchor.constraint(equalTo: textLabel.firstBaselineAnchor)])
|
||||
|
||||
preservesSuperviewLayoutMargins = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// MyAppsComponents.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/17/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import RoxasUIKit
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
final class InstalledAppCollectionViewCell: UICollectionViewCell {
|
||||
private(set) var deactivateBadge: UIView?
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
let deactivateBadge = UIView()
|
||||
deactivateBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
deactivateBadge.isHidden = true
|
||||
self.addSubview(deactivateBadge)
|
||||
|
||||
// Solid background to make the X opaque white.
|
||||
let backgroundView = UIView()
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundView.backgroundColor = .white
|
||||
deactivateBadge.addSubview(backgroundView)
|
||||
|
||||
let badgeView = UIImageView(image: UIImage(systemName: "xmark.circle.fill"))
|
||||
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||
badgeView.tintColor = .systemRed
|
||||
deactivateBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
deactivateBadge.centerXAnchor.constraint(equalTo: self.bannerView.iconImageView.trailingAnchor),
|
||||
deactivateBadge.centerYAnchor.constraint(equalTo: self.bannerView.iconImageView.topAnchor),
|
||||
|
||||
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||
])
|
||||
|
||||
self.deactivateBadge = deactivateBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
final class InstalledAppsCollectionFooterView: UICollectionReusableView {
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
@IBOutlet var button: UIButton!
|
||||
}
|
||||
|
||||
@objc
|
||||
final class NoUpdatesCollectionViewCell: UICollectionViewCell {
|
||||
@IBOutlet var blurView: UIVisualEffectView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
final class UpdatesCollectionHeaderView: UICollectionReusableView {
|
||||
let button = PillButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setTitle(">", for: .normal)
|
||||
addSubview(button)
|
||||
|
||||
NSLayoutConstraint.activate([button.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
||||
button.topAnchor.constraint(equalTo: topAnchor),
|
||||
button.widthAnchor.constraint(equalToConstant: 50),
|
||||
button.heightAnchor.constraint(equalToConstant: 26)])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// UpdateCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/16/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UpdateCollectionViewCell {
|
||||
enum Mode {
|
||||
case collapsed
|
||||
case expanded
|
||||
}
|
||||
}
|
||||
|
||||
@objc final class UpdateCollectionViewCell: UICollectionViewCell {
|
||||
var mode: Mode = .expanded {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
@IBOutlet var versionDescriptionTitleLabel: UILabel!
|
||||
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
|
||||
|
||||
@IBOutlet private var blurView: UIVisualEffectView!
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
// Prevent temporary unsatisfiable constraint errors due to UIView-Encapsulated-Layout constraints.
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
bannerView.backgroundEffectView.isHidden = true
|
||||
bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
|
||||
|
||||
blurView.layer.cornerRadius = 20
|
||||
blurView.layer.masksToBounds = true
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
if tintAdjustmentMode != .dimmed {
|
||||
originalTintColor = tintColor
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func apply(_: UICollectionViewLayoutAttributes) {
|
||||
// Animates transition to new attributes.
|
||||
let animator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let view = super.hitTest(point, with: event)
|
||||
|
||||
if view == versionDescriptionTextView {
|
||||
// Forward touches on the text view (but not on the nested "more" button)
|
||||
// so cell selection works as expected.
|
||||
return self
|
||||
} else {
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UpdateCollectionViewCell {
|
||||
func update() {
|
||||
switch mode {
|
||||
case .collapsed: versionDescriptionTextView.isCollapsed = true
|
||||
case .expanded: versionDescriptionTextView.isCollapsed = false
|
||||
}
|
||||
|
||||
versionDescriptionTitleLabel.textColor = originalTintColor ?? tintColor
|
||||
blurView.backgroundColor = originalTintColor ?? tintColor
|
||||
bannerView.button.progressTintColor = originalTintColor ?? tintColor
|
||||
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// NewsCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/29/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
final class NewsCollectionViewCell: UICollectionViewCell {
|
||||
@IBOutlet var titleLabel: UILabel!
|
||||
@IBOutlet var captionLabel: UILabel!
|
||||
@IBOutlet var imageView: UIImageView!
|
||||
@IBOutlet var contentBackgroundView: UIView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
contentBackgroundView.layer.cornerRadius = 30
|
||||
contentBackgroundView.clipsToBounds = true
|
||||
|
||||
imageView.layer.cornerRadius = 30
|
||||
imageView.clipsToBounds = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//
|
||||
// NewsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/29/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SafariServices
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
import Nuke
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
private final class AppBannerFooterView: UICollectionReusableView {
|
||||
let bannerView = AppBannerView(frame: .zero)
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: nil, action: nil)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
bannerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(bannerView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bannerView.topAnchor.constraint(equalTo: topAnchor),
|
||||
bannerView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
bannerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
|
||||
bannerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
final class NewsViewController: UICollectionViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
|
||||
private var prototypeCell: NewsCollectionViewCell!
|
||||
|
||||
private var loadingState: LoadingState = .loading {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
// Cache
|
||||
private var cachedCellSizes = [String: CGSize]()
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(NewsViewController.importApp(_:)), name: SideStoreAppDelegate.importAppDeepLinkNotification, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
prototypeCell = NewsCollectionViewCell.instantiate(with: NewsCollectionViewCell.nib!)
|
||||
prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
collectionView.prefetchDataSource = dataSource
|
||||
|
||||
collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
collectionView.register(AppBannerFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner")
|
||||
|
||||
registerForPreviewing(with: self, sourceView: collectionView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
fetchSource()
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
if collectionView.contentInset.bottom != 20 {
|
||||
// Triggers collection view update in iOS 13, which crashes if we do it in viewDidLoad()
|
||||
// since the database might not be loaded yet.
|
||||
collectionView.contentInset.bottom = 20
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction private func unwindFromSourcesViewController(_: UIStoryboardSegue) {
|
||||
fetchSource()
|
||||
}
|
||||
}
|
||||
|
||||
private extension NewsViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage> {
|
||||
let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.date, ascending: false),
|
||||
NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true),
|
||||
NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)]
|
||||
|
||||
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.objectID), cacheName: nil)
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>(fetchedResultsController: fetchedResultsController)
|
||||
dataSource.proxy = self
|
||||
dataSource.cellConfigurationHandler = { cell, newsItem, _ in
|
||||
let cell = cell as! NewsCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
cell.titleLabel.text = newsItem.title
|
||||
cell.captionLabel.text = newsItem.caption
|
||||
cell.contentBackgroundView.backgroundColor = newsItem.tintColor
|
||||
|
||||
cell.imageView.image = nil
|
||||
|
||||
if newsItem.imageURL != nil {
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
cell.imageView.isHidden = false
|
||||
} else {
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.isHidden = true
|
||||
}
|
||||
|
||||
cell.isAccessibilityElement = true
|
||||
cell.accessibilityLabel = (cell.titleLabel.text ?? "") + ". " + (cell.captionLabel.text ?? "")
|
||||
|
||||
if newsItem.storeApp != nil || newsItem.externalURL != nil {
|
||||
cell.accessibilityTraits.insert(.button)
|
||||
} else {
|
||||
cell.accessibilityTraits.remove(.button)
|
||||
}
|
||||
}
|
||||
dataSource.prefetchHandler = { newsItem, _, completionHandler in
|
||||
guard let imageURL = newsItem.imageURL else { return nil }
|
||||
|
||||
return RSTAsyncBlockOperation { operation in
|
||||
ImagePipeline.shared.loadImage(with: imageURL, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! NewsCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error {
|
||||
os_log("Error loading image: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.placeholderView = placeholderView
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func fetchSource() {
|
||||
loadingState = .loading
|
||||
|
||||
AppManager.shared.fetchSources { result in
|
||||
do {
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.loadingState = .finished(.success(()))
|
||||
}
|
||||
} catch let error as AppManager.FetchSourcesError {
|
||||
try error.managedObjectContext?.save()
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
if self.dataSource.itemCount > 0 {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
|
||||
self.loadingState = .finished(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
switch loadingState {
|
||||
case .loading:
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||
|
||||
placeholderView.activityIndicatorView.startAnimating()
|
||||
|
||||
case let .finished(.failure(error)):
|
||||
placeholderView.textLabel.isHidden = false
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch News", comment: "")
|
||||
placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
case .finished(.success):
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NewsViewController {
|
||||
@objc func handleTapGesture(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard let footerView = gestureRecognizer.view as? UICollectionReusableView else { return }
|
||||
|
||||
let indexPaths = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
|
||||
|
||||
guard let indexPath = indexPaths.first(where: { indexPath -> Bool in
|
||||
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
||||
return supplementaryView == footerView
|
||||
}) else { return }
|
||||
|
||||
let item = dataSource.item(at: indexPath)
|
||||
guard let storeApp = item.storeApp else { return }
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
||||
navigationController?.pushViewController(appViewController, animated: true)
|
||||
}
|
||||
|
||||
@objc func performAppAction(_ sender: PillButton) {
|
||||
let point = collectionView.convert(sender.center, from: sender.superview)
|
||||
let indexPaths = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
|
||||
|
||||
guard let indexPath = indexPaths.first(where: { indexPath -> Bool in
|
||||
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
||||
return supplementaryView?.frame.contains(point) ?? false
|
||||
}) else { return }
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
guard let storeApp = app.storeApp else { return }
|
||||
|
||||
if let installedApp = app.storeApp?.installedApp {
|
||||
open(installedApp)
|
||||
} else {
|
||||
install(storeApp, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath) {
|
||||
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(storeApp, presentingViewController: self) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .failure(OperationError.cancelled): break // Ignore
|
||||
case let .failure(error):
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
|
||||
case .success: os_log("Installed app: %@", type: .info, storeApp.bundleIdentifier)
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp) {
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NewsViewController {
|
||||
@objc func importApp(_: Notification) {
|
||||
presentedViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension NewsViewController {
|
||||
override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
let newsItem = dataSource.item(at: indexPath)
|
||||
|
||||
if let externalURL = newsItem.externalURL {
|
||||
let safariViewController = SFSafariViewController(url: externalURL)
|
||||
safariViewController.preferredControlTintColor = newsItem.tintColor
|
||||
present(safariViewController, animated: true, completion: nil)
|
||||
} else if let storeApp = newsItem.storeApp {
|
||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
||||
navigationController?.pushViewController(appViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind _: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
let item = dataSource.item(at: indexPath)
|
||||
|
||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner", for: indexPath) as! AppBannerFooterView
|
||||
guard let storeApp = item.storeApp else { return footerView }
|
||||
|
||||
footerView.layoutMargins.left = view.layoutMargins.left
|
||||
footerView.layoutMargins.right = view.layoutMargins.right
|
||||
|
||||
footerView.bannerView.configure(for: storeApp)
|
||||
|
||||
footerView.bannerView.tintColor = storeApp.tintColor
|
||||
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:)))
|
||||
|
||||
footerView.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
if storeApp.installedApp == nil {
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
|
||||
footerView.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: storeApp)
|
||||
footerView.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = storeApp.latestVersion?.date, versionDate > Date() {
|
||||
footerView.bannerView.button.countdownDate = storeApp.versionDate
|
||||
} else {
|
||||
footerView.bannerView.button.countdownDate = nil
|
||||
}
|
||||
} else {
|
||||
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name)
|
||||
footerView.bannerView.button.accessibilityValue = nil
|
||||
footerView.bannerView.button.progress = nil
|
||||
footerView.bannerView.button.countdownDate = nil
|
||||
}
|
||||
|
||||
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
|
||||
|
||||
return footerView
|
||||
}
|
||||
}
|
||||
|
||||
extension NewsViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let item = dataSource.item(at: indexPath)
|
||||
|
||||
if let previousSize = cachedCellSizes[item.identifier] {
|
||||
return previousSize
|
||||
}
|
||||
|
||||
let widthConstraint = prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
NSLayoutConstraint.activate([widthConstraint])
|
||||
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
||||
|
||||
dataSource.cellConfigurationHandler(prototypeCell, item, indexPath)
|
||||
|
||||
let size = prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
cachedCellSizes[item.identifier] = size
|
||||
return size
|
||||
}
|
||||
|
||||
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
|
||||
let item = dataSource.item(at: IndexPath(row: 0, section: section))
|
||||
|
||||
if item.storeApp != nil {
|
||||
return CGSize(width: 88, height: 88)
|
||||
} else {
|
||||
return .zero
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||
var insets = UIEdgeInsets(top: 30, left: 0, bottom: 13, right: 0)
|
||||
|
||||
if section == 0 {
|
||||
insets.top = 10
|
||||
}
|
||||
|
||||
return insets
|
||||
}
|
||||
}
|
||||
|
||||
extension NewsViewController: UIViewControllerPreviewingDelegate {
|
||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
|
||||
if let indexPath = collectionView.indexPathForItem(at: location), let cell = collectionView.cellForItem(at: indexPath) {
|
||||
// Previewing news item.
|
||||
|
||||
previewingContext.sourceRect = cell.frame
|
||||
|
||||
let newsItem = dataSource.item(at: indexPath)
|
||||
|
||||
if let externalURL = newsItem.externalURL {
|
||||
let safariViewController = SFSafariViewController(url: externalURL)
|
||||
safariViewController.preferredControlTintColor = newsItem.tintColor
|
||||
return safariViewController
|
||||
} else if let storeApp = newsItem.storeApp {
|
||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
||||
return appViewController
|
||||
}
|
||||
|
||||
return nil
|
||||
} else {
|
||||
// Previewing app banner (or nothing).
|
||||
|
||||
let indexPaths = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
|
||||
|
||||
guard let indexPath = indexPaths.first(where: { indexPath -> Bool in
|
||||
let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
||||
return layoutAttributes?.frame.contains(location) ?? false
|
||||
}) else { return nil }
|
||||
|
||||
guard let layoutAttributes = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) else { return nil }
|
||||
previewingContext.sourceRect = layoutAttributes.frame
|
||||
|
||||
let item = dataSource.item(at: indexPath)
|
||||
guard let storeApp = item.storeApp else { return nil }
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
||||
return appViewController
|
||||
}
|
||||
}
|
||||
|
||||
func previewingContext(_: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
|
||||
if let safariViewController = viewControllerToCommit as? SFSafariViewController {
|
||||
present(safariViewController, animated: true, completion: nil)
|
||||
} else {
|
||||
navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//
|
||||
// PatchViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/20/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import SideUIShared
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension PatchViewController {
|
||||
enum Step {
|
||||
case confirm
|
||||
case install
|
||||
case openApp
|
||||
case patchApp
|
||||
case reboot
|
||||
case refresh
|
||||
case finish
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
public final class PatchViewController: UIViewController {
|
||||
var patchApp: AnyApp?
|
||||
var installedApp: InstalledApp?
|
||||
|
||||
var completionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
private let context = AuthenticatedOperationContext()
|
||||
|
||||
private var currentStep: Step = .confirm {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonHandler: (() -> Void)?
|
||||
private var resignedApp: ALTApplication?
|
||||
|
||||
private lazy var temporaryDirectory: URL = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
private var didEnterBackgroundObservation: NSObjectProtocol?
|
||||
private weak var cancellableProgress: Progress?
|
||||
|
||||
@IBOutlet private var placeholderView: RSTPlaceholderView!
|
||||
@IBOutlet private var taskDescriptionLabel: UILabel!
|
||||
@IBOutlet private var pillButton: PillButton!
|
||||
@IBOutlet private var cancelBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private var cancelButton: UIButton!
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
isModalInPresentation = true
|
||||
|
||||
placeholderView.stackView.spacing = 20
|
||||
placeholderView.textLabel.textColor = .white
|
||||
|
||||
placeholderView.detailTextLabel.textAlignment = .left
|
||||
placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
self?.startProcess()
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
os_log("Failed to create temporary directory: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if installedApp != nil {
|
||||
refreshApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private extension PatchViewController {
|
||||
func update() {
|
||||
cancelButton.alpha = 0.0
|
||||
|
||||
switch currentStep {
|
||||
case .confirm:
|
||||
guard let app = patchApp else { break }
|
||||
|
||||
if UIDevice.current.isUntetheredJailbreakRequired {
|
||||
placeholderView.textLabel.text = NSLocalizedString("Jailbreak Requires Untethering", comment: "")
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak is untethered, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
|
||||
} else {
|
||||
placeholderView.textLabel.text = NSLocalizedString("Jailbreak Supports Untethering", comment: "")
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak has an untethered version, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
|
||||
}
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Install Untethered Jailbreak", comment: ""), for: .normal)
|
||||
|
||||
cancelButton.alpha = 1.0
|
||||
|
||||
case .install:
|
||||
guard let app = patchApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Installing %@ placeholder…", comment: ""), app.name)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("A placeholder app needs to be installed in order to prepare your device for untethering.\n\nThis may take a few moments.", comment: "")
|
||||
|
||||
case .openApp:
|
||||
placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
|
||||
|
||||
case .patchApp:
|
||||
guard let app = patchApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Patching %@ placeholder…", comment: ""), app.name)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("This will take a few moments. Please do not turn off the screen or leave the app until patching is complete.", comment: "")
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Patch Placeholder", comment: ""), for: .normal)
|
||||
|
||||
case .reboot:
|
||||
placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
|
||||
|
||||
case .refresh:
|
||||
guard let installedApp = installedApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Finish installing %@?", comment: ""), installedApp.name)
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("In order to finish jailbreaking this device, you need to install %@ then follow the instructions in the app.", comment: ""), installedApp.name)
|
||||
|
||||
pillButton.setTitle(String(format: NSLocalizedString("Install %@", comment: ""), installedApp.name), for: .normal)
|
||||
|
||||
case .finish:
|
||||
guard let installedApp = installedApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Finish in %@", comment: ""), installedApp.name)
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("Follow the instructions in %@ to finish jailbreaking this device.", comment: ""), installedApp.name)
|
||||
|
||||
pillButton.setTitle(String(format: NSLocalizedString("Open %@", comment: ""), installedApp.name), for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func present(_ error: Error, title: String) {
|
||||
DispatchQueue.main.async {
|
||||
let nsError = error as NSError
|
||||
|
||||
let alertController = UIAlertController(title: nsError.localizedFailure ?? title, message: error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
|
||||
self.setProgress(nil, description: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func setProgress(_ progress: Progress?, description: String?) {
|
||||
DispatchQueue.main.async {
|
||||
self.pillButton.progress = progress
|
||||
self.taskDescriptionLabel.text = description ?? " " // Use non-empty string to prevent label resizing itself.
|
||||
}
|
||||
}
|
||||
|
||||
func finish(with result: Result<Void, Error>) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: temporaryDirectory)
|
||||
} catch {
|
||||
os_log("Failed to remove temporary directory: %@", type: .error , error.localizedDescription)
|
||||
}
|
||||
|
||||
if let observation = didEnterBackgroundObservation {
|
||||
NotificationCenter.default.removeObserver(observation)
|
||||
}
|
||||
|
||||
completionHandler?(result)
|
||||
completionHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private extension PatchViewController {
|
||||
@IBAction func performButtonAction() {
|
||||
buttonHandler?()
|
||||
}
|
||||
|
||||
@IBAction func cancel() {
|
||||
finish(with: .success(()))
|
||||
|
||||
cancellableProgress?.cancel()
|
||||
}
|
||||
|
||||
@IBAction func installRegularJailbreak() {
|
||||
guard let app = patchApp else { return }
|
||||
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
if UIDevice.current.isUntetheredJailbreakRequired {
|
||||
title = NSLocalizedString("Untethering Required", comment: "")
|
||||
message = String(format: NSLocalizedString("%@ can not jailbreak this device unless you untether it first. Are you sure you want to install without untethering?", comment: ""), app.name)
|
||||
} else {
|
||||
title = NSLocalizedString("Untethering Recommended", comment: "")
|
||||
message = String(format: NSLocalizedString("Untethering this jailbreak will prevent %@ from expiring, even after 7 days or rebooting the device. Are you sure you want to install without untethering?", comment: ""), app.name)
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Install Without Untethering", comment: ""), style: .default) { _ in
|
||||
self.finish(with: .failure(OperationError.cancelled))
|
||||
})
|
||||
alertController.addAction(.cancel)
|
||||
present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private extension PatchViewController {
|
||||
func startProcess() {
|
||||
guard let patchApp = patchApp else { return }
|
||||
|
||||
currentStep = .install
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: patchApp) {
|
||||
// Cancel pending jailbreak app installation so we can start a new one.
|
||||
progress.cancel()
|
||||
}
|
||||
|
||||
let appURL = InstalledApp.fileURL(for: patchApp)
|
||||
let cachedAppURL = temporaryDirectory.appendingPathComponent("Cached.app")
|
||||
|
||||
do {
|
||||
// Make copy of original app, so we can replace the cached patch app with it later.
|
||||
try FileManager.default.copyItem(at: appURL, to: cachedAppURL, shouldReplace: true)
|
||||
} catch {
|
||||
present(error, title: NSLocalizedString("Could not back up jailbreak app.", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
var unzippingError: Error?
|
||||
let refreshGroup = AppManager.shared.install(patchApp, presentingViewController: self, context: context) { result in
|
||||
do {
|
||||
_ = try result.get()
|
||||
|
||||
if let unzippingError = unzippingError {
|
||||
throw unzippingError
|
||||
}
|
||||
|
||||
// Replace cached patch app with original app so we can resume installing it post-reboot.
|
||||
try FileManager.default.copyItem(at: cachedAppURL, to: appURL, shouldReplace: true)
|
||||
|
||||
self.openApp()
|
||||
} catch {
|
||||
self.present(error, title: String(format: NSLocalizedString("Could not install %@ placeholder.", comment: ""), patchApp.name))
|
||||
}
|
||||
}
|
||||
refreshGroup.beginInstallationHandler = { installedApp in
|
||||
do {
|
||||
// Replace patch app name with correct name.
|
||||
installedApp.name = patchApp.name
|
||||
|
||||
let ipaURL = installedApp.refreshedIPAURL
|
||||
let resignedAppURL = try FileManager.default.unzipAppBundle(at: ipaURL, toDirectory: self.temporaryDirectory)
|
||||
|
||||
self.resignedApp = ALTApplication(fileURL: resignedAppURL)
|
||||
} catch {
|
||||
os_log("Error unzipping app bundle: %@", type: .error , error.localizedDescription)
|
||||
unzippingError = error
|
||||
}
|
||||
}
|
||||
setProgress(refreshGroup.progress, description: nil)
|
||||
|
||||
cancellableProgress = refreshGroup.progress
|
||||
}
|
||||
|
||||
func openApp() {
|
||||
guard let patchApp = patchApp else { return }
|
||||
|
||||
setProgress(nil, description: nil)
|
||||
currentStep = .openApp
|
||||
|
||||
// This observation is willEnterForeground because patching starts immediately upon return.
|
||||
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { _ in
|
||||
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
|
||||
self.patchApplication()
|
||||
}
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
|
||||
let openURL = InstalledApp.openAppURL(for: patchApp)
|
||||
UIApplication.shared.open(openURL) { success in
|
||||
guard !success else { return }
|
||||
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func patchApplication() {
|
||||
guard let resignedApp = resignedApp else { return }
|
||||
|
||||
currentStep = .patchApp
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
self?.patchApplication()
|
||||
}
|
||||
|
||||
let patchAppOperation = AppManager.shared.patch(resignedApp: resignedApp, presentingViewController: self, context: context) { result in
|
||||
switch result {
|
||||
case let .failure(error): self.present(error, title: String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), resignedApp.name))
|
||||
case .success: self.rebootDevice()
|
||||
}
|
||||
}
|
||||
patchAppOperation.progressHandler = { progress, description in
|
||||
self.setProgress(progress, description: description)
|
||||
}
|
||||
cancellableProgress = patchAppOperation.progress
|
||||
}
|
||||
|
||||
func rebootDevice() {
|
||||
guard let patchApp = patchApp else { return }
|
||||
|
||||
setProgress(nil, description: nil)
|
||||
currentStep = .reboot
|
||||
|
||||
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
|
||||
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
|
||||
|
||||
var patchedApps = UserDefaults.standard.patchedApps ?? []
|
||||
if !patchedApps.contains(patchApp.bundleIdentifier) {
|
||||
patchedApps.append(patchApp.bundleIdentifier)
|
||||
UserDefaults.standard.patchedApps = patchedApps
|
||||
}
|
||||
|
||||
self.finish(with: .success(()))
|
||||
}
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
|
||||
let openURL = InstalledApp.openAppURL(for: patchApp)
|
||||
UIApplication.shared.open(openURL) { success in
|
||||
guard !success else { return }
|
||||
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func refreshApp() {
|
||||
guard let installedApp = installedApp else { return }
|
||||
|
||||
currentStep = .refresh
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
let tempApp = context.object(with: installedApp.objectID) as! InstalledApp
|
||||
tempApp.needsResign = true
|
||||
|
||||
let errorTitle = String(format: NSLocalizedString("Could not install %@.", comment: ""), tempApp.name)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
// Refreshing ensures we don't attempt to patch the app again,
|
||||
// since that is only checked when installing a new app.
|
||||
let refreshGroup = AppManager.shared.refresh([installedApp], presentingViewController: self, group: nil)
|
||||
refreshGroup.completionHandler = { [weak refreshGroup, weak self] results in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let (bundleIdentifier, result) = results.first else { throw refreshGroup?.context.error ?? OperationError.unknown }
|
||||
_ = try result.get()
|
||||
|
||||
if var patchedApps = UserDefaults.standard.patchedApps, let index = patchedApps.firstIndex(of: bundleIdentifier) {
|
||||
patchedApps.remove(at: index)
|
||||
UserDefaults.standard.patchedApps = patchedApps
|
||||
}
|
||||
|
||||
self.finish()
|
||||
} catch {
|
||||
self.present(error, title: errorTitle)
|
||||
}
|
||||
}
|
||||
self.setProgress(refreshGroup.progress, description: String(format: NSLocalizedString("Installing %@...", comment: ""), installedApp.name))
|
||||
}
|
||||
} catch {
|
||||
self.present(error, title: errorTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
guard let installedApp = installedApp else { return }
|
||||
|
||||
setProgress(nil, description: nil)
|
||||
currentStep = .finish
|
||||
|
||||
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
|
||||
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
|
||||
self.finish(with: .success(()))
|
||||
}
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
let appName = installedApp.name
|
||||
let openURL = installedApp.openAppURL
|
||||
|
||||
self.buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
|
||||
UIApplication.shared.open(openURL) { success in
|
||||
guard !success else { return }
|
||||
self.present(OperationError.openAppFailed(name: appName), title: String(format: NSLocalizedString("Could not open %@.", comment: ""), appName))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// SideStoreAppDelegate.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Intents
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import EmotionalDamage
|
||||
import RoxasUIKit
|
||||
|
||||
open class SideStoreAppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
}
|
||||
|
||||
public extension SideStoreAppDelegate {
|
||||
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
||||
static let importAppDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ImportAppDeepLinkNotification")
|
||||
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
||||
|
||||
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
||||
|
||||
static let importAppDeepLinkURLKey = "fileURL"
|
||||
static let appBackupResultKey = "result"
|
||||
static let addSourceDeepLinkURLKey = "sourceURL"
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
//
|
||||
// SourcesViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/17/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import OSLog
|
||||
#if canImport(Logging)
|
||||
import Logging
|
||||
#endif
|
||||
|
||||
struct SourceError: LocalizedError {
|
||||
enum Code {
|
||||
case unsupported
|
||||
}
|
||||
|
||||
var code: Code
|
||||
@Managed var source: Source
|
||||
|
||||
var errorDescription: String? {
|
||||
switch code {
|
||||
case .unsupported: return String(format: NSLocalizedString("The source “%@” is not supported by this version of SideStore.", comment: ""), $source.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(SourcesFooterView)
|
||||
private final class SourcesFooterView: TextCollectionReusableView {
|
||||
@IBOutlet var activityIndicatorView: UIActivityIndicatorView!
|
||||
@IBOutlet var textView: UITextView!
|
||||
}
|
||||
|
||||
extension SourcesViewController {
|
||||
private enum Section: Int, CaseIterable {
|
||||
case added
|
||||
case trusted
|
||||
}
|
||||
}
|
||||
|
||||
final class SourcesViewController: UICollectionViewController {
|
||||
var deepLinkSourceURL: URL? {
|
||||
didSet {
|
||||
guard let sourceURL = deepLinkSourceURL else { return }
|
||||
addSource(url: sourceURL)
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var addedSourcesDataSource = self.makeAddedSourcesDataSource()
|
||||
private lazy var trustedSourcesDataSource = self.makeTrustedSourcesDataSource()
|
||||
|
||||
private var fetchTrustedSourcesOperation: FetchTrustedSourcesOperation?
|
||||
private var fetchTrustedSourcesResult: Result<Void, Error>?
|
||||
private var _fetchTrustedSourcesContext: NSManagedObjectContext?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
|
||||
#if !BETA
|
||||
// Hide "Add Source" button for public version while in beta.
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if deepLinkSourceURL != nil {
|
||||
navigationItem.leftBarButtonItem?.isIndicatingActivity = true
|
||||
}
|
||||
|
||||
if fetchTrustedSourcesOperation == nil {
|
||||
fetchTrustedSources()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if let sourceURL = deepLinkSourceURL {
|
||||
addSource(url: sourceURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension SourcesViewController {
|
||||
func makeDataSource() -> RSTCompositeCollectionViewDataSource<Source> {
|
||||
let dataSource = RSTCompositeCollectionViewDataSource<Source>(dataSources: [addedSourcesDataSource, trustedSourcesDataSource])
|
||||
dataSource.proxy = self
|
||||
dataSource.cellConfigurationHandler = { cell, source, indexPath in
|
||||
let tintColor = UIColor.altPrimary
|
||||
|
||||
let cell = cell as! BannerCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
cell.tintColor = tintColor
|
||||
|
||||
cell.bannerView.iconImageView.isHidden = true
|
||||
cell.bannerView.buttonLabel.isHidden = true
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
switch Section.allCases[indexPath.section] {
|
||||
case .added:
|
||||
cell.bannerView.button.isHidden = true
|
||||
|
||||
case .trusted:
|
||||
// Quicker way to determine whether a source is already added than by reading from disk.
|
||||
if (self.addedSourcesDataSource.fetchedResultsController.fetchedObjects ?? []).contains(where: { $0.identifier == source.identifier }) {
|
||||
// Source exists in .added section, so hide the button.
|
||||
cell.bannerView.button.isHidden = true
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
let configuation = UIImage.SymbolConfiguration(pointSize: 24)
|
||||
|
||||
let imageAttachment = NSTextAttachment()
|
||||
imageAttachment.image = UIImage(systemName: "checkmark.circle", withConfiguration: configuation)?.withTintColor(.altPrimary)
|
||||
|
||||
let attributedText = NSAttributedString(attachment: imageAttachment)
|
||||
cell.bannerView.buttonLabel.attributedText = attributedText
|
||||
cell.bannerView.buttonLabel.textAlignment = .center
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
}
|
||||
} else {
|
||||
// Source does not exist in .added section, so show the button.
|
||||
cell.bannerView.button.isHidden = false
|
||||
cell.bannerView.buttonLabel.attributedText = nil
|
||||
}
|
||||
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("ADD", comment: ""), for: .normal)
|
||||
cell.bannerView.button.addTarget(self, action: #selector(SourcesViewController.addTrustedSource(_:)), for: .primaryActionTriggered)
|
||||
}
|
||||
|
||||
cell.bannerView.titleLabel.text = source.name
|
||||
cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString
|
||||
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||
|
||||
cell.errorBadge?.isHidden = (source.error == nil)
|
||||
|
||||
let attributedLabel = NSAttributedString(string: source.name + "\n" + source.sourceURL.absoluteString, attributes: [.accessibilitySpeechPunctuation: true])
|
||||
cell.bannerView.accessibilityAttributedLabel = attributedLabel
|
||||
cell.bannerView.accessibilityTraits.remove(.button)
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
cell.layoutIfNeeded()
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeAddedSourcesDataSource() -> RSTFetchedResultsCollectionViewDataSource<Source> {
|
||||
let fetchRequest = Source.fetchRequest() as NSFetchRequest<Source>
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Source.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Source.identifier, ascending: true)]
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewDataSource<Source>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeTrustedSourcesDataSource() -> RSTArrayCollectionViewDataSource<Source> {
|
||||
let dataSource = RSTArrayCollectionViewDataSource<Source>(items: [])
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension SourcesViewController {
|
||||
@IBAction func addSource() {
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Add Source", comment: ""), message: nil, preferredStyle: .alert)
|
||||
alertController.addTextField { textField in
|
||||
textField.placeholder = "https://apps.altstore.io"
|
||||
textField.textContentType = .URL
|
||||
}
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Add", comment: ""), style: .default) { _ in
|
||||
guard let text = alertController.textFields![0].text else { return }
|
||||
guard var sourceURL = URL(string: text) else { return }
|
||||
if sourceURL.scheme == nil {
|
||||
guard let httpsSourceURL = URL(string: "https://" + text) else { return }
|
||||
sourceURL = httpsSourceURL
|
||||
}
|
||||
|
||||
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
|
||||
|
||||
self.addSource(url: sourceURL) { _ in
|
||||
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
|
||||
}
|
||||
})
|
||||
|
||||
present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func addSource(url: URL, isTrusted: Bool = false, completionHandler: ((Result<Void, Error>) -> Void)? = nil) {
|
||||
guard view.window != nil else { return }
|
||||
|
||||
if url == deepLinkSourceURL {
|
||||
// Only handle deep link once.
|
||||
deepLinkSourceURL = nil
|
||||
}
|
||||
|
||||
func finish(_ result: Result<Void, Error>) {
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success: break
|
||||
case .failure(OperationError.cancelled): break
|
||||
case let .failure(error): self.present(error)
|
||||
}
|
||||
|
||||
self.collectionView.reloadSections([Section.trusted.rawValue])
|
||||
|
||||
completionHandler?(result)
|
||||
}
|
||||
}
|
||||
|
||||
var dependencies: [Foundation.Operation] = []
|
||||
if let fetchTrustedSourcesOperation = fetchTrustedSourcesOperation {
|
||||
// Must fetch trusted sources first to determine whether this is a trusted source.
|
||||
// We assume fetchTrustedSources() has already been called before this method.
|
||||
dependencies = [fetchTrustedSourcesOperation]
|
||||
}
|
||||
|
||||
AppManager.shared.fetchSource(sourceURL: url, dependencies: dependencies) { result in
|
||||
do {
|
||||
let source = try result.get()
|
||||
let sourceName = source.name
|
||||
let managedObjectContext = source.managedObjectContext
|
||||
|
||||
#if !BETA
|
||||
guard let trustedSourceIDs = UserDefaults.shared.trustedSourceIDs, trustedSourceIDs.contains(source.identifier) else { throw SourceError(code: .unsupported, source: source) }
|
||||
#endif
|
||||
|
||||
// Hide warning when adding a featured trusted source.
|
||||
let message = isTrusted ? nil : NSLocalizedString("Make sure to only add sources that you trust.", comment: "")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName),
|
||||
message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
|
||||
finish(.failure(OperationError.cancelled))
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Add Source", comment: ""), style: UIAlertAction.ok.style) { _ in
|
||||
managedObjectContext?.perform {
|
||||
do {
|
||||
try managedObjectContext?.save()
|
||||
finish(.success(()))
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
})
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func present(_ error: Error) {
|
||||
if let transitionCoordinator = transitionCoordinator {
|
||||
transitionCoordinator.animate(alongsideTransition: nil) { _ in
|
||||
self.present(error)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let nsError = error as NSError
|
||||
let message = nsError.userInfo[NSDebugDescriptionErrorKey] as? String ?? nsError.localizedRecoverySuggestion
|
||||
|
||||
let alertController = UIAlertController(title: error.localizedDescription, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func fetchTrustedSources() {
|
||||
func finish(_ result: Result<[Source], Error>) {
|
||||
fetchTrustedSourcesResult = result.map { _ in () }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
let sources = try result.get()
|
||||
os_log("Fetched trusted sources: %@", type: .info , sources.map { $0.identifier }.joined(separator: "\n"))
|
||||
|
||||
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
|
||||
self.trustedSourcesDataSource.setItems(sources, with: [sectionUpdate])
|
||||
} catch {
|
||||
os_log("Error fetching trusted sources: %@", type: .error , error.localizedDescription)
|
||||
|
||||
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
|
||||
self.trustedSourcesDataSource.setItems([], with: [sectionUpdate])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchTrustedSourcesOperation = AppManager.shared.fetchTrustedSources { result in
|
||||
switch result {
|
||||
case let .failure(error): finish(.failure(error))
|
||||
case let .success(trustedSources):
|
||||
// Cache trusted source IDs.
|
||||
UserDefaults.shared.trustedSourceIDs = trustedSources.map { $0.identifier }
|
||||
|
||||
// Don't show sources without a sourceURL.
|
||||
let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL }
|
||||
|
||||
// This context is never saved, but keeps the managed sources alive.
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
|
||||
self._fetchTrustedSourcesContext = context
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
var sourcesByURL = [URL: Source]()
|
||||
var fetchError: Error?
|
||||
|
||||
for sourceURL in featuredSourceURLs {
|
||||
dispatchGroup.enter()
|
||||
|
||||
AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in
|
||||
// Serialize access to sourcesByURL.
|
||||
context.performAndWait {
|
||||
switch result {
|
||||
case let .failure(error): fetchError = error
|
||||
case let .success(source): sourcesByURL[source.sourceURL] = source
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if let error = fetchError {
|
||||
os_log("fetch error: %@", type: .error, error.localizedErrorCode)
|
||||
// 1 error doesn't mean all trusted sources failed to load! Riley, why did you do this???????
|
||||
// finish(.failure(error))
|
||||
}
|
||||
let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] }
|
||||
finish(.success(sources))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func addTrustedSource(_ sender: PillButton) {
|
||||
let point = collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let completedProgress = Progress(totalUnitCount: 1)
|
||||
completedProgress.completedUnitCount = 1
|
||||
sender.progress = completedProgress
|
||||
|
||||
let source = dataSource.item(at: indexPath)
|
||||
addSource(url: source.sourceURL, isTrusted: true) { _ in
|
||||
// FIXME: Handle cell reuse.
|
||||
sender.progress = nil
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ source: Source) {
|
||||
let alertController = UIAlertController(title: String(format: NSLocalizedString("Are you sure you want to remove the source “%@”?", comment: ""), source.name),
|
||||
message: NSLocalizedString("Any apps you've installed from this source will remain, but they'll no longer receive any app updates.", comment: ""), preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: nil))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove Source", comment: ""), style: .destructive) { _ in
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
let source = context.object(with: source.objectID) as! Source
|
||||
context.delete(source)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.collectionView.reloadSections([Section.trusted.rawValue])
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.present(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension SourcesViewController {
|
||||
override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
|
||||
let source = dataSource.item(at: indexPath)
|
||||
guard let error = source.error else { return }
|
||||
|
||||
present(error)
|
||||
}
|
||||
}
|
||||
|
||||
extension SourcesViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
||||
CGSize(width: collectionView.bounds.width, height: 80)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
|
||||
let indexPath = IndexPath(row: 0, section: section)
|
||||
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
||||
|
||||
// Use this view to calculate the optimal size based on the collection view's width
|
||||
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
||||
withHorizontalFittingPriority: .required, // Width is fixed
|
||||
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||
return size
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
|
||||
guard Section(rawValue: section) == .trusted else { return .zero }
|
||||
|
||||
let indexPath = IndexPath(row: 0, section: section)
|
||||
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, at: indexPath)
|
||||
|
||||
// Use this view to calculate the optimal size based on the collection view's width
|
||||
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
||||
withHorizontalFittingPriority: .required, // Width is fixed
|
||||
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||
return size
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
let reuseIdentifier = (kind == UICollectionView.elementKindSectionHeader) ? "Header" : "Footer"
|
||||
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! TextCollectionReusableView
|
||||
headerView.layoutMargins.left = view.layoutMargins.left
|
||||
headerView.layoutMargins.right = view.layoutMargins.right
|
||||
|
||||
let almostRequiredPriority = UILayoutPriority(UILayoutPriority.required.rawValue - 1) // Can't be required or else we can't satisfy constraints when hidden (size = 0).
|
||||
headerView.leadingLayoutConstraint?.priority = almostRequiredPriority
|
||||
headerView.trailingLayoutConstraint?.priority = almostRequiredPriority
|
||||
headerView.topLayoutConstraint?.priority = almostRequiredPriority
|
||||
headerView.bottomLayoutConstraint?.priority = almostRequiredPriority
|
||||
|
||||
switch kind {
|
||||
case UICollectionView.elementKindSectionHeader:
|
||||
switch Section.allCases[indexPath.section] {
|
||||
case .added:
|
||||
headerView.textLabel.text = NSLocalizedString("Sources control what apps are available to download through SideStore.", comment: "")
|
||||
headerView.textLabel.font = UIFont.preferredFont(forTextStyle: .callout)
|
||||
headerView.textLabel.textAlignment = .natural
|
||||
|
||||
headerView.topLayoutConstraint.constant = 14
|
||||
headerView.bottomLayoutConstraint.constant = 30
|
||||
|
||||
case .trusted:
|
||||
switch fetchTrustedSourcesResult {
|
||||
case .failure: headerView.textLabel.text = NSLocalizedString("Error Loading Trusted Sources", comment: "")
|
||||
case .success, .none: headerView.textLabel.text = NSLocalizedString("Trusted Sources", comment: "")
|
||||
}
|
||||
|
||||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .callout).withSymbolicTraits(.traitBold)!
|
||||
headerView.textLabel.font = UIFont(descriptor: descriptor, size: 0)
|
||||
headerView.textLabel.textAlignment = .center
|
||||
|
||||
headerView.topLayoutConstraint.constant = 54
|
||||
headerView.bottomLayoutConstraint.constant = 15
|
||||
}
|
||||
|
||||
case UICollectionView.elementKindSectionFooter:
|
||||
let footerView = headerView as! SourcesFooterView
|
||||
let font = UIFont.preferredFont(forTextStyle: .subheadline)
|
||||
|
||||
switch fetchTrustedSourcesResult {
|
||||
case let .failure(error):
|
||||
footerView.textView.font = font
|
||||
footerView.textView.text = error.localizedDescription
|
||||
|
||||
footerView.activityIndicatorView.stopAnimating()
|
||||
footerView.topLayoutConstraint.constant = 0
|
||||
footerView.textView.textAlignment = .center
|
||||
|
||||
case .success, .none:
|
||||
footerView.textView.delegate = self
|
||||
|
||||
let attributedText = NSMutableAttributedString(
|
||||
string: NSLocalizedString("SideStore has reviewed these sources to make sure they meet our safety standards.\n\nSupport for untrusted sources is currently in beta, but you can help test them out by", comment: ""),
|
||||
attributes: [.font: font, .foregroundColor: UIColor.gray]
|
||||
)
|
||||
attributedText.mutableString.append(" ")
|
||||
|
||||
let boldedFont = UIFont(descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold)!, size: font.pointSize)
|
||||
let openPatreonURL = URL(string: "https://SideStore.io/patreon")!
|
||||
|
||||
let joinPatreonText = NSAttributedString(
|
||||
string: NSLocalizedString("joining our Patreon.", comment: ""),
|
||||
attributes: [.font: boldedFont, .link: openPatreonURL, .underlineColor: UIColor.clear]
|
||||
)
|
||||
attributedText.append(joinPatreonText)
|
||||
|
||||
footerView.textView.attributedText = attributedText
|
||||
footerView.textView.textAlignment = .natural
|
||||
|
||||
if fetchTrustedSourcesResult != nil {
|
||||
footerView.activityIndicatorView.stopAnimating()
|
||||
footerView.topLayoutConstraint.constant = 20
|
||||
} else {
|
||||
footerView.activityIndicatorView.startAnimating()
|
||||
footerView.topLayoutConstraint.constant = 0
|
||||
}
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
return headerView
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension SourcesViewController {
|
||||
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point _: CGPoint) -> UIContextMenuConfiguration? {
|
||||
let source = dataSource.item(at: indexPath)
|
||||
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { _ -> UIMenu? in
|
||||
let viewErrorAction = UIAction(title: NSLocalizedString("View Error", comment: ""), image: UIImage(systemName: "exclamationmark.circle")) { _ in
|
||||
guard let error = source.error else { return }
|
||||
self.present(error)
|
||||
}
|
||||
|
||||
let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { _ in
|
||||
self.remove(source)
|
||||
}
|
||||
|
||||
let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { _ in
|
||||
self.addSource(url: source.sourceURL, isTrusted: true)
|
||||
}
|
||||
|
||||
var actions: [UIAction] = []
|
||||
|
||||
if source.error != nil {
|
||||
actions.append(viewErrorAction)
|
||||
}
|
||||
|
||||
switch Section.allCases[indexPath.section] {
|
||||
case .added:
|
||||
if source.identifier != Source.altStoreIdentifier {
|
||||
actions.append(deleteAction)
|
||||
}
|
||||
|
||||
case .trusted:
|
||||
if let cell = collectionView.cellForItem(at: indexPath) as? BannerCollectionViewCell, !cell.bannerView.button.isHidden {
|
||||
actions.append(addAction)
|
||||
}
|
||||
}
|
||||
|
||||
guard !actions.isEmpty else { return nil }
|
||||
|
||||
let menu = UIMenu(title: "", children: actions)
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
guard let indexPath = configuration.identifier as? NSIndexPath else { return nil }
|
||||
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? BannerCollectionViewCell else { return nil }
|
||||
|
||||
let parameters = UIPreviewParameters()
|
||||
parameters.backgroundColor = .clear
|
||||
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.bounds, cornerRadius: cell.bannerView.layer.cornerRadius)
|
||||
|
||||
let preview = UITargetedPreview(view: cell.bannerView, parameters: parameters)
|
||||
return preview
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
extension SourcesViewController: UITextViewDelegate {
|
||||
func textView(_: UITextView, shouldInteractWith _: URL, in _: NSRange, interaction _: UITextItemInteraction) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
121
SideStoreApp/Sources/SideStoreUIKit/TabBarController.swift
Normal file
121
SideStoreApp/Sources/SideStoreUIKit/TabBarController.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// TabBarController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SideStoreCore
|
||||
import UIKit
|
||||
|
||||
extension TabBarController {
|
||||
private enum Tab: Int, CaseIterable {
|
||||
case news
|
||||
case browse
|
||||
case myApps
|
||||
case settings
|
||||
}
|
||||
}
|
||||
|
||||
public final class TabBarController: UITabBarController {
|
||||
private var initialSegue: (identifier: String, sender: Any?)?
|
||||
|
||||
private var _viewDidAppear = false
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.openPatreonSettings(_:)), name: SideStoreAppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.importApp(_:)), name: SideStoreAppDelegate.importAppDeepLinkNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.presentSources(_:)), name: SideStoreAppDelegate.addSourceDeepLinkNotification, object: nil)
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
_viewDidAppear = true
|
||||
|
||||
if let (identifier, sender) = initialSegue {
|
||||
initialSegue = nil
|
||||
performSegue(withIdentifier: identifier, sender: sender)
|
||||
} else if let patchedApps = UserDefaults.standard.patchedApps, !patchedApps.isEmpty {
|
||||
// Check if we need to finish installing untethered jailbreak.
|
||||
let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext)
|
||||
guard let patchedApp = activeApps.first(where: { patchedApps.contains($0.bundleIdentifier) }) else { return }
|
||||
|
||||
performSegue(withIdentifier: "finishJailbreak", sender: patchedApp)
|
||||
}
|
||||
}
|
||||
|
||||
public override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
guard let identifier = segue.identifier else { return }
|
||||
|
||||
switch identifier {
|
||||
case "presentSources":
|
||||
guard let notification = sender as? Notification,
|
||||
let sourceURL = notification.userInfo?[SideStoreAppDelegate.addSourceDeepLinkURLKey] as? URL
|
||||
else { return }
|
||||
|
||||
let navigationController = segue.destination as! UINavigationController
|
||||
let sourcesViewController = navigationController.viewControllers.first as! SourcesViewController
|
||||
sourcesViewController.deepLinkSourceURL = sourceURL
|
||||
|
||||
case "finishJailbreak":
|
||||
guard let installedApp = sender as? InstalledApp, #available(iOS 14, *) else { return }
|
||||
|
||||
let navigationController = segue.destination as! UINavigationController
|
||||
|
||||
let patchViewController = navigationController.viewControllers.first as! PatchViewController
|
||||
patchViewController.installedApp = installedApp
|
||||
patchViewController.completionHandler = { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
public override func performSegue(withIdentifier identifier: String, sender: Any?) {
|
||||
guard _viewDidAppear else {
|
||||
initialSegue = (identifier, sender)
|
||||
return
|
||||
}
|
||||
|
||||
super.performSegue(withIdentifier: identifier, sender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
extension TabBarController {
|
||||
@objc func presentSources(_ sender: Any) {
|
||||
if let presentedViewController = presentedViewController {
|
||||
if let navigationController = presentedViewController as? UINavigationController,
|
||||
let sourcesViewController = navigationController.viewControllers.first as? SourcesViewController {
|
||||
if let notification = (sender as? Notification),
|
||||
let sourceURL = notification.userInfo?[SideStoreAppDelegate.addSourceDeepLinkURLKey] as? URL {
|
||||
sourcesViewController.deepLinkSourceURL = sourceURL
|
||||
} else {
|
||||
// Don't dismiss SourcesViewController if it's already presented.
|
||||
}
|
||||
} else {
|
||||
presentedViewController.dismiss(animated: true) {
|
||||
self.presentSources(sender)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
performSegue(withIdentifier: "presentSources", sender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
private extension TabBarController {
|
||||
@objc func openPatreonSettings(_: Notification) {
|
||||
selectedIndex = Tab.settings.rawValue
|
||||
}
|
||||
|
||||
@objc func importApp(_: Notification) {
|
||||
selectedIndex = Tab.myApps.rawValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// ScreenshotProcessor.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 4/11/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Nuke
|
||||
import UIKit
|
||||
|
||||
struct ScreenshotProcessor: ImageProcessing {
|
||||
func process(image: Image, context _: ImageProcessingContext) -> Image? {
|
||||
guard let cgImage = image.cgImage, image.size.width > image.size.height else { return image }
|
||||
|
||||
let rotatedImage = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
|
||||
return rotatedImage
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageProcessing where Self == ScreenshotProcessor {
|
||||
static var screenshot: Self { Self() }
|
||||
}
|
||||
Reference in New Issue
Block a user