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

View File

@@ -0,0 +1,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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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 AttributedStrings 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 its 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
}()

View File

@@ -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()
}
}

View File

@@ -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?()
}
}

View File

@@ -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))
}
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
}
}
}

View 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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View 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
}
}
}

View File

@@ -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!
}

View 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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)!
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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() }
}