XCode project for app, moved app project to folder

This commit is contained in:
Joe Mattiello
2023-03-01 22:07:19 -05:00
parent 365cadbb31
commit 4c9c5b1a56
371 changed files with 625 additions and 39 deletions

View File

@@ -0,0 +1,18 @@
//
// ALTApplication+AltStoreApp.swift
// AltStore
//
// Created by Riley Testut on 11/11/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import AltSign
extension ALTApplication {
static let altstoreBundleID = Bundle.Info.appbundleIdentifier
var isAltStoreApp: Bool {
let isAltStoreApp = bundleIdentifier.contains(ALTApplication.altstoreBundleID)
return isAltStoreApp
}
}

View File

@@ -0,0 +1,90 @@
//
// AnalyticsManager.swift
// AltStore
//
// Created by Riley Testut on 3/31/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import SideStoreCore
import AppCenter
import AppCenterAnalytics
import AppCenterCrashes
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
public extension AnalyticsManager {
enum EventProperty: String {
case name
case bundleIdentifier
case developerName
case version
case size
case tintColor
case sourceIdentifier
case sourceURL
}
enum Event {
case installedApp(InstalledApp)
case updatedApp(InstalledApp)
case refreshedApp(InstalledApp)
public var name: String {
switch self {
case .installedApp: return "installed_app"
case .updatedApp: return "updated_app"
case .refreshedApp: return "refreshed_app"
}
}
public var properties: [EventProperty: String] {
let properties: [EventProperty: String?]
switch self {
case let .installedApp(app), let .updatedApp(app), let .refreshedApp(app):
let appBundleURL = InstalledApp.fileURL(for: app)
let appBundleSize = FileManager.default.directorySize(at: appBundleURL)
properties = [
.name: app.name,
.bundleIdentifier: app.bundleIdentifier,
.developerName: app.storeApp?.developerName,
.version: app.version,
.size: appBundleSize?.description,
.tintColor: app.storeApp?.tintColor?.hexString,
.sourceIdentifier: app.storeApp?.sourceIdentifier,
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString
]
}
return properties.compactMapValues { $0 }
}
}
}
public final class AnalyticsManager {
public static let shared = AnalyticsManager()
private init() {}
}
public extension AnalyticsManager {
func start() {
AppCenter.start(withAppSecret: appCenterAppSecret, services: [
Analytics.self,
Crashes.self
])
}
func trackEvent(_ event: Event) {
let properties = event.properties.reduce(into: [:]) { properties, item in
properties[item.key.rawValue] = item.value
}
Analytics.trackEvent(event.name, withProperties: properties)
}
}

View File

@@ -0,0 +1,221 @@
//
// 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 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 {
print("Error loading image:", error)
}
}
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,38 @@
//
// AppContentViewControllerCells.swift
// AltStore
//
// Created by Riley Testut on 7/24/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
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
}
}
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,503 @@
//
// 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
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
override var preferredStatusBarStyle: UIStatusBarStyle {
_preferredStatusBarStyle
}
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
}
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
prepareBlur()
// Update blur immediately.
view.setNeedsLayout()
view.layoutIfNeeded()
transitionCoordinator?.animate(alongsideTransition: { _ in
self.hideNavigationBar()
}, completion: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
_shouldResetLayout = true
view.setNeedsLayout()
view.layoutIfNeeded()
}
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)
}
})
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if navigationController == nil {
resetNavigationBarAnimation()
}
}
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
}
}
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
}
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: nil)
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 {
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,120 @@
//
// IntentHandler.swift
// AltStore
//
// Created by Riley Testut on 7/6/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import SideStoreCore
import Intents
@available(iOS 14, *)
public final class IntentHandler: NSObject, RefreshAllIntentHandling {
private let queue = DispatchQueue(label: "io.altstore.IntentHandler")
private var completionHandlers = [RefreshAllIntent: (RefreshAllIntentResponse) -> Void]()
private var queuedResponses = [RefreshAllIntent: RefreshAllIntentResponse]()
private var operations = [RefreshAllIntent: BackgroundRefreshAppsOperation]()
public func confirm(intent: RefreshAllIntent, completion: @escaping (RefreshAllIntentResponse) -> Void) {
// Refreshing apps usually, but not always, completes within alotted time.
// As a workaround, we'll start refreshing apps in confirm() so we can
// take advantage of some extra time before starting handle() timeout timer.
completionHandlers[intent] = { response in
if response.code != .ready {
// Operation finished before confirmation "timeout".
// Cache response to return it when handle() is called.
self.queuedResponses[intent] = response
}
completion(RefreshAllIntentResponse(code: .ready, userActivity: nil))
}
// Give ourselves 9 extra seconds before starting handle() timeout timer.
// 10 seconds or longer results in timeout regardless.
queue.asyncAfter(deadline: .now() + 9.0) {
self.finish(intent, response: RefreshAllIntentResponse(code: .ready, userActivity: nil))
}
if !DatabaseManager.shared.isStarted {
DatabaseManager.shared.start { error in
if let error = error {
self.finish(intent, response: RefreshAllIntentResponse.failure(localizedDescription: error.localizedDescription))
} else {
self.refreshApps(intent: intent)
}
}
} else {
refreshApps(intent: intent)
}
}
public func handle(intent: RefreshAllIntent, completion: @escaping (RefreshAllIntentResponse) -> Void) {
completionHandlers[intent] = { response in
// Ignore .ready response from confirm() timeout.
guard response.code != .ready else { return }
completion(response)
}
if let response = queuedResponses[intent] {
queuedResponses[intent] = nil
finish(intent, response: response)
} else {
queue.asyncAfter(deadline: .now() + 7.0) {
if let operation = self.operations[intent] {
// We took too long to finish and return the final result,
// so we'll now present a normal notification when finished.
operation.presentsFinishedNotification = true
}
self.finish(intent, response: RefreshAllIntentResponse(code: .inProgress, userActivity: nil))
}
}
}
}
@available(iOS 14, *)
private extension IntentHandler {
func finish(_ intent: RefreshAllIntent, response: RefreshAllIntentResponse) {
queue.async {
if let completionHandler = self.completionHandlers[intent] {
self.completionHandlers[intent] = nil
completionHandler(response)
} else if response.code != .ready && response.code != .inProgress {
// Queue response in case refreshing finishes after confirm() but before handle().
self.queuedResponses[intent] = response
}
}
}
func refreshApps(intent: RefreshAllIntent) {
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let installedApps = InstalledApp.fetchActiveApps(in: context)
let operation = AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: false) { result in
do {
let results = try result.get()
for (_, result) in results {
guard case let .failure(error) = result else { continue }
throw error
}
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
} catch RefreshError.noInstalledApps {
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
} catch let error as NSError {
print("Failed to refresh apps in background.", error)
self.finish(intent, response: RefreshAllIntentResponse.failure(localizedDescription: error.localizedFailureReason ?? error.localizedDescription))
}
self.operations[intent] = nil
}
self.operations[intent] = operation
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>INEnums</key>
<array/>
<key>INIntentDefinitionModelVersion</key>
<string>1.2</string>
<key>INIntentDefinitionNamespace</key>
<string>KyhEWE</string>
<key>INIntentDefinitionSystemVersion</key>
<string>20A5354i</string>
<key>INIntentDefinitionToolsBuildVersion</key>
<string>12A8189n</string>
<key>INIntentDefinitionToolsVersion</key>
<string>12.0</string>
<key>INIntents</key>
<array>
<dict>
<key>INIntentCategory</key>
<string>generic</string>
<key>INIntentConfigurable</key>
<true/>
<key>INIntentDescriptionID</key>
<string>62S1rm</string>
<key>INIntentLastParameterTag</key>
<integer>3</integer>
<key>INIntentManagedParameterCombinations</key>
<dict>
<key></key>
<dict>
<key>INIntentParameterCombinationSupportsBackgroundExecution</key>
<true/>
<key>INIntentParameterCombinationTitle</key>
<string>Refresh All Apps</string>
<key>INIntentParameterCombinationTitleID</key>
<string>cJxa2I</string>
<key>INIntentParameterCombinationUpdatesLinked</key>
<true/>
</dict>
</dict>
<key>INIntentName</key>
<string>RefreshAll</string>
<key>INIntentParameterCombinations</key>
<dict>
<key></key>
<dict>
<key>INIntentParameterCombinationSupportsBackgroundExecution</key>
<true/>
<key>INIntentParameterCombinationTitle</key>
<string>Refresh All Apps</string>
<key>INIntentParameterCombinationTitleID</key>
<string>DKTGdO</string>
</dict>
</dict>
<key>INIntentResponse</key>
<dict>
<key>INIntentResponseCodes</key>
<array>
<dict>
<key>INIntentResponseCodeConciseFormatString</key>
<string>All apps have been refreshed.</string>
<key>INIntentResponseCodeConciseFormatStringID</key>
<string>3WMWsJ</string>
<key>INIntentResponseCodeFormatString</key>
<string>All apps have been refreshed.</string>
<key>INIntentResponseCodeFormatStringID</key>
<string>BjInD3</string>
<key>INIntentResponseCodeName</key>
<string>success</string>
<key>INIntentResponseCodeSuccess</key>
<true/>
</dict>
<dict>
<key>INIntentResponseCodeConciseFormatString</key>
<string>${localizedDescription}</string>
<key>INIntentResponseCodeConciseFormatStringID</key>
<string>GJdShK</string>
<key>INIntentResponseCodeFormatString</key>
<string>${localizedDescription}</string>
<key>INIntentResponseCodeFormatStringID</key>
<string>oXAiOU</string>
<key>INIntentResponseCodeName</key>
<string>failure</string>
</dict>
</array>
<key>INIntentResponseLastParameterTag</key>
<integer>3</integer>
<key>INIntentResponseParameters</key>
<array>
<dict>
<key>INIntentResponseParameterDisplayName</key>
<string>Localized Description</string>
<key>INIntentResponseParameterDisplayNameID</key>
<string>wdy22v</string>
<key>INIntentResponseParameterDisplayPriority</key>
<integer>1</integer>
<key>INIntentResponseParameterName</key>
<string>localizedDescription</string>
<key>INIntentResponseParameterTag</key>
<integer>3</integer>
<key>INIntentResponseParameterType</key>
<string>String</string>
</dict>
</array>
</dict>
<key>INIntentTitle</key>
<string>Refresh All Apps</string>
<key>INIntentTitleID</key>
<string>2b6Xto</string>
<key>INIntentType</key>
<string>Custom</string>
<key>INIntentVerb</key>
<string>Do</string>
</dict>
</array>
<key>INTypes</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>INEnums</key>
<array/>
<key>INIntentDefinitionModelVersion</key>
<string>1.2</string>
<key>INIntentDefinitionNamespace</key>
<string>TPucbY</string>
<key>INIntentDefinitionSystemVersion</key>
<string>20A5354i</string>
<key>INIntentDefinitionToolsBuildVersion</key>
<string>12A8189n</string>
<key>INIntentDefinitionToolsVersion</key>
<string>12.0</string>
<key>INIntents</key>
<array>
<dict>
<key>INIntentCategory</key>
<string>information</string>
<key>INIntentConfigurable</key>
<true/>
<key>INIntentDescription</key>
<string>Select App</string>
<key>INIntentDescriptionID</key>
<string>sb9c7F</string>
<key>INIntentEligibleForWidgets</key>
<true/>
<key>INIntentIneligibleForSuggestions</key>
<true/>
<key>INIntentLastParameterTag</key>
<integer>2</integer>
<key>INIntentManagedParameterCombinations</key>
<dict>
<key>app</key>
<dict>
<key>INIntentParameterCombinationSupportsBackgroundExecution</key>
<true/>
<key>INIntentParameterCombinationUpdatesLinked</key>
<true/>
</dict>
</dict>
<key>INIntentName</key>
<string>ViewApp</string>
<key>INIntentParameters</key>
<array>
<dict>
<key>INIntentParameterConfigurable</key>
<true/>
<key>INIntentParameterDisplayName</key>
<string>App</string>
<key>INIntentParameterDisplayNameID</key>
<string>QwLCXY</string>
<key>INIntentParameterDisplayPriority</key>
<integer>1</integer>
<key>INIntentParameterName</key>
<string>app</string>
<key>INIntentParameterObjectType</key>
<string>App</string>
<key>INIntentParameterObjectTypeNamespace</key>
<string>TPucbY</string>
<key>INIntentParameterPromptDialogs</key>
<array>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogType</key>
<string>Configuration</string>
</dict>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogType</key>
<string>Primary</string>
</dict>
</array>
<key>INIntentParameterSupportsDynamicEnumeration</key>
<true/>
<key>INIntentParameterTag</key>
<integer>2</integer>
<key>INIntentParameterType</key>
<string>Object</string>
</dict>
</array>
<key>INIntentResponse</key>
<dict>
<key>INIntentResponseCodes</key>
<array>
<dict>
<key>INIntentResponseCodeName</key>
<string>success</string>
<key>INIntentResponseCodeSuccess</key>
<true/>
</dict>
<dict>
<key>INIntentResponseCodeName</key>
<string>failure</string>
</dict>
</array>
</dict>
<key>INIntentTitle</key>
<string>View App</string>
<key>INIntentTitleID</key>
<string>7aGoWn</string>
<key>INIntentType</key>
<string>Custom</string>
<key>INIntentVerb</key>
<string>View</string>
</dict>
</array>
<key>INTypes</key>
<array>
<dict>
<key>INTypeDisplayName</key>
<string>App</string>
<key>INTypeDisplayNameID</key>
<string>cUl1NZ</string>
<key>INTypeLastPropertyTag</key>
<integer>99</integer>
<key>INTypeName</key>
<string>App</string>
<key>INTypeProperties</key>
<array>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>1</integer>
<key>INTypePropertyName</key>
<string>identifier</string>
<key>INTypePropertyTag</key>
<integer>1</integer>
<key>INTypePropertyType</key>
<string>String</string>
</dict>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>2</integer>
<key>INTypePropertyName</key>
<string>displayString</string>
<key>INTypePropertyTag</key>
<integer>2</integer>
<key>INTypePropertyType</key>
<string>String</string>
</dict>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>3</integer>
<key>INTypePropertyName</key>
<string>pronunciationHint</string>
<key>INTypePropertyTag</key>
<integer>3</integer>
<key>INTypePropertyType</key>
<string>String</string>
</dict>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>4</integer>
<key>INTypePropertyName</key>
<string>alternativeSpeakableMatches</string>
<key>INTypePropertySupportsMultipleValues</key>
<true/>
<key>INTypePropertyTag</key>
<integer>4</integer>
<key>INTypePropertyType</key>
<string>SpeakableString</string>
</dict>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,31 @@
//
// ViewAppIntentHandler.swift
// ViewAppIntentHandler
//
// Created by Riley Testut on 7/10/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Intents
import Shared
import SideStoreCore
@available(iOS 14, *)
public class ViewAppIntentHandler: NSObject, ViewAppIntentHandling {
public func provideAppOptionsCollection(for _: ViewAppIntent, with completion: @escaping (INObjectCollection<App>?, Error?) -> Void) {
DatabaseManager.shared.start { error in
if let error = error {
print("Error starting extension:", error)
}
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let apps = InstalledApp.all(in: context).map { installedApp in
App(identifier: installedApp.bundleIdentifier, display: installedApp.name)
}
let collection = INObjectCollection(items: apps)
completion(collection, nil)
}
}
}
}

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,71 @@
//
// 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 os.log
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,90 @@
//
// BrowseCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import RoxasUIKit
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 {
print("Error loading image:", error)
}
}
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,322 @@
//
// 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 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 {
print("Error loading image:", error)
}
}
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: print("Installed app:", 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,51 @@
//
// BannerCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 3/23/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
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,86 @@
//
// NavigationBar.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import RoxasUIKit
final class NavigationBar: UINavigationBar {
@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,18 @@
//
// TextCollectionReusableView.swift
// AltStore
//
// Created by Riley Testut on 3/23/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
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 Shared
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,17 @@
//
// Proxy.swift
// SideStore
//
// Created by Joseph Mattiello on 11/7/22.
// Copyright © 2022 Joseph Mattiello. All rights reserved.
//
import Foundation
public extension Consts {
enum Proxy {
public static let address = "127.0.0.1"
public static let port = "51820"
public static let serverURL = "\(address):\(port)"
}
}

View File

@@ -0,0 +1,11 @@
//
// Consts.swift
// SideStore
//
// Created by Joseph Mattiello on 11/7/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Foundation
public enum Consts {}

View File

@@ -0,0 +1,30 @@
//
// FileManager+DirectorySize.swift
// AltStore
//
// Created by Riley Testut on 3/31/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
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 {
print("Failed to read file size for item: \(fileURL).", error)
}
}
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,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,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,90 @@
//
// MyAppsComponents.swift
// AltStore
//
// Created by Riley Testut on 7/17/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import RoxasUIKit
import UIKit
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
}
}
}
final class InstalledAppsCollectionFooterView: UICollectionReusableView {
@IBOutlet var textLabel: UILabel!
@IBOutlet var button: UIButton!
}
final class NoUpdatesCollectionViewCell: UICollectionViewCell {
@IBOutlet var blurView: UIVisualEffectView!
override func awakeFromNib() {
super.awakeFromNib()
contentView.preservesSuperviewLayoutMargins = true
}
}
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,28 @@
//
// NewsCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
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,452 @@
//
// 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
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 {
print("Error loading image:", error)
}
}
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: print("Installed app:", 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,592 @@
//
// AuthenticationOperation.swift
// AltStore
//
// Created by Riley Testut on 6/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Network
import RoxasUIKit
import AltSign
import SideStoreCore
enum AuthenticationError: LocalizedError {
case noTeam
case noCertificate
case teamSelectorError
case missingPrivateKey
case missingCertificate
var errorDescription: String? {
switch self {
case .noTeam: return NSLocalizedString("Developer team could not be found.", comment: "")
case .teamSelectorError: return NSLocalizedString("Error presenting team selector view.", comment: "")
case .noCertificate: return NSLocalizedString("Developer certificate could not be found.", comment: "")
case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "")
case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "")
}
}
}
@objc(AuthenticationOperation)
public final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppleAPISession)> {
public let context: AuthenticatedOperationContext
private weak var presentingViewController: UIViewController?
private lazy var navigationController: UINavigationController = {
let navigationController = self.storyboard.instantiateViewController(withIdentifier: "navigationController") as! UINavigationController
if #available(iOS 13.0, *) {
navigationController.isModalInPresentation = true
}
return navigationController
}()
private lazy var storyboard = UIStoryboard(name: "Authentication", bundle: nil)
private var appleIDEmailAddress: String?
private var appleIDPassword: String?
private var shouldShowInstructions = false
private let operationQueue = OperationQueue()
private var submitCodeAction: UIAlertAction?
public init(context: AuthenticatedOperationContext, presentingViewController: UIViewController?) {
self.context = context
self.presentingViewController = presentingViewController
super.init()
self.context.authenticationOperation = self
operationQueue.name = "com.altstore.AuthenticationOperation"
progress.totalUnitCount = 4
}
public override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
// Sign In
signIn { result in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result {
case let .failure(error): self.finish(.failure(error))
case let .success((account, session)):
self.context.session = session
self.progress.completedUnitCount += 1
// Fetch Team
self.fetchTeam(for: account, session: session) { result in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result {
case let .failure(error): self.finish(.failure(error))
case let .success(team):
self.context.team = team
self.progress.completedUnitCount += 1
// Fetch Certificate
self.fetchCertificate(for: team, session: session) { result in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result {
case let .failure(error): self.finish(.failure(error))
case let .success(certificate):
self.context.certificate = certificate
self.progress.completedUnitCount += 1
// Register Device
self.registerCurrentDevice(for: team, session: session) { result in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result {
case let .failure(error): self.finish(.failure(error))
case .success:
self.progress.completedUnitCount += 1
// Save account/team to disk.
self.save(team) { result in
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
switch result {
case let .failure(error): self.finish(.failure(error))
case .success:
// Must cache App IDs _after_ saving account/team to disk.
self.cacheAppIDs(team: team, session: session) { result in
let result = result.map { _ in (team, certificate, session) }
self.finish(result)
}
}
}
}
}
}
}
}
}
}
}
}
func save(_ altTeam: ALTTeam, completionHandler: @escaping (Result<Void, Error>) -> Void) {
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.performAndWait {
do {
let account: Account
let team: Team
if let tempAccount = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context) {
account = tempAccount
} else {
account = Account(altTeam.account, context: context)
}
if let tempTeam = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context) {
team = tempTeam
} else {
team = Team(altTeam, account: account, context: context)
}
account.update(account: altTeam.account)
if let providedEmailAddress = self.appleIDEmailAddress {
// Save the user's provided email address instead of the one associated with their account (which may be outdated).
account.appleID = providedEmailAddress
}
team.update(team: altTeam)
try context.save()
completionHandler(.success(()))
} catch {
completionHandler(.failure(error))
}
}
}
override func finish(_ result: Result<(ALTTeam, ALTCertificate, ALTAppleAPISession), Error>) {
guard !isFinished else { return }
print("Finished authenticating with result:", result.error?.localizedDescription ?? "success")
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.perform {
do {
let (altTeam, altCertificate, session) = try result.get()
guard
let account = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context),
let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context)
else { throw AuthenticationError.noTeam }
// Account
account.isActiveAccount = true
let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest<Account>
otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier)
let otherAccounts = try context.fetch(otherAccountsFetchRequest)
for account in otherAccounts {
account.isActiveAccount = false
}
// Team
team.isActiveTeam = true
let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest<Team>
otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier)
let otherTeams = try context.fetch(otherTeamsFetchRequest)
for team in otherTeams {
team.isActiveTeam = false
}
let activeAppsMinimumVersion = OperatingSystemVersion(majorVersion: 13, minorVersion: 3, patchVersion: 1)
if team.type == .free, ProcessInfo.processInfo.isOperatingSystemAtLeast(activeAppsMinimumVersion) {
UserDefaults.standard.activeAppsLimit = ALTActiveAppsLimit
} else {
UserDefaults.standard.activeAppsLimit = nil
}
// Save
try context.save()
// Update keychain
Keychain.shared.appleIDEmailAddress = self.appleIDEmailAddress ?? altTeam.account.appleID // Prefer the user's provided email address over the one associated with their account (which may be outdated).
Keychain.shared.appleIDPassword = self.appleIDPassword
Keychain.shared.signingCertificate = altCertificate.p12Data()
Keychain.shared.signingCertificatePassword = altCertificate.machineIdentifier
self.showInstructionsIfNecessary { _ in
let signer = ALTSigner(team: altTeam, certificate: altCertificate)
// Refresh screen must go last since a successful refresh will cause the app to quit.
self.showRefreshScreenIfNecessary(signer: signer, session: session) { _ in
super.finish(result)
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
}
}
}
} catch {
super.finish(result)
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
}
}
}
}
}
private extension AuthenticationOperation {
func present(_ viewController: UIViewController) -> Bool {
guard let presentingViewController = presentingViewController else { return false }
navigationController.view.tintColor = .white
if navigationController.viewControllers.isEmpty {
guard presentingViewController.presentedViewController == nil else { return false }
navigationController.setViewControllers([viewController], animated: false)
presentingViewController.present(navigationController, animated: true, completion: nil)
} else {
viewController.navigationItem.leftBarButtonItem = nil
navigationController.pushViewController(viewController, animated: true)
}
return true
}
}
private extension AuthenticationOperation {
func signIn(completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void) {
func authenticate() {
DispatchQueue.main.async {
let authenticationViewController = self.storyboard.instantiateViewController(withIdentifier: "authenticationViewController") as! AuthenticationViewController
authenticationViewController.authenticationHandler = { appleID, password, completionHandler in
self.authenticate(appleID: appleID, password: password) { result in
completionHandler(result)
}
}
authenticationViewController.completionHandler = { result in
if let (account, session, password) = result {
// We presented the Auth UI and the user signed in.
// In this case, we'll assume we should show the instructions again.
self.shouldShowInstructions = true
self.appleIDPassword = password
completionHandler(.success((account, session)))
} else {
completionHandler(.failure(OperationError.cancelled))
}
}
if !self.present(authenticationViewController) {
completionHandler(.failure(OperationError.notAuthenticated))
}
}
}
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword {
self.authenticate(appleID: appleID, password: password) { result in
switch result {
case let .success((account, session)):
self.appleIDPassword = password
completionHandler(.success((account, session)))
case .failure(ALTAppleAPIError.incorrectCredentials), .failure(ALTAppleAPIError.appSpecificPasswordRequired):
authenticate()
case let .failure(error):
completionHandler(.failure(error))
}
}
} else {
authenticate()
}
}
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void) {
appleIDEmailAddress = appleID
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: context)
fetchAnisetteDataOperation.resultHandler = { result in
switch result {
case let .failure(error): completionHandler(.failure(error))
case let .success(anisetteData):
let verificationHandler: ((@escaping (String?) -> Void) -> Void)?
if let presentingViewController = self.presentingViewController {
verificationHandler = { completionHandler in
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Please enter the 6-digit verification code that was sent to your Apple devices.", comment: ""), message: nil, preferredStyle: .alert)
alertController.addTextField { textField in
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
textField.keyboardType = .numberPad
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationOperation.textFieldTextDidChange(_:)), name: UITextField.textDidChangeNotification, object: textField)
}
let submitAction = UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default) { _ in
let textField = alertController.textFields?.first
let code = textField?.text ?? ""
completionHandler(code)
}
submitAction.isEnabled = false
alertController.addAction(submitAction)
self.submitCodeAction = submitAction
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { _ in
completionHandler(nil)
})
if self.navigationController.presentingViewController != nil {
self.navigationController.present(alertController, animated: true, completion: nil)
} else {
presentingViewController.present(alertController, animated: true, completion: nil)
}
}
}
} else {
// No view controller to present security code alert, so don't provide verificationHandler.
verificationHandler = nil
}
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: anisetteData,
verificationHandler: verificationHandler) { account, session, error in
if let account = account, let session = session {
completionHandler(.success((account, session)))
} else {
completionHandler(.failure(error ?? OperationError.unknown))
}
}
}
}
operationQueue.addOperation(fetchAnisetteDataOperation)
}
func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void) {
func selectTeam(from teams: [ALTTeam]) {
if teams.count <= 1 {
if let team = teams.first {
return completionHandler(.success(team))
} else {
return completionHandler(.failure(AuthenticationError.noTeam))
}
} else {
DispatchQueue.main.async {
let selectTeamViewController = self.storyboard.instantiateViewController(withIdentifier: "selectTeamViewController") as! SelectTeamViewController
selectTeamViewController.teams = teams
selectTeamViewController.completionHandler = completionHandler
if !self.present(selectTeamViewController) {
return completionHandler(.failure(AuthenticationError.noTeam))
}
}
}
}
ALTAppleAPI.shared.fetchTeams(for: account, session: session) { teams, error in
switch Result(teams, error) {
case let .failure(error): completionHandler(.failure(error))
case let .success(teams):
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
if let activeTeam = DatabaseManager.shared.activeTeam(in: context), let altTeam = teams.first(where: { $0.identifier == activeTeam.identifier }) {
completionHandler(.success(altTeam))
} else {
selectTeam(from: teams)
}
}
}
}
}
func fetchCertificate(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTCertificate, Swift.Error>) -> Void) {
func requestCertificate() {
let machineName = "AltStore - " + UIDevice.current.name
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team, session: session) { certificate, error in
do {
let certificate = try Result(certificate, error).get()
guard let privateKey = certificate.privateKey else { throw AuthenticationError.missingPrivateKey }
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { certificates, error in
do {
let certificates = try Result(certificates, error).get()
guard let certificate = certificates.first(where: { $0.serialNumber == certificate.serialNumber }) else {
throw AuthenticationError.missingCertificate
}
certificate.privateKey = privateKey
completionHandler(.success(certificate))
} catch {
completionHandler(.failure(error))
}
}
} catch {
completionHandler(.failure(error))
}
}
}
func replaceCertificate(from certificates: [ALTCertificate]) {
guard let certificate = certificates.first(where: { $0.machineName?.starts(with: "AltStore") == true }) ?? certificates.first else { return completionHandler(.failure(AuthenticationError.noCertificate)) }
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { success, error in
if let error = error, !success {
completionHandler(.failure(error))
} else {
requestCertificate()
}
}
}
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { certificates, error in
do {
let certificates = try Result(certificates, error).get()
if
let data = Keychain.shared.signingCertificate,
let localCertificate = ALTCertificate(p12Data: data, password: nil),
let certificate = certificates.first(where: { $0.serialNumber == localCertificate.serialNumber }) {
// We have a certificate stored in the keychain and it hasn't been revoked.
localCertificate.machineIdentifier = certificate.machineIdentifier
completionHandler(.success(localCertificate))
} else if
let serialNumber = Keychain.shared.signingCertificateSerialNumber,
let privateKey = Keychain.shared.signingCertificatePrivateKey,
let certificate = certificates.first(where: { $0.serialNumber == serialNumber }) {
// LEGACY
// We have the private key for one of the certificates, so add it to certificate and use it.
certificate.privateKey = privateKey
completionHandler(.success(certificate))
} else if
let serialNumber = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.certificateID) as? String,
let certificate = certificates.first(where: { $0.serialNumber == serialNumber }),
let machineIdentifier = certificate.machineIdentifier,
FileManager.default.fileExists(atPath: Bundle.main.certificateURL.path),
let data = try? Data(contentsOf: Bundle.main.certificateURL),
let localCertificate = ALTCertificate(p12Data: data, password: machineIdentifier) {
// We have an embedded certificate that hasn't been revoked.
localCertificate.machineIdentifier = machineIdentifier
completionHandler(.success(localCertificate))
} else if certificates.isEmpty {
// No certificates, so request a new one.
requestCertificate()
} else {
// We don't have private keys for any of the certificates,
// so we need to revoke one and create a new one.
replaceCertificate(from: certificates)
}
} catch {
completionHandler(.failure(error))
}
}
}
func registerCurrentDevice(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void) {
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else {
return completionHandler(.failure(OperationError.unknownUDID))
}
ALTAppleAPI.shared.fetchDevices(for: team, types: [.iphone, .ipad], session: session) { devices, error in
do {
let devices = try Result(devices, error).get()
if let device = devices.first(where: { $0.identifier == udid }) {
completionHandler(.success(device))
} else {
ALTAppleAPI.shared.registerDevice(name: UIDevice.current.name, identifier: udid, type: .iphone, team: team, session: session) { device, error in
completionHandler(Result(device, error))
}
}
} catch {
completionHandler(.failure(error))
}
}
}
func cacheAppIDs(team _: ALTTeam, session _: ALTAppleAPISession, completionHandler: @escaping (Result<Void, Error>) -> Void) {
let fetchAppIDsOperation = FetchAppIDsOperation(context: context)
fetchAppIDsOperation.resultHandler = { result in
do {
let (_, context) = try result.get()
try context.save()
completionHandler(.success(()))
} catch {
completionHandler(.failure(error))
}
}
operationQueue.addOperation(fetchAppIDsOperation)
}
func showInstructionsIfNecessary(completionHandler: @escaping (Bool) -> Void) {
guard shouldShowInstructions else { return completionHandler(false) }
DispatchQueue.main.async {
let instructionsViewController = self.storyboard.instantiateViewController(withIdentifier: "instructionsViewController") as! InstructionsViewController
instructionsViewController.showsBottomButton = true
instructionsViewController.completionHandler = {
completionHandler(true)
}
if !self.present(instructionsViewController) {
completionHandler(false)
}
}
}
func showRefreshScreenIfNecessary(signer: ALTSigner, session _: ALTAppleAPISession, completionHandler: @escaping (Bool) -> Void) {
guard let application = ALTApplication(fileURL: Bundle.main.bundleURL), let provisioningProfile = application.provisioningProfile else { return completionHandler(false) }
// If we're not using the same certificate used to install AltStore, warn user that they need to refresh.
guard !provisioningProfile.certificates.contains(signer.certificate) else { return completionHandler(false) }
#if DEBUG
completionHandler(false)
#else
DispatchQueue.main.async {
let context = AuthenticatedOperationContext(context: self.context)
context.operations.removeAllObjects() // Prevent deadlock due to endless waiting on previous operations to finish.
let refreshViewController = self.storyboard.instantiateViewController(withIdentifier: "refreshAltStoreViewController") as! RefreshAltStoreViewController
refreshViewController.context = context
refreshViewController.completionHandler = { _ in
completionHandler(true)
}
if !self.present(refreshViewController) {
completionHandler(false)
}
}
#endif
}
}
extension AuthenticationOperation {
@objc func textFieldTextDidChange(_ notification: Notification) {
guard let textField = notification.object as? UITextField else { return }
submitCodeAction?.isEnabled = (textField.text ?? "").count == 6
}
}

View File

@@ -0,0 +1,228 @@
//
// BackgroundRefreshAppsOperation.swift
// AltStore
//
// Created by Riley Testut on 7/6/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import CoreData
import UIKit
import SideStoreCore
import EmotionalDamage
enum RefreshError: LocalizedError {
case noInstalledApps
var errorDescription: String? {
switch self {
case .noInstalledApps: return NSLocalizedString("No active apps require refreshing.", comment: "")
}
}
}
private extension CFNotificationName {
static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString)
static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString)
static func requestAppState(for appID: String) -> CFNotificationName {
let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID
return CFNotificationName(name as CFString)
}
static func appIsRunning(for appID: String) -> CFNotificationName {
let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID
return CFNotificationName(name as CFString)
}
}
private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void = { _, observer, name, _, _ in
guard let name = name, let observer = observer else { return }
let operation = unsafeBitCast(observer, to: BackgroundRefreshAppsOperation.self)
operation.receivedApplicationState(notification: name)
}
@objc(BackgroundRefreshAppsOperation)
public final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<InstalledApp, Error>]> {
public let installedApps: [InstalledApp]
private let managedObjectContext: NSManagedObjectContext
public var presentsFinishedNotification: Bool = false
private let refreshIdentifier: String = UUID().uuidString
private var runningApplications: Set<String> = []
public init(installedApps: [InstalledApp]) {
self.installedApps = installedApps
managedObjectContext = installedApps.compactMap { $0.managedObjectContext }.first ?? DatabaseManager.shared.persistentContainer.newBackgroundContext()
super.init()
}
public override func finish(_ result: Result<[String: Result<InstalledApp, Error>], Error>) {
super.finish(result)
scheduleFinishedRefreshingNotification(for: result, delay: 0)
managedObjectContext.perform {
self.stopListeningForRunningApps()
}
DispatchQueue.main.async {
if UIApplication.shared.applicationState == .background {}
}
}
public override func main() {
super.main()
guard !installedApps.isEmpty else {
finish(.failure(RefreshError.noInstalledApps))
return
}
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
managedObjectContext.perform {
print("Apps to refresh:", self.installedApps.map(\.bundleIdentifier))
self.startListeningForRunningApps()
// Wait for 3 seconds (2 now, 1 later in FindServerOperation) to:
// a) give us time to discover AltServers
// b) give other processes a chance to respond to requestAppState notification
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.managedObjectContext.perform {
let filteredApps = self.installedApps.filter { !self.runningApplications.contains($0.bundleIdentifier) }
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
group.beginInstallationHandler = { installedApp in
guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return }
// We're starting to install AltStore, which means the app is about to quit.
// So, we schedule a "refresh successful" local notification to be displayed after a delay,
// but if the app is still running, we cancel the notification.
// Then, we schedule another notification and repeat the process.
// Also since AltServer has already received the app, it can finish installing even if we're no longer running in background.
if let error = group.context.error {
self.scheduleFinishedRefreshingNotification(for: .failure(error))
} else {
var results = group.results
results[installedApp.bundleIdentifier] = .success(installedApp)
self.scheduleFinishedRefreshingNotification(for: .success(results))
}
}
group.completionHandler = { results in
self.finish(.success(results))
}
}
}
}
}
}
private extension BackgroundRefreshAppsOperation {
func startListeningForRunningApps() {
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
let observer = Unmanaged.passUnretained(self).toOpaque()
for installedApp in installedApps {
let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier)
CFNotificationCenterAddObserver(notificationCenter, observer, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately)
let requestAppStateNotification = CFNotificationName.requestAppState(for: installedApp.bundleIdentifier)
CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true)
}
}
func stopListeningForRunningApps() {
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
let observer = Unmanaged.passUnretained(self).toOpaque()
for installedApp in installedApps {
let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier)
CFNotificationCenterRemoveObserver(notificationCenter, observer, appIsRunningNotification, nil)
}
}
func receivedApplicationState(notification: CFNotificationName) {
let baseName = String(CFNotificationName.appIsRunning.rawValue)
let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "")
runningApplications.insert(appID)
}
func scheduleFinishedRefreshingNotification(for result: Result<[String: Result<InstalledApp, Error>], Error>, delay: TimeInterval = 5) {
func scheduleFinishedRefreshingNotification() {
cancelFinishedRefreshingNotification()
let content = UNMutableNotificationContent()
var shouldPresentAlert = false
do {
let results = try result.get()
shouldPresentAlert = false
for (_, result) in results {
guard case let .failure(error) = result else { continue }
throw error
}
content.title = NSLocalizedString("Refreshed Apps", comment: "")
content.body = NSLocalizedString("All apps have been refreshed.", comment: "")
} catch RefreshError.noInstalledApps {
shouldPresentAlert = false
} catch {
print("Failed to refresh apps in background.", error)
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
content.body = error.localizedDescription
shouldPresentAlert = false
}
if shouldPresentAlert {
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false)
let request = UNNotificationRequest(identifier: refreshIdentifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
if delay > 0 {
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
// If app is still running at this point, we schedule another notification with same identifier.
// This prevents the currently scheduled notification from displaying, and starts another countdown timer.
// First though, make sure there _is_ still a pending request, otherwise it's been cancelled
// and we should stop polling.
guard requests.contains(where: { $0.identifier == self.refreshIdentifier }) else { return }
scheduleFinishedRefreshingNotification()
}
}
}
}
}
if presentsFinishedNotification {
scheduleFinishedRefreshingNotification()
}
// Perform synchronously to ensure app doesn't quit before we've finishing saving to disk.
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.performAndWait {
_ = RefreshAttempt(identifier: self.refreshIdentifier, result: result, context: context)
do { try context.save() } catch { print("Failed to save refresh attempt.", error) }
}
}
func cancelFinishedRefreshingNotification() {
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [refreshIdentifier])
}
}

View File

@@ -0,0 +1,158 @@
//
// BackupAppOperation.swift
// AltStore
//
// Created by Riley Testut on 5/12/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import SideStoreCore
extension BackupAppOperation {
enum Action: String {
case backup
case restore
}
}
@objc(BackupAppOperation)
class BackupAppOperation: ResultOperation<Void> {
let action: Action
let context: InstallAppOperationContext
private var appName: String?
private var timeoutTimer: Timer?
init(action: Action, context: InstallAppOperationContext) {
self.action = action
self.context = context
super.init()
}
override func main() {
super.main()
do {
if let error = self.context.error {
throw error
}
guard let installedApp = context.installedApp, let context = installedApp.managedObjectContext else { throw OperationError.invalidParameters }
context.perform {
do {
let appName = installedApp.name
self.appName = appName
guard let altstoreApp = InstalledApp.fetchAltStore(in: context) else { throw OperationError.appNotFound }
let altstoreOpenURL = altstoreApp.openAppURL
var returnURLComponents = URLComponents(url: altstoreOpenURL, resolvingAgainstBaseURL: false)
returnURLComponents?.host = "appBackupResponse"
guard let returnURL = returnURLComponents?.url else { throw OperationError.openAppFailed(name: appName) }
var openURLComponents = URLComponents()
openURLComponents.scheme = installedApp.openAppURL.scheme
openURLComponents.host = self.action.rawValue
openURLComponents.queryItems = [URLQueryItem(name: "returnURL", value: returnURL.absoluteString)]
guard let openURL = openURLComponents.url else { throw OperationError.openAppFailed(name: appName) }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let currentTime = CFAbsoluteTimeGetCurrent()
UIApplication.shared.open(openURL, options: [:]) { success in
let elapsedTime = CFAbsoluteTimeGetCurrent() - currentTime
if success {
self.registerObservers()
} else if elapsedTime < 0.5 {
// Failed too quickly for human to respond to alert, possibly still finalizing installation.
// Try again in a couple seconds.
print("Failed too quickly, retrying after a few seconds...")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
UIApplication.shared.open(openURL, options: [:]) { success in
if success {
self.registerObservers()
} else {
self.finish(.failure(OperationError.openAppFailed(name: appName)))
}
}
}
} else {
self.finish(.failure(OperationError.openAppFailed(name: appName)))
}
}
}
} catch {
self.finish(.failure(error))
}
}
} catch {
finish(.failure(error))
}
}
override func finish(_ result: Result<Void, Error>) {
let result = result.mapError { error -> Error in
let appName = self.appName ?? self.context.bundleIdentifier
switch (error, self.action) {
case let (error as NSError, _) where (self.context.error as NSError?) == error: fallthrough
case (OperationError.cancelled, _):
return error
case let (error as NSError, .backup):
let localizedFailure = String(format: NSLocalizedString("Could not back up “%@”.", comment: ""), appName)
return error.withLocalizedFailure(localizedFailure)
case let (error as NSError, .restore):
let localizedFailure = String(format: NSLocalizedString("Could not restore “%@”.", comment: ""), appName)
return error.withLocalizedFailure(localizedFailure)
}
}
switch result {
case .success: progress.completedUnitCount += 1
case .failure: break
}
super.finish(result)
}
}
private extension BackupAppOperation {
func registerObservers() {
var applicationWillReturnObserver: NSObjectProtocol!
applicationWillReturnObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in
guard let self = self, !self.isFinished else { return }
self.timeoutTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
// Final delay to ensure we don't prematurely return failure
// in case timer expired while we were in background, but
// are now returning to app with success response.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
guard let self = self, !self.isFinished else { return }
self.finish(.failure(OperationError.timedOut))
}
}
NotificationCenter.default.removeObserver(applicationWillReturnObserver!)
}
var backupResponseObserver: NSObjectProtocol!
backupResponseObserver = NotificationCenter.default.addObserver(forName: SideStoreAppDelegate.appBackupDidFinish, object: nil, queue: nil) { [weak self] notification in
self?.timeoutTimer?.invalidate()
let result = notification.userInfo?[SideStoreAppDelegate.appBackupResultKey] as? Result<Void, Error> ?? .failure(OperationError.unknownResult)
self?.finish(result)
NotificationCenter.default.removeObserver(backupResponseObserver!)
}
}
}

View File

@@ -0,0 +1,61 @@
//
// DeactivateAppOperation.swift
// AltStore
//
// Created by Riley Testut on 3/4/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import SideStoreCore
import minimuxer
import MiniMuxerSwift
import RoxasUIKit
import SideKit
@objc(DeactivateAppOperation)
final class DeactivateAppOperation: ResultOperation<InstalledApp> {
let app: InstalledApp
let context: OperationContext
init(app: InstalledApp, context: OperationContext) {
self.app = app
self.context = context
super.init()
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let installedApp = context.object(with: self.app.objectID) as! InstalledApp
let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier }
let allIdentifiers = [installedApp.resignedBundleIdentifier] + appExtensionProfiles
for profile in allIdentifiers {
do {
let res = try remove_provisioning_profile(id: profile)
if case let Uhoh.Bad(code) = res {
self.finish(.failure(minimuxer_to_operation(code: code)))
}
} catch let Uhoh.Bad(code) {
self.finish(.failure(minimuxer_to_operation(code: code)))
} catch {
self.finish(.failure(ALTServerError.unknownResponse))
}
}
self.progress.completedUnitCount += 1
installedApp.isActive = false
self.finish(.success(installedApp))
}
}
}

View File

@@ -0,0 +1,274 @@
//
// DownloadAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/10/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import RoxasUIKit
import AltSign
import SideKit
import SideStoreCore
import Shared
private extension DownloadAppOperation {
struct DependencyError: ALTLocalizedError {
let dependency: Dependency
let error: Error
var failure: String? {
String(format: NSLocalizedString("Could not download “%@”.", comment: ""), dependency.preferredFilename)
}
var underlyingError: Error? {
error
}
}
}
@objc(DownloadAppOperation)
final class DownloadAppOperation: ResultOperation<ALTApplication> {
let app: AppProtocol
let context: AppOperationContext
private let bundleIdentifier: String
private var sourceURL: URL?
private let destinationURL: URL
private let session = URLSession(configuration: .default)
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
init(app: AppProtocol, destinationURL: URL, context: AppOperationContext) {
self.app = app
self.context = context
bundleIdentifier = app.bundleIdentifier
sourceURL = app.url
self.destinationURL = destinationURL
super.init()
// App = 3, Dependencies = 1
progress.totalUnitCount = 4
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
print("Downloading App:", bundleIdentifier)
guard let sourceURL = sourceURL else { return finish(.failure(OperationError.appNotFound)) }
downloadApp(from: sourceURL) { result in
do {
let application = try result.get()
if self.context.bundleIdentifier == StoreApp.dolphinAppID, self.context.bundleIdentifier != application.bundleIdentifier {
if var infoPlist = NSDictionary(contentsOf: application.bundle.infoPlistURL) as? [String: Any] {
// Manually update the app's bundle identifier to match the one specified in the source.
// This allows people who previously installed the app to still update and refresh normally.
infoPlist[kCFBundleIdentifierKey as String] = StoreApp.dolphinAppID
(infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true)
}
}
self.downloadDependencies(for: application) { result in
do {
_ = try result.get()
try FileManager.default.copyItem(at: application.fileURL, to: self.destinationURL, shouldReplace: true)
guard let copiedApplication = ALTApplication(fileURL: self.destinationURL) else { throw OperationError.invalidApp }
self.finish(.success(copiedApplication))
self.progress.completedUnitCount += 1
} catch {
self.finish(.failure(error))
}
}
} catch {
self.finish(.failure(error))
}
}
}
override func finish(_ result: Result<ALTApplication, Error>) {
do {
try FileManager.default.removeItem(at: temporaryDirectory)
} catch {
print("Failed to remove DownloadAppOperation temporary directory: \(temporaryDirectory).", error)
}
super.finish(result)
}
}
private extension DownloadAppOperation {
func downloadApp(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void) {
func finishOperation(_ result: Result<URL, Error>) {
do {
let fileURL = try result.get()
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound }
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
let appBundleURL: URL
if isDirectory.boolValue {
// Directory, so assuming this is .app bundle.
guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp }
appBundleURL = temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
} else {
// File, so assuming this is a .ipa file.
appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
}
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
completionHandler(.success(application))
} catch {
completionHandler(.failure(error))
}
}
if sourceURL.isFileURL {
finishOperation(.success(sourceURL))
progress.completedUnitCount += 3
} else {
let downloadTask = session.downloadTask(with: sourceURL) { fileURL, response, error in
do {
let (fileURL, _) = try Result((fileURL, response), error).get()
finishOperation(.success(fileURL))
try? FileManager.default.removeItem(at: fileURL)
} catch {
finishOperation(.failure(error))
}
}
progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
downloadTask.resume()
}
}
}
private extension DownloadAppOperation {
struct AltStorePlist: Decodable {
private enum CodingKeys: String, CodingKey {
case dependencies = "ALTDependencies"
}
var dependencies: [Dependency]
}
struct Dependency: Decodable {
var downloadURL: URL
var path: String?
var preferredFilename: String {
let preferredFilename = path.map { ($0 as NSString).lastPathComponent } ?? downloadURL.lastPathComponent
return preferredFilename
}
init(from decoder: Decoder) throws {
enum CodingKeys: String, CodingKey {
case downloadURL
case path
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let urlString = try container.decode(String.self, forKey: .downloadURL)
let path = try container.decodeIfPresent(String.self, forKey: .path)
guard let downloadURL = URL(string: urlString) else {
throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "downloadURL is not a valid URL.")
}
self.downloadURL = downloadURL
self.path = path
}
}
func downloadDependencies(for application: ALTApplication, completionHandler: @escaping (Result<Set<URL>, Error>) -> Void) {
guard FileManager.default.fileExists(atPath: application.bundle.altstorePlistURL.path) else {
return completionHandler(.success([]))
}
do {
let data = try Data(contentsOf: application.bundle.altstorePlistURL)
let altstorePlist = try PropertyListDecoder().decode(AltStorePlist.self, from: data)
var dependencyURLs = Set<URL>()
var dependencyError: DependencyError?
let dispatchGroup = DispatchGroup()
let progress = Progress(totalUnitCount: Int64(altstorePlist.dependencies.count), parent: self.progress, pendingUnitCount: 1)
for dependency in altstorePlist.dependencies {
dispatchGroup.enter()
download(dependency, for: application, progress: progress) { result in
switch result {
case let .failure(error): dependencyError = error
case let .success(fileURL): dependencyURLs.insert(fileURL)
}
dispatchGroup.leave()
}
}
dispatchGroup.notify(qos: .userInitiated, queue: .global()) {
if let dependencyError = dependencyError {
completionHandler(.failure(dependencyError))
} else {
completionHandler(.success(dependencyURLs))
}
}
} catch let error as DecodingError {
let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not download dependencies for %@.", comment: ""), application.name))
completionHandler(.failure(nsError))
} catch {
completionHandler(.failure(error))
}
}
func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result<URL, DependencyError>) -> Void) {
let downloadTask = session.downloadTask(with: dependency.downloadURL) { fileURL, response, error in
do {
let (fileURL, _) = try Result((fileURL, response), error).get()
defer { try? FileManager.default.removeItem(at: fileURL) }
let path = dependency.path ?? dependency.preferredFilename
let destinationURL = application.fileURL.appendingPathComponent(path)
let directoryURL = destinationURL.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: directoryURL.path) {
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
}
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
completionHandler(.success(destinationURL))
} catch {
completionHandler(.failure(DependencyError(dependency: dependency, error: error)))
}
}
progress.addChild(downloadTask.progress, withPendingUnitCount: 1)
downloadTask.resume()
}
}

View File

@@ -0,0 +1,61 @@
//
// EnableJITOperation.swift
// EnableJITOperation
//
// Created by Riley Testut on 9/1/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import Combine
import minimuxer
import MiniMuxerSwift
import UIKit
import SideStoreCore
@available(iOS 14, *)
protocol EnableJITContext {
var installedApp: InstalledApp? { get }
var error: Error? { get }
}
@available(iOS 14, *)
final class EnableJITOperation<Context: EnableJITContext>: ResultOperation<Void> {
let context: Context
private var cancellable: AnyCancellable?
init(context: Context) {
self.context = context
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard let installedApp = context.installedApp else { return finish(.failure(OperationError.invalidParameters)) }
installedApp.managedObjectContext?.perform {
let v = minimuxer_to_operation(code: 1)
do {
var x = try debug_app(app_id: installedApp.resignedBundleIdentifier)
switch x {
case .Good:
self.finish(.success(()))
case let .Bad(code):
self.finish(.failure(minimuxer_to_operation(code: code)))
}
} catch let Uhoh.Bad(code) {
self.finish(.failure(minimuxer_to_operation(code: code)))
} catch {
self.finish(.failure(OperationError.unknown))
}
}
}
}

View File

@@ -0,0 +1,58 @@
//
// FetchAnisetteDataOperation.swift
// AltStore
//
// Created by Riley Testut on 1/7/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import SideStoreCore
import RoxasUIKit
@objc(FetchAnisetteDataOperation)
final class FetchAnisetteDataOperation: ResultOperation<ALTAnisetteData> {
let context: OperationContext
init(context: OperationContext) {
self.context = context
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
let url = AnisetteManager.currentURL
DLOG("Anisette URL: %@", url.absoluteString)
let task = URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
do {
// make sure this JSON is in the format we expect
// convert data to json
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] {
// try to read out a dictionary
// for some reason serial number isn't needed but it doesn't work unless it has a value
let formattedJSON: [String: String] = ["machineID": json["X-Apple-I-MD-M"]!, "oneTimePassword": json["X-Apple-I-MD"]!, "localUserID": json["X-Apple-I-MD-LU"]!, "routingInfo": json["X-Apple-I-MD-RINFO"]!, "deviceUniqueIdentifier": json["X-Mme-Device-Id"]!, "deviceDescription": json["X-MMe-Client-Info"]!, "date": json["X-Apple-I-Client-Time"]!, "locale": json["X-Apple-Locale"]!, "timeZone": json["X-Apple-I-TimeZone"]!, "deviceSerialNumber": "1"]
if let anisette = ALTAnisetteData(json: formattedJSON) {
DLOG("Anisette used: %@", formattedJSON)
self.finish(.success(anisette))
}
}
} catch let error as NSError {
print("Failed to load: \(error.localizedDescription)")
self.finish(.failure(error))
}
}
task.resume()
}
}

View File

@@ -0,0 +1,65 @@
//
// FetchAppIDsOperation.swift
// AltStore
//
// Created by Riley Testut on 1/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import SideStoreCore
import RoxasUIKit
@objc(FetchAppIDsOperation)
final class FetchAppIDsOperation: ResultOperation<([AppID], NSManagedObjectContext)> {
let context: AuthenticatedOperationContext
let managedObjectContext: NSManagedObjectContext
init(context: AuthenticatedOperationContext, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) {
self.context = context
self.managedObjectContext = managedObjectContext
super.init()
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard
let team = context.team,
let session = context.session
else { return finish(.failure(OperationError.invalidParameters)) }
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { appIDs, error in
self.managedObjectContext.perform {
do {
let fetchedAppIDs = try Result(appIDs, error).get()
guard let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), team.identifier), in: self.managedObjectContext) else { throw OperationError.notAuthenticated }
let fetchedIdentifiers = fetchedAppIDs.map { $0.identifier }
let deletedAppIDsRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
deletedAppIDsRequest.predicate = NSPredicate(format: "%K == %@ AND NOT (%K IN %@)",
#keyPath(AppID.team), team,
#keyPath(AppID.identifier), fetchedIdentifiers)
let deletedAppIDs = try self.managedObjectContext.fetch(deletedAppIDsRequest)
deletedAppIDs.forEach { self.managedObjectContext.delete($0) }
let appIDs = fetchedAppIDs.map { AppID($0, team: team, context: self.managedObjectContext) }
self.finish(.success((appIDs, self.managedObjectContext)))
} catch {
self.finish(.failure(error))
}
}
}
}
}

View File

@@ -0,0 +1,404 @@
//
// FetchProvisioningProfilesOperation.swift
// AltStore
//
// Created by Riley Testut on 2/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import SideStoreCore
import RoxasUIKit
@objc(FetchProvisioningProfilesOperation)
final class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioningProfile]> {
let context: AppOperationContext
var additionalEntitlements: [ALTEntitlement: Any]?
private let appGroupsLock = NSLock()
init(context: AppOperationContext) {
self.context = context
super.init()
progress.totalUnitCount = 1
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard
let team = context.team,
let session = context.session
else { return finish(.failure(OperationError.invalidParameters)) }
guard let app = context.app else { return finish(.failure(OperationError.appNotFound)) }
progress.totalUnitCount = Int64(1 + app.appExtensions.count)
prepareProvisioningProfile(for: app, parentApp: nil, team: team, session: session) { result in
do {
self.progress.completedUnitCount += 1
let profile = try result.get()
var profiles = [app.bundleIdentifier: profile]
var error: Error?
let dispatchGroup = DispatchGroup()
for appExtension in app.appExtensions {
dispatchGroup.enter()
self.prepareProvisioningProfile(for: appExtension, parentApp: app, team: team, session: session) { result in
switch result {
case let .failure(e): error = e
case let .success(profile): profiles[appExtension.bundleIdentifier] = profile
}
dispatchGroup.leave()
self.progress.completedUnitCount += 1
}
}
dispatchGroup.notify(queue: .global()) {
if let error = error {
self.finish(.failure(error))
} else {
self.finish(.success(profiles))
}
}
} catch {
self.finish(.failure(error))
}
}
}
func process<T>(_ result: Result<T, Error>) -> T? {
switch result {
case let .failure(error):
finish(.failure(error))
return nil
case let .success(value):
guard !isCancelled else {
finish(.failure(OperationError.cancelled))
return nil
}
return value
}
}
}
extension FetchProvisioningProfilesOperation {
func prepareProvisioningProfile(for app: ALTApplication, parentApp: ALTApplication?, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void) {
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let preferredBundleID: String?
// Check if we have already installed this app with this team before.
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier)
if let installedApp = InstalledApp.first(satisfying: predicate, in: context) {
// Teams match if installedApp.team has same identifier as team,
// or if installedApp.team is nil but resignedBundleIdentifier contains the team's identifier.
let teamsMatch = installedApp.team?.identifier == team.identifier || (installedApp.team == nil && installedApp.resignedBundleIdentifier.contains(team.identifier))
// #if DEBUG
//
// if app.isAltStoreApp
// {
// // Use legacy bundle ID format for AltStore.
// preferredBundleID = teamsMatch ? installedApp.resignedBundleIdentifier : nil
// }
// else
// {
// preferredBundleID = teamsMatch ? installedApp.resignedBundleIdentifier : nil
// }
//
// #else
if teamsMatch {
// This app is already installed with the same team, so use the same resigned bundle identifier as before.
// This way, if we change the identifier format (again), AltStore will continue to use
// the old bundle identifier to prevent it from installing as a new app.
preferredBundleID = installedApp.resignedBundleIdentifier
} else {
preferredBundleID = nil
}
// #endif
} else {
preferredBundleID = nil
}
let bundleID: String
if let preferredBundleID = preferredBundleID {
bundleID = preferredBundleID
} else {
// This app isn't already installed, so create the resigned bundle identifier ourselves.
// Or, if the app _is_ installed but with a different team, we need to create a new
// bundle identifier anyway to prevent collisions with the previous team.
let parentBundleID = parentApp?.bundleIdentifier ?? app.bundleIdentifier
let updatedParentBundleID: String
if app.isAltStoreApp {
// Use legacy bundle ID format for AltStore (and its extensions).
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
} else {
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
}
bundleID = app.bundleIdentifier.replacingOccurrences(of: parentBundleID, with: updatedParentBundleID)
}
let preferredName: String
if let parentApp = parentApp {
preferredName = parentApp.name + " " + app.name
} else {
preferredName = app.name
}
// Register
self.registerAppID(for: app, name: preferredName, bundleIdentifier: bundleID, team: team, session: session) { result in
switch result {
case let .failure(error): completionHandler(.failure(error))
case let .success(appID):
// Update features
self.updateFeatures(for: appID, app: app, team: team, session: session) { result in
switch result {
case let .failure(error): completionHandler(.failure(error))
case let .success(appID):
// Update app groups
self.updateAppGroups(for: appID, app: app, team: team, session: session) { result in
switch result {
case let .failure(error): completionHandler(.failure(error))
case let .success(appID):
// Fetch Provisioning Profile
self.fetchProvisioningProfile(for: appID, team: team, session: session) { result in
completionHandler(result)
}
}
}
}
}
}
}
}
}
func registerAppID(for application: ALTApplication, name: String, bundleIdentifier: String, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void) {
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { appIDs, error in
do {
let appIDs = try Result(appIDs, error).get()
if let appID = appIDs.first(where: { $0.bundleIdentifier.lowercased() == bundleIdentifier.lowercased() }) {
completionHandler(.success(appID))
} else {
let requiredAppIDs = 1 + application.appExtensions.count
let availableAppIDs = max(0, Team.maximumFreeAppIDs - appIDs.count)
let sortedExpirationDates = appIDs.compactMap { $0.expirationDate }.sorted(by: { $0 < $1 })
if team.type == .free {
if requiredAppIDs > availableAppIDs {
if let expirationDate = sortedExpirationDates.first {
throw OperationError.maximumAppIDLimitReached(application: application, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, nextExpirationDate: expirationDate)
} else {
throw ALTAppleAPIError(.maximumAppIDLimitReached)
}
}
}
ALTAppleAPI.shared.addAppID(withName: name, bundleIdentifier: bundleIdentifier, team: team, session: session) { appID, error in
do {
do {
let appID = try Result(appID, error).get()
completionHandler(.success(appID))
} catch ALTAppleAPIError.maximumAppIDLimitReached {
if let expirationDate = sortedExpirationDates.first {
throw OperationError.maximumAppIDLimitReached(application: application, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, nextExpirationDate: expirationDate)
} else {
throw ALTAppleAPIError(.maximumAppIDLimitReached)
}
}
} catch {
completionHandler(.failure(error))
}
}
}
} catch {
completionHandler(.failure(error))
}
}
}
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void) {
var entitlements = app.entitlements
for (key, value) in additionalEntitlements ?? [:] {
entitlements[key] = value
}
let requiredFeatures = entitlements.compactMap { entitlement, value -> (ALTFeature, Any)? in
guard let feature = ALTFeature(entitlement: entitlement) else { return nil }
return (feature, value)
}
var features = requiredFeatures.reduce(into: [ALTFeature: Any]()) { $0[$1.0] = $1.1 }
if let applicationGroups = entitlements[.appGroups] as? [String], !applicationGroups.isEmpty {
// App uses app groups, so assign `true` to enable the feature.
features[.appGroups] = true
} else {
// App has no app groups, so assign `false` to disable the feature.
features[.appGroups] = false
}
var updateFeatures = false
// Determine whether the required features are already enabled for the AppID.
for (feature, value) in features {
if let appIDValue = appID.features[feature] as AnyObject?, (value as AnyObject).isEqual(appIDValue) {
// AppID already has this feature enabled and the values are the same.
continue
} else if appID.features[feature] == nil, let shouldEnableFeature = value as? Bool, !shouldEnableFeature {
// AppID doesn't already have this feature enabled, but we want it disabled anyway.
continue
} else {
// AppID either doesn't have this feature enabled or the value has changed,
// so we need to update it to reflect new values.
updateFeatures = true
break
}
}
if updateFeatures {
let appID = appID.copy() as! ALTAppID
appID.features = features
ALTAppleAPI.shared.update(appID, team: team, session: session) { appID, error in
completionHandler(Result(appID, error))
}
} else {
completionHandler(.success(appID))
}
}
func updateAppGroups(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void) {
var entitlements = app.entitlements
for (key, value) in additionalEntitlements ?? [:] {
entitlements[key] = value
}
guard var applicationGroups = entitlements[.appGroups] as? [String], !applicationGroups.isEmpty else {
// Assigning an App ID to an empty app group array fails,
// so just do nothing if there are no app groups.
return completionHandler(.success(appID))
}
if app.isAltStoreApp {
// Potentially updating app groups for this specific AltStore.
// Find the (unique) AltStore app group, then replace it
// with the correct "base" app group ID.
// Otherwise, we may append a duplicate team identifier to the end.
if let index = applicationGroups.firstIndex(where: { $0.contains(Bundle.baseAltStoreAppGroupID) }) {
applicationGroups[index] = Bundle.baseAltStoreAppGroupID
} else {
applicationGroups.append(Bundle.baseAltStoreAppGroupID)
}
}
// Dispatch onto global queue to prevent appGroupsLock deadlock.
DispatchQueue.global().async {
// Ensure we're not concurrently fetching and updating app groups,
// which can lead to race conditions such as adding an app group twice.
self.appGroupsLock.lock()
func finish(_ result: Result<ALTAppID, Error>) {
self.appGroupsLock.unlock()
completionHandler(result)
}
ALTAppleAPI.shared.fetchAppGroups(for: team, session: session) { groups, error in
switch Result(groups, error) {
case let .failure(error): finish(.failure(error))
case let .success(fetchedGroups):
let dispatchGroup = DispatchGroup()
var groups = [ALTAppGroup]()
var errors = [Error]()
for groupIdentifier in applicationGroups {
let adjustedGroupIdentifier = groupIdentifier + "." + team.identifier
if let group = fetchedGroups.first(where: { $0.groupIdentifier == adjustedGroupIdentifier }) {
groups.append(group)
} else {
dispatchGroup.enter()
// Not all characters are allowed in group names, so we replace periods with spaces (like Apple does).
let name = "AltStore " + groupIdentifier.replacingOccurrences(of: ".", with: " ")
ALTAppleAPI.shared.addAppGroup(withName: name, groupIdentifier: adjustedGroupIdentifier, team: team, session: session) { group, error in
switch Result(group, error) {
case let .success(group): groups.append(group)
case let .failure(error): errors.append(error)
}
dispatchGroup.leave()
}
}
}
dispatchGroup.notify(queue: .global()) {
if let error = errors.first {
finish(.failure(error))
} else {
ALTAppleAPI.shared.assign(appID, to: Array(groups), team: team, session: session) { success, error in
let result = Result(success, error)
finish(result.map { _ in appID })
}
}
}
}
}
}
}
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void) {
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { profile, error in
switch Result(profile, error) {
case let .failure(error): completionHandler(.failure(error))
case let .success(profile):
// Delete existing profile
ALTAppleAPI.shared.delete(profile, for: team, session: session) { success, error in
switch Result(success, error) {
case let .failure(error): completionHandler(.failure(error))
case .success:
// Fetch new provisiong profile
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { profile, error in
completionHandler(Result(profile, error))
}
}
}
}
}
}
}

View File

@@ -0,0 +1,96 @@
//
// FetchSourceOperation.swift
// AltStore
//
// Created by Riley Testut on 7/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import CoreData
import Foundation
import SideStoreCore
import RoxasUIKit
@objc(FetchSourceOperation)
final class FetchSourceOperation: ResultOperation<Source> {
let sourceURL: URL
let managedObjectContext: NSManagedObjectContext
private let session: URLSession
private lazy var dateFormatter: ISO8601DateFormatter = {
let dateFormatter = ISO8601DateFormatter()
return dateFormatter
}()
init(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) {
self.sourceURL = sourceURL
self.managedObjectContext = managedObjectContext
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
session = URLSession(configuration: configuration)
}
override func main() {
super.main()
let dataTask = session.dataTask(with: sourceURL) { data, response, error in
let childContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(withParent: self.managedObjectContext)
childContext.mergePolicy = NSOverwriteMergePolicy
childContext.perform {
do {
let (data, _) = try Result((data, response), error).get()
let decoder = SideStoreCore.JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder -> Date in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
// Full ISO8601 Format.
self.dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withTimeZone]
if let date = self.dateFormatter.date(from: text) {
return date
}
// Just date portion of ISO8601.
self.dateFormatter.formatOptions = [.withFullDate]
if let date = self.dateFormatter.date(from: text) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.")
}
decoder.managedObjectContext = childContext
decoder.sourceURL = self.sourceURL
let source = try decoder.decode(Source.self, from: data)
let identifier = source.identifier
try childContext.save()
self.managedObjectContext.perform {
if let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), identifier), in: self.managedObjectContext) {
self.finish(.success(source))
} else {
self.finish(.failure(OperationError.noSources))
}
}
} catch {
self.managedObjectContext.perform {
self.finish(.failure(error))
}
}
}
}
progress.addChild(dataTask.progress, withPendingUnitCount: 1)
dataTask.resume()
}
}

View File

@@ -0,0 +1,55 @@
//
// FetchTrustedSourcesOperation.swift
// AltStore
//
// Created by Riley Testut on 4/13/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Foundation
private extension URL {
#if STAGING
static let trustedSources = URL(string: "https://raw.githubusercontent.com/SideStore/SideStore/develop/trustedapps.json")!
#else
static let trustedSources = URL(string: "https://raw.githubusercontent.com/SideStore/SideStore/develop/trustedapps.json")!
#endif
}
public extension FetchTrustedSourcesOperation {
public struct TrustedSource: Decodable {
public var identifier: String
public var sourceURL: URL?
}
private struct Response: Decodable {
var version: Int
var sources: [FetchTrustedSourcesOperation.TrustedSource]
}
}
public final class FetchTrustedSourcesOperation: ResultOperation<[FetchTrustedSourcesOperation.TrustedSource]> {
public override func main() {
super.main()
let dataTask = URLSession.shared.dataTask(with: .trustedSources) { data, response, error in
do {
if let response = response as? HTTPURLResponse {
guard response.statusCode != 404 else {
self.finish(.failure(URLError(.fileDoesNotExist, userInfo: [NSURLErrorKey: URL.trustedSources])))
return
}
}
guard let data = data else { throw error! }
let response = try Foundation.JSONDecoder().decode(Response.self, from: data)
self.finish(.success(response.sources))
} catch {
self.finish(.failure(error))
}
}
dataTask.resume()
}
}

View File

@@ -0,0 +1,178 @@
//
// InstallAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/19/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Network
import AltSign
import SideStoreCore
import RoxasUIKit
import MiniMuxerSwift
import minimuxer
@objc(InstallAppOperation)
final class InstallAppOperation: ResultOperation<InstalledApp> {
let context: InstallAppOperationContext
private var didCleanUp = false
init(context: InstallAppOperationContext) {
self.context = context
super.init()
progress.totalUnitCount = 100
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard
let certificate = context.certificate,
let resignedApp = context.resignedApp
else { return finish(.failure(OperationError.invalidParameters)) }
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
backgroundContext.perform {
/* App */
let installedApp: InstalledApp
// Fetch + update rather than insert + resolve merge conflicts to prevent potential context-level conflicts.
if let app = InstalledApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), self.context.bundleIdentifier), in: backgroundContext) {
installedApp = app
} else {
installedApp = InstalledApp(resignedApp: resignedApp, originalBundleIdentifier: self.context.bundleIdentifier, certificateSerialNumber: certificate.serialNumber, context: backgroundContext)
}
installedApp.update(resignedApp: resignedApp, certificateSerialNumber: certificate.serialNumber)
installedApp.needsResign = false
if let team = DatabaseManager.shared.activeTeam(in: backgroundContext) {
installedApp.team = team
}
/* App Extensions */
var installedExtensions = Set<InstalledExtension>()
if
let bundle = Bundle(url: resignedApp.fileURL),
let directory = bundle.builtInPlugInsURL,
let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants])
{
for case let fileURL as URL in enumerator {
guard let appExtensionBundle = Bundle(url: fileURL) else { continue }
guard let appExtension = ALTApplication(fileURL: appExtensionBundle.bundleURL) else { continue }
let parentBundleID = self.context.bundleIdentifier
let resignedParentBundleID = resignedApp.bundleIdentifier
let resignedBundleID = appExtension.bundleIdentifier
let originalBundleID = resignedBundleID.replacingOccurrences(of: resignedParentBundleID, with: parentBundleID)
print("`parentBundleID`: \(parentBundleID)")
print("`resignedParentBundleID`: \(resignedParentBundleID)")
print("`resignedBundleID`: \(resignedBundleID)")
print("`originalBundleID`: \(originalBundleID)")
let installedExtension: InstalledExtension
if let appExtension = installedApp.appExtensions.first(where: { $0.bundleIdentifier == originalBundleID }) {
installedExtension = appExtension
} else {
installedExtension = InstalledExtension(resignedAppExtension: appExtension, originalBundleIdentifier: originalBundleID, context: backgroundContext)
}
installedExtension.update(resignedAppExtension: appExtension)
installedExtensions.insert(installedExtension)
}
}
installedApp.appExtensions = installedExtensions
self.context.beginInstallationHandler?(installedApp)
// Temporary directory and resigned .ipa no longer needed, so delete them now to ensure AltStore doesn't quit before we get the chance to.
self.cleanUp()
var activeProfiles: Set<String>?
if let sideloadedAppsLimit = UserDefaults.standard.activeAppsLimit {
// When installing these new profiles, AltServer will remove all non-active profiles to ensure we remain under limit.
let fetchRequest = InstalledApp.activeAppsFetchRequest()
fetchRequest.includesPendingChanges = false
var activeApps = InstalledApp.fetch(fetchRequest, in: backgroundContext)
if !activeApps.contains(installedApp) {
let activeAppsCount = activeApps.map { $0.requiredActiveSlots }.reduce(0, +)
let availableActiveApps = max(sideloadedAppsLimit - activeAppsCount, 0)
if installedApp.requiredActiveSlots <= availableActiveApps {
// This app has not been explicitly activated, but there are enough slots available,
// so implicitly activate it.
installedApp.isActive = true
activeApps.append(installedApp)
} else {
installedApp.isActive = false
}
}
activeProfiles = Set(activeApps.flatMap { installedApp -> [String] in
let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier }
return [installedApp.resignedBundleIdentifier] + appExtensionProfiles
})
}
let ns_bundle = NSString(string: installedApp.bundleIdentifier)
let ns_bundle_ptr = UnsafeMutablePointer<CChar>(mutating: ns_bundle.utf8String)
let res = minimuxer_install_ipa(ns_bundle_ptr)
if res == 0 {
installedApp.refreshedDate = Date()
self.finish(.success(installedApp))
} else {
self.finish(.failure(minimuxer_to_operation(code: res)))
}
}
}
override func finish(_ result: Result<InstalledApp, Error>) {
cleanUp()
// Only remove refreshed IPA when finished.
if let app = context.app {
let fileURL = InstalledApp.refreshedIPAURL(for: app)
do {
try FileManager.default.removeItem(at: fileURL)
} catch {
print("Failed to remove refreshed .ipa:", error)
}
}
super.finish(result)
}
}
private extension InstallAppOperation {
func cleanUp() {
guard !didCleanUp else { return }
didCleanUp = true
do {
try FileManager.default.removeItem(at: context.temporaryDirectory)
} catch {
print("Failed to remove temporary directory.", error)
}
}
}

View File

@@ -0,0 +1,80 @@
//
// Operation.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import RoxasUIKit
public class ResultOperation<ResultType>: Operation {
var resultHandler: ((Result<ResultType, Error>) -> Void)?
@available(*, unavailable)
public override func finish() {
super.finish()
}
func finish(_ result: Result<ResultType, Error>) {
guard !isFinished else { return }
if isCancelled {
resultHandler?(.failure(OperationError.cancelled))
} else {
resultHandler?(result)
}
super.finish()
}
}
public class Operation: RSTOperation, ProgressReporting {
public let progress = Progress.discreteProgress(totalUnitCount: 1)
private var backgroundTaskID: UIBackgroundTaskIdentifier?
public override var isAsynchronous: Bool {
true
}
override init() {
super.init()
progress.cancellationHandler = { [weak self] in self?.cancel() }
}
public override func cancel() {
super.cancel()
if !progress.isCancelled {
progress.cancel()
}
}
public override func main() {
super.main()
let name = "com.altstore." + NSStringFromClass(type(of: self))
backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
guard let backgroundTask = self?.backgroundTaskID else { return }
self?.cancel()
UIApplication.shared.endBackgroundTask(backgroundTask)
self?.backgroundTaskID = .invalid
}
}
public override func finish() {
guard !isFinished else { return }
super.finish()
if let backgroundTaskID = backgroundTaskID {
UIApplication.shared.endBackgroundTask(backgroundTaskID)
self.backgroundTaskID = .invalid
}
}
}

View File

@@ -0,0 +1,107 @@
//
// Contexts.swift
// AltStore
//
// Created by Riley Testut on 6/20/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import CoreData
import Foundation
import Network
import AltSign
import SideStoreCore
public class OperationContext {
var error: Error?
var presentingViewController: UIViewController?
let operations: NSHashTable<Foundation.Operation>
public init(error: Error? = nil, operations: [Foundation.Operation] = []) {
self.error = error
self.operations = NSHashTable<Foundation.Operation>.weakObjects()
for operation in operations {
self.operations.add(operation)
}
}
public convenience init(context: OperationContext) {
self.init(error: context.error, operations: context.operations.allObjects)
}
}
public final class AuthenticatedOperationContext: OperationContext {
var session: ALTAppleAPISession?
var team: ALTTeam?
var certificate: ALTCertificate?
weak var authenticationOperation: AuthenticationOperation?
public convenience init(context: AuthenticatedOperationContext) {
self.init(error: context.error, operations: context.operations.allObjects)
session = context.session
team = context.team
certificate = context.certificate
authenticationOperation = context.authenticationOperation
}
}
@dynamicMemberLookup
class AppOperationContext {
let bundleIdentifier: String
let authenticatedContext: AuthenticatedOperationContext
var app: ALTApplication?
var provisioningProfiles: [String: ALTProvisioningProfile]?
var isFinished = false
var error: Error? {
get {
_error ?? self.authenticatedContext.error
}
set {
_error = newValue
}
}
private var _error: Error?
init(bundleIdentifier: String, authenticatedContext: AuthenticatedOperationContext) {
self.bundleIdentifier = bundleIdentifier
self.authenticatedContext = authenticatedContext
}
subscript<T>(dynamicMember keyPath: WritableKeyPath<AuthenticatedOperationContext, T>) -> T {
self.authenticatedContext[keyPath: keyPath]
}
}
class InstallAppOperationContext: AppOperationContext {
lazy var temporaryDirectory: URL = {
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
do { try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) } catch { self.error = error }
return temporaryDirectory
}()
var resignedApp: ALTApplication?
var installedApp: InstalledApp? {
didSet {
installedAppContext = installedApp?.managedObjectContext
}
}
private var installedAppContext: NSManagedObjectContext?
var beginInstallationHandler: ((InstalledApp) -> Void)?
var alternateIconURL: URL?
}

View File

@@ -0,0 +1,160 @@
//
// OperationError.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import AltSign
import Foundation
enum OperationError: LocalizedError {
static let domain = OperationError.unknown._domain
case unknown
case unknownResult
case cancelled
case timedOut
case notAuthenticated
case appNotFound
case unknownUDID
case invalidApp
case invalidParameters
case maximumAppIDLimitReached(application: ALTApplication, requiredAppIDs: Int, availableAppIDs: Int, nextExpirationDate: Date)
case noSources
case openAppFailed(name: String)
case missingAppGroup
case noDevice
case createService(name: String)
case getFromDevice(name: String)
case setArgument(name: String)
case afc
case install
case uninstall
case lookupApps
case detach
case functionArguments
case profileInstall
case noConnection
var failureReason: String? {
switch self {
case .unknown: return NSLocalizedString("An unknown error occured.", comment: "")
case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "")
case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "")
case .timedOut: return NSLocalizedString("The operation timed out.", comment: "")
case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "")
case .appNotFound: return NSLocalizedString("App not found.", comment: "")
case .unknownUDID: return NSLocalizedString("Unknown device UDID.", comment: "")
case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "")
case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "")
case .noSources: return NSLocalizedString("There are no SideStore sources.", comment: "")
case let .openAppFailed(name): return String(format: NSLocalizedString("SideStore was denied permission to launch %@.", comment: ""), name)
case .missingAppGroup: return NSLocalizedString("SideStore's shared app group could not be found.", comment: "")
case .maximumAppIDLimitReached: return NSLocalizedString("Cannot register more than 10 App IDs.", comment: "")
case .noDevice: return NSLocalizedString("Cannot fetch the device from the muxer", comment: "")
case let .createService(name): return String(format: NSLocalizedString("Cannot start a %@ server on the device.", comment: ""), name)
case let .getFromDevice(name): return String(format: NSLocalizedString("Cannot fetch %@ from the device.", comment: ""), name)
case let .setArgument(name): return String(format: NSLocalizedString("Cannot set %@ on the device.", comment: ""), name)
case .afc: return NSLocalizedString("AFC was unable to manage files on the device", comment: "")
case .install: return NSLocalizedString("Unable to install the app from the staging directory", comment: "")
case .uninstall: return NSLocalizedString("Unable to uninstall the app", comment: "")
case .lookupApps: return NSLocalizedString("Unable to fetch apps from the device", comment: "")
case .detach: return NSLocalizedString("Unable to detach from the app's process", comment: "")
case .functionArguments: return NSLocalizedString("A function was passed invalid arguments", comment: "")
case .profileInstall: return NSLocalizedString("Unable to manage profiles on the device", comment: "")
case .noConnection: return NSLocalizedString("Unable to connect to the device, make sure Wireguard is enabled and you're connected to WiFi", comment: "")
}
}
var recoverySuggestion: String? {
switch self {
case let .maximumAppIDLimitReached(application, requiredAppIDs, availableAppIDs, date):
let baseMessage = NSLocalizedString("Delete sideloaded apps to free up App ID slots.", comment: "")
let message: String
if requiredAppIDs > 1 {
let availableText: String
switch availableAppIDs {
case 0: availableText = NSLocalizedString("none are available", comment: "")
case 1: availableText = NSLocalizedString("only 1 is available", comment: "")
default: availableText = String(format: NSLocalizedString("only %@ are available", comment: ""), NSNumber(value: availableAppIDs))
}
let prefixMessage = String(format: NSLocalizedString("%@ requires %@ App IDs, but %@.", comment: ""), application.name, NSNumber(value: requiredAppIDs), availableText)
message = prefixMessage + " " + baseMessage
} else {
let dateComponents = Calendar.current.dateComponents([.day, .hour, .minute], from: Date(), to: date)
let dateComponentsFormatter = DateComponentsFormatter()
dateComponentsFormatter.maximumUnitCount = 1
dateComponentsFormatter.unitsStyle = .full
let remainingTime = dateComponentsFormatter.string(from: dateComponents)!
let remainingTimeMessage = String(format: NSLocalizedString("You can register another App ID in %@.", comment: ""), remainingTime)
message = baseMessage + " " + remainingTimeMessage
}
return message
default: return nil
}
}
}
func minimuxer_to_operation(code: Int32) -> OperationError {
switch code {
case 1:
return OperationError.noDevice
case 2:
return OperationError.createService(name: "debug")
case 3:
return OperationError.createService(name: "instproxy")
case 4:
return OperationError.getFromDevice(name: "installed apps")
case 5:
return OperationError.getFromDevice(name: "path to the app")
case 6:
return OperationError.getFromDevice(name: "bundle path")
case 7:
return OperationError.setArgument(name: "max packet")
case 8:
return OperationError.setArgument(name: "working directory")
case 9:
return OperationError.setArgument(name: "argv")
case 10:
return OperationError.getFromDevice(name: "launch success")
case 11:
return OperationError.detach
case 12:
return OperationError.functionArguments
case 13:
return OperationError.createService(name: "AFC")
case 14:
return OperationError.afc
case 15:
return OperationError.install
case 16:
return OperationError.uninstall
case 17:
return OperationError.createService(name: "misagent")
case 18:
return OperationError.profileInstall
case 19:
return OperationError.profileInstall
case 20:
return OperationError.noConnection
default:
return OperationError.unknown
}
}

View File

@@ -0,0 +1,225 @@
//
// PatchAppOperation.swift
// AltStore
//
// Created by Riley Testut on 10/13/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import AppleArchive
import Combine
import System
import UIKit
import SidePatcher
import AltSign
import SideStoreCore
import RoxasUIKit
@available(iOS 14, *)
protocol PatchAppContext {
var bundleIdentifier: String { get }
var temporaryDirectory: URL { get }
var resignedApp: ALTApplication? { get }
var error: Error? { get }
}
enum PatchAppError: LocalizedError {
case unsupportedOperatingSystemVersion(OperatingSystemVersion)
var errorDescription: String? {
switch self {
case let .unsupportedOperatingSystemVersion(osVersion):
var osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion)"
if osVersion.patchVersion != 0 {
osVersionString += ".\(osVersion.patchVersion)"
}
let errorDescription = String(format: NSLocalizedString("The OTA download URL for iOS %@ could not be determined.", comment: ""), osVersionString)
return errorDescription
}
}
}
private struct OTAUpdate {
var url: URL
var archivePath: String
}
@available(iOS 14, *)
public final class PatchAppOperation: ResultOperation<Void> {
let context: PatchAppContext
var progressHandler: ((Progress, String) -> Void)?
private let appPatcher = SideAppPatcher()
private lazy var patchDirectory: URL = self.context.temporaryDirectory.appendingPathComponent("Patch", isDirectory: true)
private var cancellable: AnyCancellable?
init(context: PatchAppContext) {
self.context = context
super.init()
progress.totalUnitCount = 100
}
public override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard let resignedApp = context.resignedApp else { return finish(.failure(OperationError.invalidParameters)) }
progressHandler?(progress, NSLocalizedString("Downloading iOS firmware...", comment: ""))
cancellable = fetchOTAUpdate()
.flatMap { self.downloadArchive(from: $0) }
.flatMap { self.extractSpotlightFromArchive(at: $0) }
.flatMap { self.patch(resignedApp, withBinaryAt: $0) }
.tryMap { try FileManager.default.zipAppBundle(at: $0) }
.tryMap { fileURL in
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL)
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
}
.receive(on: RunLoop.main)
.sink { completion in
switch completion {
case let .failure(error): self.finish(.failure(error))
case .finished: self.finish(.success(()))
}
} receiveValue: { _ in }
}
public override func cancel() {
super.cancel()
cancellable?.cancel()
cancellable = nil
}
}
private let ALTFragmentZipCallback: @convention(c) (UInt32) -> Void = { percentageComplete in
guard let progress = Progress.current() else { return }
if percentageComplete == 100, progress.completedUnitCount == 0 {
// Ignore first percentageComplete, which is always 100.
return
}
progress.completedUnitCount = Int64(percentageComplete)
}
@available(iOS 14, *)
private extension PatchAppOperation {
func fetchOTAUpdate() -> AnyPublisher<OTAUpdate, Error> {
Just(()).tryMap {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
switch (osVersion.majorVersion, osVersion.minorVersion) {
case (14, 3):
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2020WinterFCS/patches/001-87330/99E29969-F6B6-422A-B946-70DE2E2D73BE/com_apple_MobileAsset_SoftwareUpdate/67f9e42f5e57a20e0a87eaf81b69dd2a61311d3f.zip")!,
archivePath: "AssetData/payloadv2/payload.042")
case (14, 4):
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2021WinterFCS/patches/001-98606/43AF99A1-F286-43B1-A101-F9F856EA395A/com_apple_MobileAsset_SoftwareUpdate/c4985c32c344beb7b49c61919b4e39d1fd336c90.zip")!,
archivePath: "AssetData/payloadv2/payload.042")
case (14, 5):
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2021SpringFCS/patches/061-84483/AB525139-066E-46F8-8E85-DCE802C03BA8/com_apple_MobileAsset_SoftwareUpdate/788573ae93113881db04269acedeecabbaa643e3.zip")!,
archivePath: "AssetData/payloadv2/payload.043")
default: throw PatchAppError.unsupportedOperatingSystemVersion(osVersion)
}
}
.eraseToAnyPublisher()
}
func downloadArchive(from update: OTAUpdate) -> AnyPublisher<URL, Error> {
Just(()).tryMap {
#if targetEnvironment(simulator)
throw PatchAppError.unsupportedOperatingSystemVersion(ProcessInfo.processInfo.operatingSystemVersion)
#else
try FileManager.default.createDirectory(at: self.patchDirectory, withIntermediateDirectories: true, attributes: nil)
let archiveURL = self.patchDirectory.appendingPathComponent("ota.archive")
try archiveURL.withUnsafeFileSystemRepresentation { archivePath in
guard let fz = fragmentzip_open((update.url.absoluteString as NSString).utf8String!) else {
throw URLError(.cannotConnectToHost, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The connection failed because a connection cannot be made to the host.", comment: ""),
NSURLErrorKey: update.url])
}
defer { fragmentzip_close(fz) }
self.progress.becomeCurrent(withPendingUnitCount: 100)
defer { self.progress.resignCurrent() }
guard fragmentzip_download_file(fz, update.archivePath, archivePath!, ALTFragmentZipCallback) == 0 else {
throw URLError(.networkConnectionLost, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The connection failed because the network connection was lost.", comment: ""),
NSURLErrorKey: update.url])
}
}
print("Downloaded OTA archive.")
return archiveURL
#endif
}
.mapError { ($0 as NSError).withLocalizedFailure(NSLocalizedString("Could not download OTA archive.", comment: "")) }
.eraseToAnyPublisher()
}
func extractSpotlightFromArchive(at archiveURL: URL) -> AnyPublisher<URL, Error> {
Just(()).tryMap {
#if targetEnvironment(simulator)
throw PatchAppError.unsupportedOperatingSystemVersion(ProcessInfo.processInfo.operatingSystemVersion)
#else
let spotlightPath = "Applications/Spotlight.app/Spotlight"
let spotlightFileURL = self.patchDirectory.appendingPathComponent(spotlightPath)
guard let readFileStream = ArchiveByteStream.fileStream(path: FilePath(archiveURL.path), mode: .readOnly, options: [], permissions: FilePermissions(rawValue: 0o644)),
let decompressStream = ArchiveByteStream.decompressionStream(readingFrom: readFileStream),
let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressStream),
let readStream = ArchiveStream.extractStream(extractingTo: FilePath(self.patchDirectory.path))
else { throw CocoaError(.fileReadCorruptFile, userInfo: [NSURLErrorKey: archiveURL]) }
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: readStream) { _, filePath, _ in
guard filePath == FilePath(spotlightPath) else { return .skip }
return .ok
}
print("Extracted Spotlight from OTA archive.")
return spotlightFileURL
#endif
}
.mapError { ($0 as NSError).withLocalizedFailure(NSLocalizedString("Could not extract Spotlight from OTA archive.", comment: "")) }
.eraseToAnyPublisher()
}
func patch(_ app: ALTApplication, withBinaryAt patchFileURL: URL) -> AnyPublisher<URL, Error> {
Just(()).tryMap {
// executableURL may be nil, so use infoDictionary instead to determine executable name.
// guard let appName = app.bundle.executableURL?.lastPathComponent else { throw OperationError.invalidApp }
guard let appName = app.bundle.infoDictionary?[kCFBundleExecutableKey as String] as? String else { throw OperationError.invalidApp }
let temporaryAppURL = self.patchDirectory.appendingPathComponent("Patched.app", isDirectory: true)
try FileManager.default.copyItem(at: app.fileURL, to: temporaryAppURL)
let appBinaryURL = temporaryAppURL.appendingPathComponent(appName, isDirectory: false)
try self.appPatcher.patchAppBinary(at: appBinaryURL, withBinaryAt: patchFileURL)
print("Patched \(app.name).")
return temporaryAppURL
}
.mapError { ($0 as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), app.name)) }
.eraseToAnyPublisher()
}
}

View File

@@ -0,0 +1,443 @@
//
// 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
@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 {
print("Failed to create temporary directory:", error)
}
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 {
print("Failed to remove temporary directory:", error)
}
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 {
print("Error unzipping app bundle:", error)
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,77 @@
//
// RefreshAppOperation.swift
// AltStore
//
// Created by Riley Testut on 2/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import SideStoreCore
import minimuxer
import MiniMuxerSwift
import RoxasUIKit
@objc(RefreshAppOperation)
final class RefreshAppOperation: ResultOperation<InstalledApp> {
let context: AppOperationContext
// Strong reference to managedObjectContext to keep it alive until we're finished.
let managedObjectContext: NSManagedObjectContext
init(context: AppOperationContext) {
self.context = context
managedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
super.init()
}
override func main() {
super.main()
do {
if let error = context.error {
throw error
}
guard let profiles = context.provisioningProfiles else { throw OperationError.invalidParameters }
guard let app = context.app else { throw OperationError.appNotFound }
DatabaseManager.shared.persistentContainer.performBackgroundTask { _ in
print("Sending refresh app request...")
for p in profiles {
do {
let x = try install_provisioning_profile(plist: p.value.data)
if case let .Bad(code) = x {
self.finish(.failure(minimuxer_to_operation(code: code)))
}
} catch let Uhoh.Bad(code) {
self.finish(.failure(minimuxer_to_operation(code: code)))
} catch {
self.finish(.failure(OperationError.unknown))
}
self.progress.completedUnitCount += 1
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier)
self.managedObjectContext.perform {
guard let installedApp = InstalledApp.first(satisfying: predicate, in: self.managedObjectContext) else {
return
}
installedApp.update(provisioningProfile: p.value)
for installedExtension in installedApp.appExtensions {
guard let provisioningProfile = profiles[installedExtension.bundleIdentifier] else { continue }
installedExtension.update(provisioningProfile: provisioningProfile)
}
self.finish(.success(installedApp))
}
}
}
} catch {
finish(.failure(error))
}
}
}

View File

@@ -0,0 +1,82 @@
//
// RefreshGroup.swift
// AltStore
//
// Created by Riley Testut on 6/20/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import CoreData
import Foundation
import AltSign
import SideStoreCore
public final class RefreshGroup: NSObject {
let context: AuthenticatedOperationContext
let progress = Progress.discreteProgress(totalUnitCount: 0)
var completionHandler: (([String: Result<InstalledApp, Error>]) -> Void)?
var beginInstallationHandler: ((InstalledApp) -> Void)?
private(set) var results = [String: Result<InstalledApp, Error>]()
// Keep strong references to managed object contexts
// so they don't die out from under us.
private(set) var _contexts = Set<NSManagedObjectContext>()
private var isFinished = false
private let dispatchGroup = DispatchGroup()
private var operations: [Foundation.Operation] = []
public init(context: AuthenticatedOperationContext = AuthenticatedOperationContext()) {
self.context = context
super.init()
}
/// Used to keep track of which operations belong to this group.
/// This does _not_ add them to any operation queue.
public func add(_ operations: [Foundation.Operation]) {
for operation in operations {
dispatchGroup.enter()
operation.completionBlock = { [weak self] in
self?.dispatchGroup.leave()
}
}
if self.operations.isEmpty && !operations.isEmpty {
dispatchGroup.notify(queue: .global()) { [weak self] in
self?.finish()
}
}
self.operations.append(contentsOf: operations)
}
public func set(_ result: Result<InstalledApp, Error>, forAppWithBundleIdentifier bundleIdentifier: String) {
results[bundleIdentifier] = result
switch result {
case .failure: break
case let .success(installedApp):
guard let context = installedApp.managedObjectContext else { break }
_contexts.insert(context)
}
}
public func cancel() {
operations.forEach { $0.cancel() }
}
}
private extension RefreshGroup {
func finish() {
guard !isFinished else { return }
isFinished = true
completionHandler?(results)
}
}

View File

@@ -0,0 +1,67 @@
//
// RemoveAppBackupOperation.swift
// AltStore
//
// Created by Riley Testut on 5/13/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
@objc(RemoveAppBackupOperation)
final class RemoveAppBackupOperation: ResultOperation<Void> {
let context: InstallAppOperationContext
private let coordinator = NSFileCoordinator()
private let coordinatorQueue = OperationQueue()
init(context: InstallAppOperationContext) {
self.context = context
super.init()
coordinatorQueue.name = "AltStore - RemoveAppBackupOperation Queue"
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard let installedApp = context.installedApp else { return finish(.failure(OperationError.invalidParameters)) }
installedApp.managedObjectContext?.perform {
guard let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return self.finish(.failure(OperationError.missingAppGroup)) }
let intent = NSFileAccessIntent.writingIntent(with: backupDirectoryURL, options: [.forDeleting])
self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { error in
do {
if let error = error {
throw error
}
try FileManager.default.removeItem(at: intent.url)
self.finish(.success(()))
} catch let error as CocoaError where error.code == CocoaError.Code.fileNoSuchFile {
#if DEBUG
// When debugging, it's expected that app groups don't match, so ignore.
self.finish(.success(()))
#else
print("Failed to remove app backup directory:", error)
self.finish(.failure(error))
#endif
} catch {
print("Failed to remove app backup directory:", error)
self.finish(.failure(error))
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
//
// RemoveAppOperation.swift
// AltStore
//
// Created by Riley Testut on 5/12/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import SideStoreCore
import minimuxer
import MiniMuxerSwift
import Shared
import SideKit
@objc(RemoveAppOperation)
final class RemoveAppOperation: ResultOperation<InstalledApp> {
let context: InstallAppOperationContext
init(context: InstallAppOperationContext) {
self.context = context
super.init()
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard let installedApp = context.installedApp else { return finish(.failure(OperationError.invalidParameters)) }
installedApp.managedObjectContext?.perform {
let resignedBundleIdentifier = installedApp.resignedBundleIdentifier
do {
let res = try remove_app(app_id: resignedBundleIdentifier)
if case let Uhoh.Bad(code) = res {
self.finish(.failure(minimuxer_to_operation(code: code)))
}
} catch let Uhoh.Bad(code) {
self.finish(.failure(minimuxer_to_operation(code: code)))
} catch {
self.finish(.failure(ALTServerError.appDeletionFailed))
}
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
self.progress.completedUnitCount += 1
let installedApp = context.object(with: installedApp.objectID) as! InstalledApp
installedApp.isActive = false
self.finish(.success(installedApp))
}
}
}
}

View File

@@ -0,0 +1,232 @@
//
// ResignAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import RoxasUIKit
import AltSign
import SideStoreCore
@objc(ResignAppOperation)
final class ResignAppOperation: ResultOperation<ALTApplication> {
let context: InstallAppOperationContext
init(context: InstallAppOperationContext) {
self.context = context
super.init()
progress.totalUnitCount = 3
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard
let app = context.app,
let profiles = context.provisioningProfiles,
let team = context.team,
let certificate = context.certificate
else { return finish(.failure(OperationError.invalidParameters)) }
// Prepare app bundle
let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2)
progress.addChild(prepareAppProgress, withPendingUnitCount: 3)
let prepareAppBundleProgress = prepareAppBundle(for: app, profiles: profiles) { result in
guard let appBundleURL = self.process(result) else { return }
print("Resigning App:", self.context.bundleIdentifier)
// Resign app bundle
let resignProgress = self.resignAppBundle(at: appBundleURL, team: team, certificate: certificate, profiles: Array(profiles.values)) { result in
guard let resignedURL = self.process(result) else { return }
// Finish
do {
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
try FileManager.default.copyItem(at: resignedURL, to: destinationURL, shouldReplace: true)
// Use appBundleURL since we need an app bundle, not .ipa.
guard let resignedApplication = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
self.finish(.success(resignedApplication))
} catch {
self.finish(.failure(error))
}
}
prepareAppProgress.addChild(resignProgress, withPendingUnitCount: 1)
}
prepareAppProgress.addChild(prepareAppBundleProgress, withPendingUnitCount: 1)
}
func process<T>(_ result: Result<T, Error>) -> T? {
switch result {
case let .failure(error):
finish(.failure(error))
return nil
case let .success(value):
guard !isCancelled else {
finish(.failure(OperationError.cancelled))
return nil
}
return value
}
}
}
private extension ResignAppOperation {
func prepareAppBundle(for app: ALTApplication, profiles: [String: ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress {
let progress = Progress.discreteProgress(totalUnitCount: 1)
let bundleIdentifier = app.bundleIdentifier
let openURL = InstalledApp.openAppURL(for: app)
let fileURL = app.fileURL
func prepare(_ bundle: Bundle, additionalInfoDictionaryValues: [String: Any] = [:]) throws {
guard let identifier = bundle.bundleIdentifier else { throw ALTError(.missingAppBundle) }
guard let profile = profiles[identifier] else { throw ALTError(.missingProvisioningProfile) }
guard var infoDictionary = bundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
infoDictionary[Bundle.Info.altBundleID] = identifier
infoDictionary[Bundle.Info.devicePairingString] = Bundle.main.object(forInfoDictionaryKey: "ALTPairingFile") as? String
for (key, value) in additionalInfoDictionaryValues {
infoDictionary[key] = value
}
if let appGroups = profile.entitlements[.appGroups] as? [String] {
infoDictionary[Bundle.Info.appGroups] = appGroups
// To keep file providers working, remap the NSExtensionFileProviderDocumentGroup, if there is one.
if var extensionInfo = infoDictionary["NSExtension"] as? [String: Any],
let appGroup = extensionInfo["NSExtensionFileProviderDocumentGroup"] as? String,
let localAppGroup = appGroups.filter({ $0.contains(appGroup) }).min(by: { $0.count < $1.count }) {
extensionInfo["NSExtensionFileProviderDocumentGroup"] = localAppGroup
infoDictionary["NSExtension"] = extensionInfo
}
}
// Add app-specific exported UTI so we can check later if this app (extension) is installed or not.
let installedAppUTI = ["UTTypeConformsTo": [],
"UTTypeDescription": "AltStore Installed App",
"UTTypeIconFiles": [],
"UTTypeIdentifier": InstalledApp.installedAppUTI(forBundleIdentifier: profile.bundleIdentifier),
"UTTypeTagSpecification": [:]] as [String: Any]
var exportedUTIs = infoDictionary[Bundle.Info.exportedUTIs] as? [[String: Any]] ?? []
exportedUTIs.append(installedAppUTI)
infoDictionary[Bundle.Info.exportedUTIs] = exportedUTIs
try (infoDictionary as NSDictionary).write(to: bundle.infoPlistURL)
}
DispatchQueue.global().async {
do {
let appBundleURL = self.context.temporaryDirectory.appendingPathComponent("App.app")
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
// Become current so we can observe progress from unzipAppBundle().
progress.becomeCurrent(withPendingUnitCount: 1)
guard let appBundle = Bundle(url: appBundleURL) else { throw ALTError(.missingAppBundle) }
guard let infoDictionary = appBundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
var allURLSchemes = infoDictionary[Bundle.Info.urlTypes] as? [[String: Any]] ?? []
let altstoreURLScheme = ["CFBundleTypeRole": "Editor",
"CFBundleURLName": bundleIdentifier,
"CFBundleURLSchemes": [openURL.scheme!]] as [String: Any]
allURLSchemes.append(altstoreURLScheme)
var additionalValues: [String: Any] = [Bundle.Info.urlTypes: allURLSchemes]
if app.isAltStoreApp {
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw OperationError.unknownUDID }
guard let pairingFileString = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) as? String else { throw OperationError.unknownUDID }
additionalValues[Bundle.Info.devicePairingString] = pairingFileString
additionalValues[Bundle.Info.deviceID] = udid
additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
if
let data = Keychain.shared.signingCertificate,
let signingCertificate = ALTCertificate(p12Data: data, password: nil),
let encryptingPassword = Keychain.shared.signingCertificatePassword
{
additionalValues[Bundle.Info.certificateID] = signingCertificate.serialNumber
let encryptedData = signingCertificate.encryptedP12Data(withPassword: encryptingPassword)
try encryptedData?.write(to: appBundle.certificateURL, options: .atomic)
} else {
// The embedded certificate + certificate identifier are already in app bundle, no need to update them.
}
} else if infoDictionary.keys.contains(Bundle.Info.deviceID), let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String {
// There is an ALTDeviceID entry, so assume the app is using AltKit and replace it with the device's UDID.
additionalValues[Bundle.Info.deviceID] = udid
additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
}
let iconScale = Int(UIScreen.main.scale)
if let alternateIconURL = self.context.alternateIconURL,
case let data = try Data(contentsOf: alternateIconURL),
let image = UIImage(data: data),
let icon = image.resizing(toFill: CGSize(width: 60 * iconScale, height: 60 * iconScale)),
let iconData = icon.pngData() {
let iconName = "AltIcon"
let iconURL = appBundleURL.appendingPathComponent(iconName + "@\(iconScale)x.png")
try iconData.write(to: iconURL, options: .atomic)
let iconDictionary = ["CFBundlePrimaryIcon": ["CFBundleIconFiles": [iconName]]]
additionalValues["CFBundleIcons"] = iconDictionary
}
// Prepare app
try prepare(appBundle, additionalInfoDictionaryValues: additionalValues)
if let directory = appBundle.builtInPlugInsURL, let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants]) {
for case let fileURL as URL in enumerator {
guard let appExtension = Bundle(url: fileURL) else { throw ALTError(.missingAppBundle) }
try prepare(appExtension)
}
}
completionHandler(.success(appBundleURL))
} catch {
completionHandler(.failure(error))
}
}
return progress
}
func resignAppBundle(at fileURL: URL, team: ALTTeam, certificate: ALTCertificate, profiles: [ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress {
let signer = ALTSigner(team: team, certificate: certificate)
let progress = signer.signApp(at: fileURL, provisioningProfiles: profiles) { success, error in
do {
try Result(success, error).get()
let ipaURL = try FileManager.default.zipAppBundle(at: fileURL)
completionHandler(.success(ipaURL))
} catch {
completionHandler(.failure(error))
}
}
return progress
}
}

View File

@@ -0,0 +1,67 @@
//
// SendAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Network
import SideStoreCore
import Shared
import SideKit
import MiniMuxerSwift
@objc(SendAppOperation)
final class SendAppOperation: ResultOperation<Void> {
let context: InstallAppOperationContext
private let dispatchQueue = DispatchQueue(label: "com.sidestore.SendAppOperation")
init(context: InstallAppOperationContext) {
self.context = context
super.init()
progress.totalUnitCount = 1
}
override func main() {
super.main()
if let error = context.error {
finish(.failure(error))
return
}
guard let resignedApp = context.resignedApp else { return finish(.failure(OperationError.invalidParameters)) }
// self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa.
let app = AnyApp(name: resignedApp.name, bundleIdentifier: context.bundleIdentifier, url: resignedApp.fileURL)
let fileURL = InstalledApp.refreshedIPAURL(for: app)
print("AFC App `fileURL`: \(fileURL.absoluteString)")
let ns_bundle = NSString(string: app.bundleIdentifier)
let ns_bundle_ptr = UnsafeMutablePointer<CChar>(mutating: ns_bundle.utf8String)
if let data = NSData(contentsOf: fileURL) {
let pls = UnsafeMutablePointer<UInt8>.allocate(capacity: data.length)
for (index, data) in data.enumerated() {
pls[index] = data
}
let res = minimuxer_yeet_app_afc(ns_bundle_ptr, pls, UInt(data.length))
if res == 0 {
print("minimuxer_yeet_app_afc `res` == \(res)")
progress.completedUnitCount += 1
finish(.success(()))
} else {
finish(.failure(minimuxer_to_operation(code: res)))
}
} else {
finish(.failure(ALTServerError.unknownResponse))
}
}
}

View File

@@ -0,0 +1,93 @@
//
// UpdatePatronsOperation.swift
// AltStore
//
// Created by Riley Testut on 4/11/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import CoreData
import Foundation
import SideStoreCore
private extension URL {
#if STAGING
static let patreonInfo = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/altstore/patreon.json")!
#else
static let patreonInfo = URL(string: "https://cdn.altstore.io/file/altstore/altstore/patreon.json")!
#endif
}
extension UpdatePatronsOperation {
private struct Response: Decodable {
var version: Int
var accessToken: String
var refreshID: String
}
}
final class UpdatePatronsOperation: ResultOperation<Void> {
let context: NSManagedObjectContext
init(context: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) {
self.context = context
}
override func main() {
super.main()
let dataTask = URLSession.shared.dataTask(with: .patreonInfo) { data, response, error in
do {
if let response = response as? HTTPURLResponse {
guard response.statusCode != 404 else {
self.finish(.failure(URLError(.fileDoesNotExist, userInfo: [NSURLErrorKey: URL.patreonInfo])))
return
}
}
guard let data = data else { throw error! }
let response = try SideStoreCore.JSONDecoder().decode(Response.self, from: data)
Keychain.shared.patreonCreatorAccessToken = response.accessToken
let previousRefreshID = UserDefaults.shared.patronsRefreshID
guard response.refreshID != previousRefreshID else {
self.finish(.success(()))
return
}
PatreonAPI.shared.fetchPatrons { result in
self.context.perform {
do {
let patrons = try result.get()
let managedPatrons = patrons.map { ManagedPatron(patron: $0, context: self.context) }
let patronIDs = Set(managedPatrons.map { $0.identifier })
let nonFriendZonePredicate = NSPredicate(format: "NOT (%K IN %@)", #keyPath(ManagedPatron.identifier), patronIDs)
let nonFriendZonePatrons = ManagedPatron.all(satisfying: nonFriendZonePredicate, in: self.context)
for managedPatron in nonFriendZonePatrons {
self.context.delete(managedPatron)
}
try self.context.save()
UserDefaults.shared.patronsRefreshID = response.refreshID
self.finish(.success(()))
print("Updated Friend Zone Patrons!")
} catch {
self.finish(.failure(error))
}
}
}
} catch {
self.finish(.failure(error))
}
}
dataTask.resume()
}
}

View File

@@ -0,0 +1,156 @@
//
// VerifyAppOperation.swift
// AltStore
//
// Created by Riley Testut on 5/2/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import RoxasUIKit
import SideKit
import Shared
enum VerificationError: LocalizedError {
case privateEntitlements(ALTApplication, entitlements: [String: Any])
case mismatchedBundleIdentifiers(ALTApplication, sourceBundleID: String)
case iOSVersionNotSupported(ALTApplication)
var app: ALTApplication {
switch self {
case let .privateEntitlements(app, _): return app
case let .mismatchedBundleIdentifiers(app, _): return app
case let .iOSVersionNotSupported(app): return app
}
}
var failure: String? {
String(format: NSLocalizedString("“%@” could not be installed.", comment: ""), app.name)
}
var failureReason: String? {
switch self {
case let .privateEntitlements(app, _):
return String(format: NSLocalizedString("“%@” requires private permissions.", comment: ""), app.name)
case let .mismatchedBundleIdentifiers(app, sourceBundleID):
return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), app.bundleIdentifier, sourceBundleID)
case let .iOSVersionNotSupported(app):
let name = app.name
var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)"
if app.minimumiOSVersion.patchVersion > 0 {
version += ".\(app.minimumiOSVersion.patchVersion)"
}
let localizedDescription = String(format: NSLocalizedString("%@ requires %@.", comment: ""), name, version)
return localizedDescription
}
}
}
@objc(VerifyAppOperation)
final class VerifyAppOperation: ResultOperation<Void> {
let context: AppOperationContext
var verificationHandler: ((VerificationError) -> Bool)?
init(context: AppOperationContext) {
self.context = context
super.init()
}
override func main() {
super.main()
do {
if let error = context.error {
throw error
}
guard let app = context.app else { throw OperationError.invalidParameters }
guard app.bundleIdentifier == context.bundleIdentifier else {
throw VerificationError.mismatchedBundleIdentifiers(app, sourceBundleID: context.bundleIdentifier)
}
guard ProcessInfo.processInfo.isOperatingSystemAtLeast(app.minimumiOSVersion) else {
throw VerificationError.iOSVersionNotSupported(app)
}
if #available(iOS 13.5, *) {
// No psychic paper, so we can ignore private entitlements
app.hasPrivateEntitlements = false
} else {
// Make sure this goes last, since once user responds to alert we don't do any more app verification.
if let commentStart = app.entitlementsString.range(of: "<!---><!-->"), let commentEnd = app.entitlementsString.range(of: "<!-- -->") {
// Psychic Paper private entitlements.
let entitlementsStart = app.entitlementsString.index(after: commentStart.upperBound)
let rawEntitlements = String(app.entitlementsString[entitlementsStart ..< commentEnd.lowerBound])
let plistTemplate = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
%@
</dict>
</plist>
"""
let entitlementsPlist = String(format: plistTemplate, rawEntitlements)
let entitlements = try PropertyListSerialization.propertyList(from: entitlementsPlist.data(using: .utf8)!, options: [], format: nil) as! [String: Any]
app.hasPrivateEntitlements = true
let error = VerificationError.privateEntitlements(app, entitlements: entitlements)
process(error) { result in
self.finish(result.mapError { $0 as Error })
}
return
} else {
app.hasPrivateEntitlements = false
}
}
finish(.success(()))
} catch {
finish(.failure(error))
}
}
}
private extension VerifyAppOperation {
func process(_ error: VerificationError, completion: @escaping (Result<Void, VerificationError>) -> Void) {
guard let presentingViewController = context.presentingViewController else { return completion(.failure(error)) }
DispatchQueue.main.async {
switch error {
case let .privateEntitlements(_, entitlements):
let permissions = entitlements.keys.sorted().joined(separator: "\n")
let message = String(format: NSLocalizedString("""
You must allow access to these private permissions before continuing:
%@
Private permissions allow apps to do more than normally allowed by iOS, including potentially accessing sensitive private data. Make sure to only install apps from sources you trust.
""", comment: ""), permissions)
let alertController = UIAlertController(title: error.failureReason ?? error.localizedDescription, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Allow Access", comment: ""), style: .destructive) { _ in
completion(.success(()))
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Deny Access", comment: ""), style: .default, handler: { _ in
completion(.failure(error))
}))
presentingViewController.present(alertController, animated: true, completion: nil)
case .mismatchedBundleIdentifiers: return completion(.failure(error))
case .iOSVersionNotSupported: return completion(.failure(error))
}
}
}
}

View File

@@ -0,0 +1,49 @@
//
// AnisetteManager.swift
// SideStore
//
// Created by Joseph Mattiello on 11/16/22.
// Copyright © 2022 SideStore. All rights reserved.
//
import Foundation
public enum AnisetteManager {
/// User defined URL from Settings/UserDefaults
static var userURL: String? {
var urlString: String?
if UserDefaults.standard.textServer == false {
urlString = UserDefaults.standard.textInputAnisetteURL
} else {
urlString = UserDefaults.standard.customAnisetteURL
}
// guard let urlString = UserDefaults.standard.customAnisetteURL, !urlString.isEmpty else { return nil }
// Test it's a valid URL
if let urlString = urlString {
guard URL(string: urlString) != nil else {
ELOG("UserDefaults has invalid `customAnisetteURL`")
assertionFailure("UserDefaults has invalid `customAnisetteURL`")
return nil
}
}
return urlString
}
static var defaultURL: String {
guard let url = Bundle.main.object(forInfoDictionaryKey: "ALTAnisetteURL") as? String else {
assertionFailure("Info.plist has invalid `ALTAnisetteURL`")
abort()
}
return url
}
static var currentURLString: String { userURL ?? defaultURL }
// Force unwrap is safe here since we check validity before hand -- @JoeMatt
/// User url or default from plist if none specified
static var currentURL: URL { URL(string: currentURLString)! }
}

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,283 @@
//
// 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
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 {
print("[ALTLog] Failed to delete LoggedError \(loggedError.objectID):", error)
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,87 @@
//
// PatreonComponents.swift
// AltStore
//
// Created by Riley Testut on 9/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
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: nil)
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: nil)
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,584 @@
//
// 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
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()
print("Fetched trusted sources:", sources.map { $0.identifier })
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
self.trustedSourcesDataSource.setItems(sources, with: [sectionUpdate])
} catch {
print("Error fetching trusted sources:", error)
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 {
print(error)
// 1 error doesn't mean all trusted sources failed to load! Riley, why did you do this???????
// finish(.failure(error))
}
let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] }
finish(.success(sources))
}
}
}
}
@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,14 @@
//
// LoadingState.swift
// AltStore
//
// Created by Riley Testut on 9/19/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
enum LoadingState {
case loading
case finished(Result<Void, Error>)
}

View File

@@ -0,0 +1,44 @@
//
// Managed.swift
// AltStore
//
// Created by Riley Testut on 10/5/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import CoreData
import Foundation
@propertyWrapper @dynamicMemberLookup
struct Managed<ManagedObject: NSManagedObject> {
var wrappedValue: ManagedObject {
didSet {
self.managedObjectContext = self.wrappedValue.managedObjectContext
}
}
private var managedObjectContext: NSManagedObjectContext?
var projectedValue: Managed<ManagedObject> {
self
}
init(wrappedValue: ManagedObject) {
self.wrappedValue = wrappedValue
self.managedObjectContext = wrappedValue.managedObjectContext
}
subscript<T>(dynamicMember keyPath: KeyPath<ManagedObject, T>) -> T {
var result: T!
if let context = managedObjectContext {
context.performAndWait {
result = self.wrappedValue[keyPath: keyPath]
}
} else {
result = self.wrappedValue[keyPath: keyPath]
}
return result
}
}

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