mirror of
https://github.com/SideStore/SideStore.git
synced 2026-03-30 07:15:38 +02:00
XCode project for app, moved app project to folder
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// PermissionPopoverViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
final class PermissionPopoverViewController: UIViewController {
|
||||
var permission: AppPermission!
|
||||
|
||||
@IBOutlet private var nameLabel: UILabel!
|
||||
@IBOutlet private var descriptionLabel: UILabel!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
nameLabel.text = permission.type.localizedName
|
||||
descriptionLabel.text = permission.usageDescription
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//
|
||||
// AppIDsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 1/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import Down
|
||||
|
||||
final class AppIDsViewController: UICollectionViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
private var didInitialFetch = false
|
||||
private var isLoading = false {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
|
||||
activityIndicatorBarButtonItem.isIndicatingActivity = true
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
|
||||
collectionView.refreshControl = refreshControl
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if !didInitialFetch {
|
||||
fetchAppIDs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppIDsViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<AppID> {
|
||||
let fetchRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AppID.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
if let team = DatabaseManager.shared.activeTeam() {
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
|
||||
} else {
|
||||
fetchRequest.predicate = NSPredicate(value: false)
|
||||
}
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.proxy = self
|
||||
dataSource.cellConfigurationHandler = { cell, appID, _ in
|
||||
let tintColor = UIColor.altPrimary
|
||||
|
||||
let cell = cell as! BannerCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
cell.tintColor = tintColor
|
||||
|
||||
cell.bannerView.iconImageView.isHidden = true
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
||||
|
||||
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
|
||||
|
||||
if let expirationDate = appID.expirationDate {
|
||||
cell.bannerView.button.isHidden = false
|
||||
cell.bannerView.button.isUserInteractionEnabled = false
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
|
||||
let currentDate = Date()
|
||||
|
||||
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
|
||||
let numberOfDaysText = (numberOfDays == 1) ? NSLocalizedString("1 day", comment: "") : String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
|
||||
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
|
||||
|
||||
attributedAccessibilityLabel.mutableString.append(String(format: NSLocalizedString("Expires in %@.", comment: ""), numberOfDaysText) + " ")
|
||||
} else {
|
||||
cell.bannerView.button.isHidden = true
|
||||
cell.bannerView.button.isUserInteractionEnabled = true
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = true
|
||||
}
|
||||
|
||||
cell.bannerView.titleLabel.text = appID.name
|
||||
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
||||
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||
|
||||
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
||||
|
||||
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *) {
|
||||
// Prefer to speak the team ID one character at a time.
|
||||
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
||||
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
|
||||
}
|
||||
|
||||
attributedAccessibilityLabel.append(attributedBundleIdentifier)
|
||||
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
cell.layoutIfNeeded()
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
@objc func fetchAppIDs() {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
|
||||
AppManager.shared.fetchAppIDs { result in
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
if !isLoading {
|
||||
collectionView.refreshControl?.endRefreshing()
|
||||
activityIndicatorBarButtonItem.isIndicatingActivity = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppIDsViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
||||
CGSize(width: collectionView.bounds.width, height: 80)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
|
||||
let indexPath = IndexPath(row: 0, section: section)
|
||||
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
||||
|
||||
// Use this view to calculate the optimal size based on the collection view's width
|
||||
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
||||
withHorizontalFittingPriority: .required, // Width is fixed
|
||||
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||
return size
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection _: Int) -> CGSize {
|
||||
CGSize(width: collectionView.bounds.width, height: 50)
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
switch kind {
|
||||
case UICollectionView.elementKindSectionHeader:
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView
|
||||
headerView.layoutMargins.left = view.layoutMargins.left
|
||||
headerView.layoutMargins.right = view.layoutMargins.right
|
||||
|
||||
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free {
|
||||
let text = NSLocalizedString(
|
||||
"""
|
||||
Each app and app extension installed with SideStore must register an App ID with Apple. Apple limits non-developer Apple IDs to 10 App IDs at a time.
|
||||
|
||||
**App IDs can't be deleted**, but they do expire after one week. SideStore will automatically renew App IDs for all active apps once they've expired.
|
||||
""", comment: "")
|
||||
|
||||
let mdown = Down(markdownString: text)
|
||||
let labelFont: DownFont = headerView.textLabel.font
|
||||
let fonts: FontCollection = StaticFontCollection(
|
||||
heading1: labelFont,
|
||||
heading2: labelFont,
|
||||
heading3: labelFont,
|
||||
heading4: labelFont,
|
||||
heading5: labelFont,
|
||||
heading6: labelFont,
|
||||
body: labelFont,
|
||||
code: labelFont,
|
||||
listItemPrefix: labelFont)
|
||||
let config: DownStylerConfiguration = .init(fonts: fonts)
|
||||
let styler: Styler = DownStyler.init(configuration: config)
|
||||
let options: DownOptions = .default
|
||||
headerView.textLabel.attributedText = try? mdown.toAttributedString(options,
|
||||
styler: styler) ?? NSAttributedString(string: text)
|
||||
} else {
|
||||
headerView.textLabel.text = NSLocalizedString("""
|
||||
Each app and app extension installed with SideStore must register an App ID with Apple.
|
||||
|
||||
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
|
||||
""", comment: "")
|
||||
}
|
||||
|
||||
return headerView
|
||||
|
||||
case UICollectionView.elementKindSectionFooter:
|
||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
|
||||
|
||||
let count = dataSource.itemCount
|
||||
if count == 1 {
|
||||
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
|
||||
} else {
|
||||
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
|
||||
}
|
||||
|
||||
return footerView
|
||||
|
||||
default: fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate enum MarkdownStyledBlock: Equatable {
|
||||
case generic
|
||||
case headline(Int)
|
||||
case paragraph
|
||||
case unorderedListElement
|
||||
case orderedListElement(Int)
|
||||
case blockquote
|
||||
case code(String?)
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
@available(iOS 15, *)
|
||||
extension AttributedString {
|
||||
|
||||
init(styledMarkdown markdownString: String, fontSize: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize) throws {
|
||||
|
||||
var s = try AttributedString(markdown: markdownString, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: "en"), baseURL: nil)
|
||||
|
||||
// Looking at the AttributedString’s raw structure helps with understanding the following code.
|
||||
print(s)
|
||||
|
||||
// Set base font and paragraph style for the whole string
|
||||
s.font = .systemFont(ofSize: fontSize)
|
||||
s.paragraphStyle = defaultParagraphStyle
|
||||
|
||||
// Will respect dark mode automatically
|
||||
s.foregroundColor = .label
|
||||
|
||||
// MARK: Inline Intents
|
||||
let inlineIntents: [InlinePresentationIntent] = [.emphasized, .stronglyEmphasized, .code, .strikethrough, .softBreak, .lineBreak, .inlineHTML, .blockHTML]
|
||||
|
||||
for inlineIntent in inlineIntents {
|
||||
|
||||
var sourceAttributeContainer = AttributeContainer()
|
||||
sourceAttributeContainer.inlinePresentationIntent = inlineIntent
|
||||
|
||||
var targetAttributeContainer = AttributeContainer()
|
||||
switch inlineIntent {
|
||||
case .emphasized:
|
||||
targetAttributeContainer.font = .italicSystemFont(ofSize: fontSize)
|
||||
case .stronglyEmphasized:
|
||||
targetAttributeContainer.font = .systemFont(ofSize: fontSize, weight: .bold)
|
||||
case .code:
|
||||
targetAttributeContainer.font = .monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||
targetAttributeContainer.backgroundColor = .secondarySystemBackground
|
||||
case .strikethrough:
|
||||
targetAttributeContainer.strikethroughStyle = .single
|
||||
case .softBreak:
|
||||
break // TODO: Implement
|
||||
case .lineBreak:
|
||||
break // TODO: Implement
|
||||
case .inlineHTML:
|
||||
break // TODO: Implement
|
||||
case .blockHTML:
|
||||
break // TODO: Implement
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
s = s.replacingAttributes(sourceAttributeContainer, with: targetAttributeContainer)
|
||||
}
|
||||
|
||||
// MARK: Blocks
|
||||
// Accessing via dynamic lookup key path (\.presentationIntent) triggers a warning on Xcode 13.1, so we use the verbose way: AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self
|
||||
// We use .reversed() iteration to be able to add characters to the string without breaking ranges.
|
||||
var previousListID = 0
|
||||
|
||||
for (intentBlock, intentRange) in s.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
|
||||
guard let intentBlock = intentBlock else { continue }
|
||||
|
||||
var block: MarkdownStyledBlock = .generic
|
||||
var currentElementOrdinal: Int = 0
|
||||
|
||||
var currentListID = 0
|
||||
|
||||
for intent in intentBlock.components {
|
||||
switch intent.kind {
|
||||
case .paragraph:
|
||||
if block == .generic {
|
||||
block = .paragraph
|
||||
}
|
||||
case .header(level: let level):
|
||||
block = .headline(level)
|
||||
case .orderedList:
|
||||
block = .orderedListElement(currentElementOrdinal)
|
||||
currentListID = intent.identity
|
||||
case .unorderedList:
|
||||
block = .unorderedListElement
|
||||
currentListID = intent.identity
|
||||
case .listItem(ordinal: let ordinal):
|
||||
currentElementOrdinal = ordinal
|
||||
if block != .unorderedListElement {
|
||||
block = .orderedListElement(ordinal)
|
||||
}
|
||||
case .codeBlock(languageHint: let languageHint):
|
||||
block = .code(languageHint)
|
||||
case .blockQuote:
|
||||
block = .blockquote
|
||||
case .thematicBreak:
|
||||
break // This is ---- in Markdown.
|
||||
case .table(columns: _):
|
||||
break
|
||||
case .tableHeaderRow:
|
||||
break
|
||||
case .tableRow(rowIndex: _):
|
||||
break
|
||||
case .tableCell(columnIndex: _):
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch block {
|
||||
case .generic:
|
||||
assertionFailure(intentBlock.debugDescription)
|
||||
case .headline(let level):
|
||||
switch level {
|
||||
case 1:
|
||||
s[intentRange].font = .systemFont(ofSize: 30, weight: .heavy)
|
||||
case 2:
|
||||
s[intentRange].font = .systemFont(ofSize: 20, weight: .heavy)
|
||||
case 3:
|
||||
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
|
||||
default:
|
||||
// TODO: Handle H4 to H6
|
||||
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
|
||||
}
|
||||
case .paragraph:
|
||||
break
|
||||
case .unorderedListElement:
|
||||
s.characters.insert(contentsOf: "•\t", at: intentRange.lowerBound)
|
||||
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
|
||||
case .orderedListElement(let ordinal):
|
||||
s.characters.insert(contentsOf: "\(ordinal).\t", at: intentRange.lowerBound)
|
||||
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
|
||||
case .blockquote:
|
||||
s[intentRange].paragraphStyle = defaultParagraphStyle
|
||||
s[intentRange].foregroundColor = .secondaryLabel
|
||||
case .code:
|
||||
s[intentRange].font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
||||
s[intentRange].paragraphStyle = codeParagraphStyle
|
||||
}
|
||||
|
||||
// Remember the list ID so we can check if it’s identical in the next block
|
||||
previousListID = currentListID
|
||||
|
||||
// MARK: Add line breaks to separate blocks
|
||||
if intentRange.lowerBound != s.startIndex {
|
||||
s.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
self = s
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let defaultParagraphStyle: NSParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.paragraphSpacing = 10.0
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
|
||||
fileprivate let listParagraphStyle: NSMutableParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
|
||||
paragraphStyle.headIndent = 20
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
fileprivate let lastElementListParagraphStyle: NSMutableParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
|
||||
paragraphStyle.headIndent = 20
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
paragraphStyle.paragraphSpacing = 20.0 // The last element in a list needs extra paragraph spacing
|
||||
return paragraphStyle
|
||||
}()
|
||||
|
||||
|
||||
fileprivate let codeParagraphStyle: NSParagraphStyle = {
|
||||
var paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.minimumLineHeight = 20.0
|
||||
paragraphStyle.firstLineHeadIndent = 20
|
||||
paragraphStyle.headIndent = 20
|
||||
return paragraphStyle
|
||||
}()
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// AuthenticationViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/5/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
|
||||
final class AuthenticationViewController: UIViewController {
|
||||
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
|
||||
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
|
||||
|
||||
private weak var toastView: ToastView?
|
||||
|
||||
@IBOutlet private var appleIDTextField: UITextField!
|
||||
@IBOutlet private var passwordTextField: UITextField!
|
||||
@IBOutlet private var signInButton: UIButton!
|
||||
|
||||
@IBOutlet private var appleIDBackgroundView: UIView!
|
||||
@IBOutlet private var passwordBackgroundView: UIView!
|
||||
|
||||
@IBOutlet private var scrollView: UIScrollView!
|
||||
@IBOutlet private var contentStackView: UIStackView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
signInButton.activityIndicatorView.style = .medium
|
||||
|
||||
for view in [appleIDBackgroundView!, passwordBackgroundView!, signInButton!] {
|
||||
view.clipsToBounds = true
|
||||
view.layer.cornerRadius = 16
|
||||
}
|
||||
|
||||
if UIScreen.main.isExtraCompactHeight {
|
||||
contentStackView.spacing = 20
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: appleIDTextField)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
signInButton.isIndicatingActivity = false
|
||||
toastView?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationViewController {
|
||||
func update() {
|
||||
if let _ = validate() {
|
||||
signInButton.isEnabled = true
|
||||
signInButton.alpha = 1.0
|
||||
} else {
|
||||
signInButton.isEnabled = false
|
||||
signInButton.alpha = 0.6
|
||||
}
|
||||
}
|
||||
|
||||
func validate() -> (String, String)? {
|
||||
guard
|
||||
let emailAddress = appleIDTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
|
||||
let password = passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return (emailAddress, password)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationViewController {
|
||||
@IBAction func authenticate() {
|
||||
guard let (emailAddress, password) = validate() else { return }
|
||||
|
||||
appleIDTextField.resignFirstResponder()
|
||||
passwordTextField.resignFirstResponder()
|
||||
|
||||
signInButton.isIndicatingActivity = true
|
||||
|
||||
authenticationHandler?(emailAddress, password) { result in
|
||||
switch result {
|
||||
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
|
||||
// Ignore
|
||||
DispatchQueue.main.async {
|
||||
self.signInButton.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
case let .failure(error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
let error = error.withLocalizedFailure(NSLocalizedString("Failed to Log In", comment: ""))
|
||||
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.textLabel.textColor = .altPink
|
||||
toastView.detailTextLabel.textColor = .altPink
|
||||
toastView.show(in: self)
|
||||
self.toastView = toastView
|
||||
|
||||
self.signInButton.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
case let .success((account, session)):
|
||||
self.completionHandler?((account, session, password))
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.scrollView.setContentOffset(CGPoint(x: 0, y: -self.view.safeAreaInsets.top), animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func cancel(_: UIBarButtonItem) {
|
||||
completionHandler?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
switch textField {
|
||||
case appleIDTextField: passwordTextField.becomeFirstResponder()
|
||||
case passwordTextField: authenticate()
|
||||
default: break
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_: UITextField) {
|
||||
guard UIScreen.main.isExtraCompactHeight else { return }
|
||||
|
||||
// Position all the controls within visible frame.
|
||||
var contentOffset = scrollView.contentOffset
|
||||
contentOffset.y = 44
|
||||
scrollView.setContentOffset(contentOffset, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewController {
|
||||
@objc func textFieldDidChangeText(_: Notification) {
|
||||
update()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// InstructionsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/6/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class InstructionsViewController: UIViewController {
|
||||
var completionHandler: (() -> Void)?
|
||||
|
||||
var showsBottomButton: Bool = false
|
||||
|
||||
@IBOutlet private var contentStackView: UIStackView!
|
||||
@IBOutlet private var dismissButton: UIButton!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
if UIScreen.main.isExtraCompactHeight {
|
||||
contentStackView.layoutMargins.top = 0
|
||||
contentStackView.layoutMargins.bottom = contentStackView.layoutMargins.left
|
||||
}
|
||||
|
||||
dismissButton.clipsToBounds = true
|
||||
dismissButton.layer.cornerRadius = 16
|
||||
|
||||
if showsBottomButton {
|
||||
navigationItem.hidesBackButton = true
|
||||
} else {
|
||||
dismissButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension InstructionsViewController {
|
||||
@IBAction func dismiss() {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// RefreshAltStoreViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/26/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
final class RefreshAltStoreViewController: UIViewController {
|
||||
var context: AuthenticatedOperationContext!
|
||||
|
||||
var completionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
@IBOutlet private var placeholderView: RSTPlaceholderView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
placeholderView.textLabel.isHidden = true
|
||||
|
||||
placeholderView.detailTextLabel.textAlignment = .left
|
||||
placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("SideStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including SideStore.\n\nTo prevent SideStore from expiring early, please refresh the app now. SideStore will quit once refreshing is complete.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private extension RefreshAltStoreViewController {
|
||||
@IBAction func refreshAltStore(_ sender: PillButton) {
|
||||
guard let altStore = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext) else { return }
|
||||
|
||||
func refresh() {
|
||||
sender.isIndicatingActivity = true
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: altStore) {
|
||||
// Cancel pending AltStore installation so we can start a new one.
|
||||
progress.cancel()
|
||||
}
|
||||
|
||||
// Install, _not_ refresh, to ensure we are installing with a non-revoked certificate.
|
||||
let group = AppManager.shared.install(altStore, presentingViewController: self, context: context) { result in
|
||||
switch result {
|
||||
case .success: self.completionHandler?(.success(()))
|
||||
case let .failure(error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
sender.progress = nil
|
||||
sender.isIndicatingActivity = false
|
||||
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh SideStore", comment: ""), message: error.localizedFailureReason ?? error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: ""), style: .default, handler: { _ in
|
||||
refresh()
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh Later", comment: ""), style: .cancel, handler: { _ in
|
||||
self.completionHandler?(.failure(error))
|
||||
}))
|
||||
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sender.progress = group.progress
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
@IBAction func cancel(_: UIButton) {
|
||||
completionHandler?(.failure(OperationError.cancelled))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// ScreenshotCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(ScreenshotCollectionViewCell)
|
||||
class ScreenshotCollectionViewCell: UICollectionViewCell {
|
||||
let imageView = UIImageView(image: nil)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
imageView.layer.masksToBounds = true
|
||||
addSubview(imageView, pinningEdgesWith: .zero)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
imageView.layer.cornerRadius = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// AppBannerView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/29/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
class AppBannerView: RSTNibView {
|
||||
override var accessibilityLabel: String? {
|
||||
get { self.accessibilityView?.accessibilityLabel }
|
||||
set { self.accessibilityView?.accessibilityLabel = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedLabel: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedLabel }
|
||||
set { self.accessibilityView?.accessibilityAttributedLabel = newValue }
|
||||
}
|
||||
|
||||
override var accessibilityValue: String? {
|
||||
get { self.accessibilityView?.accessibilityValue }
|
||||
set { self.accessibilityView?.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedValue: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedValue }
|
||||
set { self.accessibilityView?.accessibilityAttributedValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityTraits: UIAccessibilityTraits {
|
||||
get { accessibilityView?.accessibilityTraits ?? [] }
|
||||
set { accessibilityView?.accessibilityTraits = newValue }
|
||||
}
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
@IBOutlet var titleLabel: UILabel!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
@IBOutlet var iconImageView: AppIconImageView!
|
||||
@IBOutlet var button: PillButton!
|
||||
@IBOutlet var buttonLabel: UILabel!
|
||||
@IBOutlet var betaBadgeView: UIView!
|
||||
|
||||
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||
@IBOutlet private var accessibilityView: UIView!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
accessibilityView.accessibilityTraits.formUnion(.button)
|
||||
|
||||
isAccessibilityElement = false
|
||||
accessibilityElements = [accessibilityView, button].compactMap { $0 }
|
||||
|
||||
betaBadgeView.isHidden = true
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
if tintAdjustmentMode != .dimmed {
|
||||
originalTintColor = tintColor
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppBannerView {
|
||||
func configure(for app: AppProtocol) {
|
||||
struct AppValues {
|
||||
var name: String
|
||||
var developerName: String?
|
||||
var isBeta: Bool = false
|
||||
|
||||
init(app: AppProtocol) {
|
||||
name = app.name
|
||||
|
||||
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||
developerName = storeApp.developerName
|
||||
|
||||
if storeApp.isBeta {
|
||||
name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||
isBeta = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let values = AppValues(app: app)
|
||||
titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||
betaBadgeView.isHidden = !values.isBeta
|
||||
|
||||
if let developerName = values.developerName {
|
||||
subtitleLabel.text = developerName
|
||||
accessibilityLabel = String(format: NSLocalizedString("%@ by %@", comment: ""), values.name, developerName)
|
||||
} else {
|
||||
subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||
accessibilityLabel = values.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppBannerView {
|
||||
func update() {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 22
|
||||
|
||||
subtitleLabel.textColor = originalTintColor ?? tintColor
|
||||
backgroundEffectView.backgroundColor = originalTintColor ?? tintColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppIconImageView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class AppIconImageView: UIImageView {
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentMode = .scaleAspectFill
|
||||
clipsToBounds = true
|
||||
|
||||
backgroundColor = .white
|
||||
|
||||
if #available(iOS 13, *) {
|
||||
self.layer.cornerCurve = .continuous
|
||||
} else {
|
||||
if layer.responds(to: Selector(("continuousCorners"))) {
|
||||
layer.setValue(true, forKey: "continuousCorners")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Based off of 60pt icon having 12pt radius.
|
||||
let radius = bounds.height / 5
|
||||
layer.cornerRadius = radius
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// BackgroundTaskManager.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
public final class BackgroundTaskManager {
|
||||
public static let shared = BackgroundTaskManager()
|
||||
|
||||
private var isPlaying = false
|
||||
|
||||
private let audioEngine: AVAudioEngine
|
||||
private let player: AVAudioPlayerNode
|
||||
private let audioFile: AVAudioFile
|
||||
|
||||
private let audioEngineQueue: DispatchQueue
|
||||
|
||||
private init() {
|
||||
audioEngine = AVAudioEngine()
|
||||
audioEngine.mainMixerNode.outputVolume = 0.0
|
||||
|
||||
player = AVAudioPlayerNode()
|
||||
audioEngine.attach(player)
|
||||
|
||||
do {
|
||||
let audioFileURL = Bundle.main.url(forResource: "Silence", withExtension: "m4a")!
|
||||
|
||||
audioFile = try AVAudioFile(forReading: audioFileURL)
|
||||
audioEngine.connect(player, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
|
||||
} catch {
|
||||
fatalError("Error. \(error)")
|
||||
}
|
||||
|
||||
audioEngineQueue = DispatchQueue(label: "com.altstore.BackgroundTaskManager")
|
||||
}
|
||||
}
|
||||
|
||||
public extension BackgroundTaskManager {
|
||||
func performExtendedBackgroundTask(taskHandler: @escaping ((Result<Void, Error>, @escaping () -> Void) -> Void)) {
|
||||
func finish() {
|
||||
player.stop()
|
||||
audioEngine.stop()
|
||||
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
audioEngineQueue.sync {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
// Schedule audio file buffers.
|
||||
self.scheduleAudioFile()
|
||||
self.scheduleAudioFile()
|
||||
|
||||
let outputFormat = self.audioEngine.outputNode.outputFormat(forBus: 0)
|
||||
self.audioEngine.connect(self.audioEngine.mainMixerNode, to: self.audioEngine.outputNode, format: outputFormat)
|
||||
|
||||
try self.audioEngine.start()
|
||||
self.player.play()
|
||||
|
||||
self.isPlaying = true
|
||||
|
||||
taskHandler(.success(())) {
|
||||
finish()
|
||||
}
|
||||
} catch {
|
||||
taskHandler(.failure(error)) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackgroundTaskManager {
|
||||
func scheduleAudioFile() {
|
||||
player.scheduleFile(audioFile, at: nil) {
|
||||
self.audioEngineQueue.async {
|
||||
guard self.isPlaying else { return }
|
||||
self.scheduleAudioFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
57
SideStoreApp/Sources/SideStoreAppKit/Components/Button.swift
Normal file
57
SideStoreApp/Sources/SideStoreAppKit/Components/Button.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Button.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class Button: UIButton {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 20
|
||||
size.height += 10
|
||||
return size
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
setTitleColor(.white, for: .normal)
|
||||
|
||||
layer.masksToBounds = true
|
||||
layer.cornerRadius = 8
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
override var isEnabled: Bool {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Button {
|
||||
func update() {
|
||||
if isEnabled {
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
backgroundColor = .lightGray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// CollapsingTextView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class CollapsingTextView: UITextView {
|
||||
var isCollapsed = true {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var maximumNumberOfLines = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var lineSpacing: CGFloat = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
let moreButton = UIButton(type: .system)
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layoutManager.delegate = self
|
||||
|
||||
textContainerInset = .zero
|
||||
textContainer.lineFragmentPadding = 0
|
||||
textContainer.lineBreakMode = .byTruncatingTail
|
||||
textContainer.heightTracksTextView = true
|
||||
textContainer.widthTracksTextView = true
|
||||
|
||||
moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal)
|
||||
moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered)
|
||||
addSubview(moreButton)
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let font = font else { return }
|
||||
|
||||
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
|
||||
moreButton.titleLabel?.font = buttonFont
|
||||
|
||||
let buttonY = (font.lineHeight + lineSpacing) * CGFloat(maximumNumberOfLines - 1)
|
||||
let size = moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
||||
|
||||
let moreButtonFrame = CGRect(x: bounds.width - moreButton.bounds.width,
|
||||
y: buttonY,
|
||||
width: size.width,
|
||||
height: font.lineHeight)
|
||||
moreButton.frame = moreButtonFrame
|
||||
|
||||
if isCollapsed {
|
||||
textContainer.maximumNumberOfLines = maximumNumberOfLines
|
||||
|
||||
let boundingSize = attributedText.boundingRect(with: CGSize(width: textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(maximumNumberOfLines)
|
||||
|
||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded() {
|
||||
var exclusionFrame = moreButtonFrame
|
||||
exclusionFrame.origin.y += moreButton.bounds.midY
|
||||
exclusionFrame.size.width = bounds.width // Extra wide to make sure it wraps to next line.
|
||||
textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
||||
|
||||
moreButton.isHidden = false
|
||||
} else {
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
} else {
|
||||
textContainer.maximumNumberOfLines = 0
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CollapsingTextView {
|
||||
@objc func toggleCollapsed(_: UIButton) {
|
||||
isCollapsed.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
extension CollapsingTextView: NSLayoutManagerDelegate {
|
||||
func layoutManager(_: NSLayoutManager, lineSpacingAfterGlyphAt _: Int, withProposedLineFragmentRect _: CGRect) -> CGFloat {
|
||||
lineSpacing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// ForwardingNavigationController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ForwardingNavigationController: UINavigationController {
|
||||
override var childForStatusBarStyle: UIViewController? {
|
||||
self.topViewController
|
||||
}
|
||||
|
||||
override var childForStatusBarHidden: UIViewController? {
|
||||
topViewController
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
SideStoreApp/Sources/SideStoreAppKit/Components/PillButton.swift
Normal file
172
SideStoreApp/Sources/SideStoreAppKit/Components/PillButton.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// PillButton.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class PillButton: UIButton {
|
||||
override var accessibilityValue: String? {
|
||||
get {
|
||||
guard progress != nil else { return super.accessibilityValue }
|
||||
return progressView.accessibilityValue
|
||||
}
|
||||
set { super.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
var progress: Progress? {
|
||||
didSet {
|
||||
progressView.progress = Float(progress?.fractionCompleted ?? 0)
|
||||
progressView.observedProgress = progress
|
||||
|
||||
let isUserInteractionEnabled = self.isUserInteractionEnabled
|
||||
isIndicatingActivity = (progress != nil)
|
||||
self.isUserInteractionEnabled = isUserInteractionEnabled
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
var progressTintColor: UIColor? {
|
||||
get {
|
||||
progressView.progressTintColor
|
||||
}
|
||||
set {
|
||||
progressView.progressTintColor = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var countdownDate: Date? {
|
||||
didSet {
|
||||
isEnabled = (countdownDate == nil)
|
||||
displayLink.isPaused = (countdownDate == nil)
|
||||
|
||||
if countdownDate == nil {
|
||||
setTitle(nil, for: .disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
|
||||
private lazy var displayLink: CADisplayLink = {
|
||||
let displayLink = CADisplayLink(target: self, selector: #selector(PillButton.updateCountdown))
|
||||
displayLink.preferredFramesPerSecond = 15
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .main, forMode: .common)
|
||||
return displayLink
|
||||
}()
|
||||
|
||||
private let dateComponentsFormatter: DateComponentsFormatter = {
|
||||
let dateComponentsFormatter = DateComponentsFormatter()
|
||||
dateComponentsFormatter.zeroFormattingBehavior = [.pad]
|
||||
dateComponentsFormatter.collapsesLargestUnit = false
|
||||
return dateComponentsFormatter
|
||||
}()
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 26
|
||||
size.height += 3
|
||||
return size
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layer.masksToBounds = true
|
||||
accessibilityTraits.formUnion([.updatesFrequently, .button])
|
||||
|
||||
activityIndicatorView.style = .medium
|
||||
activityIndicatorView.isUserInteractionEnabled = false
|
||||
|
||||
progressView.progress = 0
|
||||
progressView.trackImage = UIImage()
|
||||
progressView.isUserInteractionEnabled = false
|
||||
addSubview(progressView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
progressView.bounds.size.width = bounds.width
|
||||
|
||||
let scale = bounds.height / progressView.bounds.height
|
||||
|
||||
progressView.transform = CGAffineTransform.identity.scaledBy(x: 1, y: scale)
|
||||
progressView.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
|
||||
layer.cornerRadius = bounds.midY
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private extension PillButton {
|
||||
func update() {
|
||||
if progress == nil {
|
||||
setTitleColor(.white, for: .normal)
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
setTitleColor(tintColor, for: .normal)
|
||||
backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
}
|
||||
|
||||
progressView.progressTintColor = tintColor
|
||||
}
|
||||
|
||||
@objc func updateCountdown() {
|
||||
guard let endDate = countdownDate else { return }
|
||||
|
||||
let startDate = Date()
|
||||
|
||||
let interval = endDate.timeIntervalSince(startDate)
|
||||
guard interval > 0 else {
|
||||
isEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
let text: String?
|
||||
|
||||
if interval < (1 * 60 * 60) {
|
||||
dateComponentsFormatter.unitsStyle = .positional
|
||||
dateComponentsFormatter.allowedUnits = [.minute, .second]
|
||||
|
||||
text = dateComponentsFormatter.string(from: startDate, to: endDate)
|
||||
} else if interval < (2 * 24 * 60 * 60) {
|
||||
dateComponentsFormatter.unitsStyle = .positional
|
||||
dateComponentsFormatter.allowedUnits = [.hour, .minute, .second]
|
||||
|
||||
text = dateComponentsFormatter.string(from: startDate, to: endDate)
|
||||
} else {
|
||||
dateComponentsFormatter.unitsStyle = .full
|
||||
dateComponentsFormatter.allowedUnits = [.day]
|
||||
|
||||
let numberOfDays = endDate.numberOfCalendarDays(since: startDate)
|
||||
text = String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays))
|
||||
}
|
||||
|
||||
if let text = text {
|
||||
UIView.performWithoutAnimation {
|
||||
self.isEnabled = false
|
||||
self.setTitle(text, for: .disabled)
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
} else {
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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!
|
||||
}
|
||||
115
SideStoreApp/Sources/SideStoreAppKit/Components/ToastView.swift
Normal file
115
SideStoreApp/Sources/SideStoreAppKit/Components/ToastView.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// ToastView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import RoxasUIKit
|
||||
import 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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
11
SideStoreApp/Sources/SideStoreAppKit/Consts/Consts.swift
Normal file
11
SideStoreApp/Sources/SideStoreAppKit/Consts/Consts.swift
Normal 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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// INInteraction+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/4/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Intents
|
||||
|
||||
// Requires iOS 14 in-app intent handling.
|
||||
@available(iOS 14, *)
|
||||
extension INInteraction {
|
||||
static func refreshAllApps() -> INInteraction {
|
||||
let refreshAllIntent = RefreshAllIntent()
|
||||
refreshAllIntent.suggestedInvocationPhrase = NSString.deferredLocalizedIntentsString(with: "Refresh my apps") as String
|
||||
|
||||
let interaction = INInteraction(intent: refreshAllIntent, response: nil)
|
||||
return interaction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// OSLog+SideStore.swift
|
||||
// SideStore
|
||||
//
|
||||
// Created by Joseph Mattiello on 11/16/22.
|
||||
// Copyright © 2022 SideStore. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
public let customLog = OSLog(subsystem: "org.sidestore.sidestore",
|
||||
category: "ios")
|
||||
|
||||
public extension OSLog {
|
||||
/// Error logger extension
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
static func error(_ message: StaticString, _ args: CVarArg...) {
|
||||
os_log(message, log: customLog, type: .error, args)
|
||||
}
|
||||
|
||||
/// Info logger extension
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
static func info(_ message: StaticString, _ args: CVarArg...) {
|
||||
os_log(message, log: customLog, type: .info, args)
|
||||
}
|
||||
|
||||
/// Debug logger extension
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
static func debug(_ message: StaticString, _ args: CVarArg...) {
|
||||
os_log(message, log: customLog, type: .debug, args)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add file,line,function to messages? -- @JoeMatt
|
||||
|
||||
/// Error logger convenience method for SideStore logging
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
public func ELOG(_ message: StaticString, file _: StaticString = #file, function _: StaticString = #function, line _: UInt = #line, _ args: CVarArg...) {
|
||||
OSLog.error(message, args)
|
||||
}
|
||||
|
||||
/// Info logger convenience method for SideStore logging
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
public func ILOG(_ message: StaticString, file _: StaticString = #file, function _: StaticString = #function, line _: UInt = #line, _ args: CVarArg...) {
|
||||
OSLog.info(message, args)
|
||||
}
|
||||
|
||||
/// Debug logger convenience method for SideStore logging
|
||||
/// - Parameters:
|
||||
/// - message: String or format string
|
||||
/// - args: optional args for format string
|
||||
@inlinable
|
||||
public func DLOG(_ message: StaticString, file _: StaticString = #file, function _: StaticString = #function, line _: UInt = #line, _ args: CVarArg...) {
|
||||
OSLog.debug(message, args)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// UIDevice+Jailbreak.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/5/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import ARKit
|
||||
import UIKit
|
||||
|
||||
extension UIDevice {
|
||||
var isJailbroken: Bool {
|
||||
if
|
||||
FileManager.default.fileExists(atPath: "/Applications/Cydia.app") ||
|
||||
FileManager.default.fileExists(atPath: "/Library/MobileSubstrate/MobileSubstrate.dylib") ||
|
||||
FileManager.default.fileExists(atPath: "/bin/bash") ||
|
||||
FileManager.default.fileExists(atPath: "/usr/sbin/sshd") ||
|
||||
FileManager.default.fileExists(atPath: "/etc/apt") ||
|
||||
FileManager.default.fileExists(atPath: "/private/var/lib/apt/") ||
|
||||
UIApplication.shared.canOpenURL(URL(string: "cydia://")!)
|
||||
{
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
var supportsFugu14: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return true
|
||||
#else
|
||||
// Fugu14 is supported on devices with an A12 processor or better.
|
||||
// ARKit 3 is only supported by devices with an A12 processor or better, according to the documentation.
|
||||
return ARBodyTrackingConfiguration.isSupported
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
var isUntetheredJailbreakRequired: Bool {
|
||||
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
||||
|
||||
let isUntetheredJailbreakRequired = ProcessInfo.processInfo.isOperatingSystemAtLeast(ios14_4)
|
||||
return isUntetheredJailbreakRequired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// UIDevice+Vibration.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/1/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioToolbox
|
||||
import CoreHaptics
|
||||
import UIKit
|
||||
|
||||
private extension SystemSoundID {
|
||||
static let pop = SystemSoundID(1520)
|
||||
static let cancelled = SystemSoundID(1521)
|
||||
static let tryAgain = SystemSoundID(1102)
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension UIDevice {
|
||||
enum VibrationPattern {
|
||||
case success
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension UIDevice {
|
||||
var isVibrationSupported: Bool {
|
||||
CHHapticEngine.capabilitiesForHardware().supportsHaptics
|
||||
}
|
||||
|
||||
func vibrate(pattern: VibrationPattern) {
|
||||
guard isVibrationSupported else { return }
|
||||
|
||||
switch pattern {
|
||||
case .success: AudioServicesPlaySystemSound(.tryAgain)
|
||||
case .error: AudioServicesPlaySystemSound(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// UIScreen+CompactHeight.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/6/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIScreen {
|
||||
var isExtraCompactHeight: Bool {
|
||||
fixedCoordinateSpace.bounds.height < 600
|
||||
}
|
||||
}
|
||||
1575
SideStoreApp/Sources/SideStoreAppKit/Managing Apps/AppManager.swift
Normal file
1575
SideStoreApp/Sources/SideStoreAppKit/Managing Apps/AppManager.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// AppManagerErrors.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
public extension AppManager {
|
||||
struct FetchSourcesError: LocalizedError, CustomNSError {
|
||||
public private(set) var primaryError: Error?
|
||||
|
||||
public private(set) var sources: Set<Source>?
|
||||
public private(set) var errors = [Source: Error]()
|
||||
|
||||
public private(set) var managedObjectContext: NSManagedObjectContext?
|
||||
|
||||
public var errorDescription: String? {
|
||||
if let error = primaryError {
|
||||
return error.localizedDescription
|
||||
} else {
|
||||
var localizedDescription: String?
|
||||
|
||||
managedObjectContext?.performAndWait {
|
||||
if self.sources?.count == 1 {
|
||||
localizedDescription = NSLocalizedString("Could not refresh store.", comment: "")
|
||||
} else if self.errors.count == 1 {
|
||||
guard let source = self.errors.keys.first else { return }
|
||||
localizedDescription = String(format: NSLocalizedString("Could not refresh source “%@”.", comment: ""), source.name)
|
||||
} else {
|
||||
localizedDescription = String(format: NSLocalizedString("Could not refresh %@ sources.", comment: ""), NSNumber(value: self.errors.count))
|
||||
}
|
||||
}
|
||||
|
||||
return localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
if let error = primaryError as NSError? {
|
||||
return error.localizedRecoverySuggestion
|
||||
} else if errors.count == 1 {
|
||||
return nil
|
||||
} else {
|
||||
return NSLocalizedString("Tap to view source errors.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
public var errorUserInfo: [String: Any] {
|
||||
guard let error = errors.values.first, errors.count == 1 else { return [:] }
|
||||
return [NSUnderlyingErrorKey: error]
|
||||
}
|
||||
|
||||
public init(_ error: Error) {
|
||||
primaryError = error
|
||||
}
|
||||
|
||||
public init(sources: Set<Source>, errors: [Source: Error], context: NSManagedObjectContext) {
|
||||
self.sources = sources
|
||||
self.errors = errors
|
||||
managedObjectContext = context
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// InstalledAppsCollectionHeaderView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/9/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class InstalledAppsCollectionHeaderView: UICollectionReusableView {
|
||||
let textLabel: UILabel
|
||||
let button: UIButton
|
||||
|
||||
override init(frame: CGRect) {
|
||||
textLabel = UILabel()
|
||||
textLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
textLabel.font = UIFont.systemFont(ofSize: 24, weight: .bold)
|
||||
textLabel.accessibilityTraits.insert(.header)
|
||||
|
||||
button = UIButton(type: .system)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
addSubview(textLabel)
|
||||
addSubview(button)
|
||||
|
||||
NSLayoutConstraint.activate([textLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
|
||||
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor)])
|
||||
|
||||
NSLayoutConstraint.activate([button.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
|
||||
button.firstBaselineAnchor.constraint(equalTo: textLabel.firstBaselineAnchor)])
|
||||
|
||||
preservesSuperviewLayoutMargins = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// UpdateCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/16/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UpdateCollectionViewCell {
|
||||
enum Mode {
|
||||
case collapsed
|
||||
case expanded
|
||||
}
|
||||
}
|
||||
|
||||
@objc final class UpdateCollectionViewCell: UICollectionViewCell {
|
||||
var mode: Mode = .expanded {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
@IBOutlet var versionDescriptionTitleLabel: UILabel!
|
||||
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
|
||||
|
||||
@IBOutlet private var blurView: UIVisualEffectView!
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
// Prevent temporary unsatisfiable constraint errors due to UIView-Encapsulated-Layout constraints.
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
bannerView.backgroundEffectView.isHidden = true
|
||||
bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
|
||||
|
||||
blurView.layer.cornerRadius = 20
|
||||
blurView.layer.masksToBounds = true
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
if tintAdjustmentMode != .dimmed {
|
||||
originalTintColor = tintColor
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func apply(_: UICollectionViewLayoutAttributes) {
|
||||
// Animates transition to new attributes.
|
||||
let animator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let view = super.hitTest(point, with: event)
|
||||
|
||||
if view == versionDescriptionTextView {
|
||||
// Forward touches on the text view (but not on the nested "more" button)
|
||||
// so cell selection works as expected.
|
||||
return self
|
||||
} else {
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UpdateCollectionViewCell {
|
||||
func update() {
|
||||
switch mode {
|
||||
case .collapsed: versionDescriptionTextView.isCollapsed = true
|
||||
case .expanded: versionDescriptionTextView.isCollapsed = false
|
||||
}
|
||||
|
||||
versionDescriptionTitleLabel.textColor = originalTintColor ?? tintColor
|
||||
blurView.backgroundColor = originalTintColor ?? tintColor
|
||||
bannerView.button.progressTintColor = originalTintColor ?? tintColor
|
||||
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)! }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// ErrorLogTableViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/9/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc(ErrorLogTableViewCell)
|
||||
final class ErrorLogTableViewCell: UITableViewCell {
|
||||
@IBOutlet var appIconImageView: AppIconImageView!
|
||||
|
||||
@IBOutlet var dateLabel: UILabel!
|
||||
@IBOutlet var errorFailureLabel: UILabel!
|
||||
@IBOutlet var errorCodeLabel: UILabel!
|
||||
@IBOutlet var errorDescriptionTextView: CollapsingTextView!
|
||||
|
||||
@IBOutlet var menuButton: UIButton!
|
||||
|
||||
private var didLayoutSubviews = false
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let moreButtonFrame = convert(errorDescriptionTextView.moreButton.frame, from: errorDescriptionTextView)
|
||||
guard moreButtonFrame.contains(point) else { return super.hitTest(point, with: event) }
|
||||
|
||||
// Pass touches through menuButton so user can press moreButton.
|
||||
return errorDescriptionTextView.moreButton
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
didLayoutSubviews = true
|
||||
}
|
||||
|
||||
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
|
||||
if !didLayoutSubviews {
|
||||
// Ensure cell is laid out so it will report correct size.
|
||||
layoutIfNeeded()
|
||||
}
|
||||
|
||||
let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
|
||||
return size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// InsetGroupTableViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/31/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension InsetGroupTableViewCell {
|
||||
@objc enum Style: Int {
|
||||
case single
|
||||
case top
|
||||
case middle
|
||||
case bottom
|
||||
}
|
||||
}
|
||||
|
||||
final class InsetGroupTableViewCell: UITableViewCell {
|
||||
#if !TARGET_INTERFACE_BUILDER
|
||||
@IBInspectable var style: Style = .single {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
#else
|
||||
@IBInspectable var style: Int = 0
|
||||
#endif
|
||||
|
||||
@IBInspectable var isSelectable: Bool = false
|
||||
|
||||
private let separatorView = UIView()
|
||||
private let insetView = UIView()
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
selectionStyle = .none
|
||||
|
||||
separatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
separatorView.backgroundColor = UIColor.white.withAlphaComponent(0.25)
|
||||
addSubview(separatorView)
|
||||
|
||||
insetView.layer.masksToBounds = true
|
||||
insetView.layer.cornerRadius = 16
|
||||
|
||||
// Get the preferred background color from Interface Builder.
|
||||
insetView.backgroundColor = backgroundColor
|
||||
backgroundColor = nil
|
||||
|
||||
addSubview(insetView, pinningEdgesWith: UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15))
|
||||
sendSubviewToBack(insetView)
|
||||
|
||||
NSLayoutConstraint.activate([separatorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 30),
|
||||
separatorView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -30),
|
||||
separatorView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
separatorView.heightAnchor.constraint(equalToConstant: 1)])
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func setSelected(_ selected: Bool, animated: Bool) {
|
||||
super.setSelected(selected, animated: animated)
|
||||
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
self.update()
|
||||
}
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
super.setHighlighted(highlighted, animated: animated)
|
||||
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
self.update()
|
||||
}
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension InsetGroupTableViewCell {
|
||||
func update() {
|
||||
switch style {
|
||||
case .single:
|
||||
insetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
separatorView.isHidden = true
|
||||
|
||||
case .top:
|
||||
insetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
separatorView.isHidden = false
|
||||
|
||||
case .middle:
|
||||
insetView.layer.maskedCorners = []
|
||||
separatorView.isHidden = false
|
||||
|
||||
case .bottom:
|
||||
insetView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
separatorView.isHidden = true
|
||||
}
|
||||
|
||||
if isSelectable && (isHighlighted || isSelected) {
|
||||
insetView.backgroundColor = UIColor.white.withAlphaComponent(0.55)
|
||||
} else {
|
||||
insetView.backgroundColor = UIColor.white.withAlphaComponent(0.25)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// LicensesViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/6/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class LicensesViewController: UIViewController {
|
||||
private var _didAppear = false
|
||||
|
||||
@IBOutlet private var textView: UITextView!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
|
||||
// Fix incorrect initial offset on iPhone SE.
|
||||
textView.contentOffset.y = 0
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
_didAppear = true
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
textView.textContainerInset.left = view.layoutMargins.left
|
||||
textView.textContainerInset.right = view.layoutMargins.right
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
|
||||
if !_didAppear {
|
||||
// Fix incorrect initial offset on iPhone SE.
|
||||
textView.contentOffset.y = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -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 ,
|
||||
|
||||
You’re the best. Your account was linked successfully, so you now have access to the beta versions of all of our apps. You can find them all in the Browse tab.
|
||||
|
||||
Thanks for all of your support. Enjoy!
|
||||
- SideTeam
|
||||
""", comment: "")
|
||||
|
||||
if let account = DatabaseManager.shared.patreonAccount(), PatreonAPI.shared.isAuthenticated {
|
||||
headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered)
|
||||
headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal)
|
||||
|
||||
if account.isPatron {
|
||||
headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal)
|
||||
|
||||
let font = UIFont.systemFont(ofSize: 16)
|
||||
|
||||
let attributedText = NSMutableAttributedString(string: isPatronText, attributes: [.font: font,
|
||||
.foregroundColor: UIColor.white])
|
||||
|
||||
let boldedName = NSAttributedString(string: account.firstName ?? account.name,
|
||||
attributes: [.font: UIFont.boldSystemFont(ofSize: font.pointSize),
|
||||
.foregroundColor: UIColor.white])
|
||||
attributedText.insert(boldedName, at: 4)
|
||||
|
||||
headerView.textView.attributedText = attributedText
|
||||
} else {
|
||||
headerView.supportButton.setTitle(defaultSupportButtonTitle, for: .normal)
|
||||
headerView.textView.text = defaultText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PatreonViewController {
|
||||
@objc func fetchPatrons() {
|
||||
AppManager.shared.updatePatronsIfNeeded()
|
||||
update()
|
||||
}
|
||||
|
||||
@objc func openPatreonURL(_: UIButton) {
|
||||
let patreonURL = URL(string: "https://www.patreon.com/SideStore")!
|
||||
|
||||
let safariViewController = SFSafariViewController(url: patreonURL)
|
||||
safariViewController.preferredControlTintColor = view.tintColor
|
||||
present(safariViewController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@IBAction func authenticate(_: UIBarButtonItem) {
|
||||
PatreonAPI.shared.authenticate { result in
|
||||
do {
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
} catch ASWebAuthenticationSessionError.canceledLogin {
|
||||
// Ignore
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func signOut(_: UIBarButtonItem) {
|
||||
func signOut() {
|
||||
PatreonAPI.shared.signOut { result in
|
||||
do {
|
||||
try result.get()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to unlink your Patreon account?", comment: ""), message: NSLocalizedString("You will no longer have access to beta versions of apps.", comment: ""), preferredStyle: .actionSheet)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Unlink Patreon Account", comment: ""), style: .destructive) { _ in signOut() })
|
||||
alertController.addAction(.cancel)
|
||||
present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func didUpdatePatrons(_: Notification) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
// Wait short delay before reloading or else footer won't properly update if it's already visible 🤷♂️
|
||||
self.collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonViewController {
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
let section = Section.allCases[indexPath.section]
|
||||
switch section {
|
||||
case .about:
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "AboutHeader", for: indexPath) as! AboutPatreonHeaderView
|
||||
prepare(headerView)
|
||||
return headerView
|
||||
|
||||
case .patrons:
|
||||
if kind == UICollectionView.elementKindSectionHeader {
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "PatronsHeader", for: indexPath) as! PatronsHeaderView
|
||||
headerView.textLabel.text = NSLocalizedString("Special thanks to...", comment: "")
|
||||
return headerView
|
||||
} else {
|
||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "PatronsFooter", for: indexPath) as! PatronsFooterView
|
||||
footerView.button.isIndicatingActivity = false
|
||||
footerView.button.isHidden = false
|
||||
// footerView.button.addTarget(self, action: #selector(PatreonViewController.fetchPatrons), for: .primaryActionTriggered)
|
||||
|
||||
switch AppManager.shared.updatePatronsResult {
|
||||
case .none: footerView.button.isIndicatingActivity = true
|
||||
case .success?: footerView.button.isHidden = true
|
||||
case .failure?:
|
||||
#if DEBUG
|
||||
let debug = true
|
||||
#else
|
||||
let debug = false
|
||||
#endif
|
||||
|
||||
if patronsDataSource.itemCount == 0 || debug {
|
||||
// Only show error message if there aren't any cached Patrons (or if this is a debug build).
|
||||
|
||||
footerView.button.isHidden = false
|
||||
footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal)
|
||||
} else {
|
||||
footerView.button.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
return footerView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
|
||||
let section = Section.allCases[section]
|
||||
switch section {
|
||||
case .about:
|
||||
let widthConstraint = prototypeAboutHeader.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
NSLayoutConstraint.activate([widthConstraint])
|
||||
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
||||
|
||||
prepare(prototypeAboutHeader)
|
||||
|
||||
let size = prototypeAboutHeader.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
return size
|
||||
|
||||
case .patrons:
|
||||
return CGSize(width: 0, height: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
|
||||
let section = Section.allCases[section]
|
||||
switch section {
|
||||
case .about: return .zero
|
||||
case .patrons: return CGSize(width: 0, height: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// RefreshAttemptsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/31/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(RefreshAttemptTableViewCell)
|
||||
private final class RefreshAttemptTableViewCell: UITableViewCell {
|
||||
@IBOutlet var successLabel: UILabel!
|
||||
@IBOutlet var dateLabel: UILabel!
|
||||
@IBOutlet var errorDescriptionLabel: UILabel!
|
||||
}
|
||||
|
||||
final class RefreshAttemptsViewController: UITableViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.dataSource = dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension RefreshAttemptsViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsTableViewDataSource<RefreshAttempt> {
|
||||
let fetchRequest = RefreshAttempt.fetchRequest() as NSFetchRequest<RefreshAttempt>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \RefreshAttempt.date, ascending: false)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
let dataSource = RSTFetchedResultsTableViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellConfigurationHandler = { [weak self] cell, attempt, _ in
|
||||
let cell = cell as! RefreshAttemptTableViewCell
|
||||
cell.dateLabel.text = self?.dateFormatter.string(from: attempt.date)
|
||||
cell.errorDescriptionLabel.text = attempt.errorDescription
|
||||
|
||||
if attempt.isSuccess {
|
||||
cell.successLabel.text = NSLocalizedString("Success", comment: "")
|
||||
cell.successLabel.textColor = .refreshGreen
|
||||
} else {
|
||||
cell.successLabel.text = NSLocalizedString("Failure", comment: "")
|
||||
cell.successLabel.textColor = .refreshRed
|
||||
}
|
||||
}
|
||||
|
||||
let placeholderView = RSTPlaceholderView()
|
||||
placeholderView.textLabel.text = NSLocalizedString("No Refresh Attempts", comment: "")
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("The more you use SideStore, the more often iOS will allow it to refresh apps in the background.", comment: "")
|
||||
dataSource.placeholderView = placeholderView
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// SettingsHeaderFooterView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/31/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
final class SettingsHeaderFooterView: UITableViewHeaderFooterView {
|
||||
@IBOutlet var primaryLabel: UILabel!
|
||||
@IBOutlet var secondaryLabel: UILabel!
|
||||
@IBOutlet var button: UIButton!
|
||||
|
||||
@IBOutlet private var stackView: UIStackView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.layoutMargins = .zero
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([stackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
stackView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//
|
||||
// SettingsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/31/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Intents
|
||||
import IntentsUI
|
||||
import MessageUI
|
||||
import SafariServices
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
private extension SettingsViewController {
|
||||
enum Section: Int, CaseIterable {
|
||||
case signIn
|
||||
case account
|
||||
case patreon
|
||||
case appRefresh
|
||||
case instructions
|
||||
case credits
|
||||
case debug
|
||||
}
|
||||
|
||||
enum AppRefreshRow: Int, CaseIterable {
|
||||
case backgroundRefresh
|
||||
|
||||
@available(iOS 14, *)
|
||||
case addToSiri
|
||||
|
||||
static var allCases: [AppRefreshRow] {
|
||||
guard #available(iOS 14, *) else { return [.backgroundRefresh] }
|
||||
return [.backgroundRefresh, .addToSiri]
|
||||
}
|
||||
}
|
||||
|
||||
enum CreditsRow: Int, CaseIterable {
|
||||
case developer
|
||||
case operations
|
||||
case designer
|
||||
case softwareLicenses
|
||||
}
|
||||
|
||||
enum DebugRow: Int, CaseIterable {
|
||||
case sendFeedback
|
||||
case refreshAttempts
|
||||
case errorLog
|
||||
case resetPairingFile
|
||||
case advancedSettings
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsViewController: UITableViewController {
|
||||
private var activeTeam: Team?
|
||||
|
||||
private var prototypeHeaderFooterView: SettingsHeaderFooterView!
|
||||
|
||||
private var debugGestureCounter = 0
|
||||
private weak var debugGestureTimer: Timer?
|
||||
|
||||
@IBOutlet private var accountNameLabel: UILabel!
|
||||
@IBOutlet private var accountEmailLabel: UILabel!
|
||||
@IBOutlet private var accountTypeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var backgroundRefreshSwitch: UISwitch!
|
||||
|
||||
@IBOutlet private var versionLabel: UILabel!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsViewController.openPatreonSettings(_:)), name: SideStoreAppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let nib = UINib(nibName: "SettingsHeaderFooterView", bundle: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// SideStoreAppDelegate.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Intents
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import EmotionalDamage
|
||||
import RoxasUIKit
|
||||
|
||||
open class SideStoreAppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
}
|
||||
|
||||
public extension SideStoreAppDelegate {
|
||||
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
||||
static let importAppDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ImportAppDeepLinkNotification")
|
||||
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
||||
|
||||
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
||||
|
||||
static let importAppDeepLinkURLKey = "fileURL"
|
||||
static let appBackupResultKey = "result"
|
||||
static let addSourceDeepLinkURLKey = "sourceURL"
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
121
SideStoreApp/Sources/SideStoreAppKit/TabBarController.swift
Normal file
121
SideStoreApp/Sources/SideStoreAppKit/TabBarController.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// TabBarController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SideStoreCore
|
||||
import UIKit
|
||||
|
||||
extension TabBarController {
|
||||
private enum Tab: Int, CaseIterable {
|
||||
case news
|
||||
case browse
|
||||
case myApps
|
||||
case settings
|
||||
}
|
||||
}
|
||||
|
||||
public final class TabBarController: UITabBarController {
|
||||
private var initialSegue: (identifier: String, sender: Any?)?
|
||||
|
||||
private var _viewDidAppear = false
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.openPatreonSettings(_:)), name: SideStoreAppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.importApp(_:)), name: SideStoreAppDelegate.importAppDeepLinkNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.presentSources(_:)), name: SideStoreAppDelegate.addSourceDeepLinkNotification, object: nil)
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
_viewDidAppear = true
|
||||
|
||||
if let (identifier, sender) = initialSegue {
|
||||
initialSegue = nil
|
||||
performSegue(withIdentifier: identifier, sender: sender)
|
||||
} else if let patchedApps = UserDefaults.standard.patchedApps, !patchedApps.isEmpty {
|
||||
// Check if we need to finish installing untethered jailbreak.
|
||||
let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext)
|
||||
guard let patchedApp = activeApps.first(where: { patchedApps.contains($0.bundleIdentifier) }) else { return }
|
||||
|
||||
performSegue(withIdentifier: "finishJailbreak", sender: patchedApp)
|
||||
}
|
||||
}
|
||||
|
||||
public override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
guard let identifier = segue.identifier else { return }
|
||||
|
||||
switch identifier {
|
||||
case "presentSources":
|
||||
guard let notification = sender as? Notification,
|
||||
let sourceURL = notification.userInfo?[SideStoreAppDelegate.addSourceDeepLinkURLKey] as? URL
|
||||
else { return }
|
||||
|
||||
let navigationController = segue.destination as! UINavigationController
|
||||
let sourcesViewController = navigationController.viewControllers.first as! SourcesViewController
|
||||
sourcesViewController.deepLinkSourceURL = sourceURL
|
||||
|
||||
case "finishJailbreak":
|
||||
guard let installedApp = sender as? InstalledApp, #available(iOS 14, *) else { return }
|
||||
|
||||
let navigationController = segue.destination as! UINavigationController
|
||||
|
||||
let patchViewController = navigationController.viewControllers.first as! PatchViewController
|
||||
patchViewController.installedApp = installedApp
|
||||
patchViewController.completionHandler = { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
public override func performSegue(withIdentifier identifier: String, sender: Any?) {
|
||||
guard _viewDidAppear else {
|
||||
initialSegue = (identifier, sender)
|
||||
return
|
||||
}
|
||||
|
||||
super.performSegue(withIdentifier: identifier, sender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
extension TabBarController {
|
||||
@objc func presentSources(_ sender: Any) {
|
||||
if let presentedViewController = presentedViewController {
|
||||
if let navigationController = presentedViewController as? UINavigationController,
|
||||
let sourcesViewController = navigationController.viewControllers.first as? SourcesViewController {
|
||||
if let notification = (sender as? Notification),
|
||||
let sourceURL = notification.userInfo?[SideStoreAppDelegate.addSourceDeepLinkURLKey] as? URL {
|
||||
sourcesViewController.deepLinkSourceURL = sourceURL
|
||||
} else {
|
||||
// Don't dismiss SourcesViewController if it's already presented.
|
||||
}
|
||||
} else {
|
||||
presentedViewController.dismiss(animated: true) {
|
||||
self.presentSources(sender)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
performSegue(withIdentifier: "presentSources", sender: sender)
|
||||
}
|
||||
}
|
||||
|
||||
private extension TabBarController {
|
||||
@objc func openPatreonSettings(_: Notification) {
|
||||
selectedIndex = Tab.settings.rawValue
|
||||
}
|
||||
|
||||
@objc func importApp(_: Notification) {
|
||||
selectedIndex = Tab.myApps.rawValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>)
|
||||
}
|
||||
44
SideStoreApp/Sources/SideStoreAppKit/Types/Managed.swift
Normal file
44
SideStoreApp/Sources/SideStoreAppKit/Types/Managed.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// ScreenshotProcessor.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 4/11/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Nuke
|
||||
import UIKit
|
||||
|
||||
struct ScreenshotProcessor: ImageProcessing {
|
||||
func process(image: Image, context _: ImageProcessingContext) -> Image? {
|
||||
guard let cgImage = image.cgImage, image.size.width > image.size.height else { return image }
|
||||
|
||||
let rotatedImage = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
|
||||
return rotatedImage
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageProcessing where Self == ScreenshotProcessor {
|
||||
static var screenshot: Self { Self() }
|
||||
}
|
||||
Reference in New Issue
Block a user