mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 23:03:27 +01:00
* Change error from Swift.Error to NSError
* Adds ResultOperation.localizedFailure
* Finish Riley's monster commit
3b38d725d7
May the Gods have mercy on my soul.
* Fix format strings I broke
* Include "Enable JIT" errors in Error Log
* Fix minimuxer status checking
* [skip ci] Update the no wifi message to include VPN
* Opens Error Log when tapping ToastView
* Fixes Error Log context menu covering cell content
* Fixes Error Log context menu appearing while scrolling
* Fixes incorrect Search FAQ URL
* Fix Error Log showing UIAlertController on iOS 14+
* Fix Error Log not showing UIAlertController on iOS <=13
* Fix wrong color in AuthenticationViewController
* Fix typo
* Fixes logging non-AltServerErrors as AltServerError.underlyingError
* Limits quitting other AltStore/SideStore processes to database migrations
* Skips logging cancelled errors
* Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion
We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors.
However, we kept the underlying Core Data property name the same to avoid extra migration.
* Conforms OperatingSystemVersion to Comparable
* Parses AppVersion.minOSVersion/maxOSVersion from source JSON
* Supports non-NSManagedObjects for @Managed properties
This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before.
* Supports optional @Managed properties
* Conforms AppVersion to AppProtocol
* Verifies min/max OS version before downloading app + asks user to download older app version if necessary
* Improves error message when file does not exist at AppVersion.downloadURL
* Removes unnecessary StoreApp convenience properties
* Removes unnecessary StoreApp convenience properties as well as fix other issues
* Remove Settings bundle, add SwiftUI view instead
Fix refresh all shortcut intent
* Update AuthenticationOperation.swift
Signed-off-by: June Park <rjp2030@outlook.com>
* Fix build issues given by develop
* Add availability check to fix CI build(?)
* If it's gonna be that way...
---------
Signed-off-by: June Park <rjp2030@outlook.com>
Co-authored-by: nythepegasus <nythepegasus84@gmail.com>
Co-authored-by: Riley Testut <riley@rileytestut.com>
Co-authored-by: ny <me@nythepegas.us>
1855 lines
82 KiB
Swift
1855 lines
82 KiB
Swift
//
|
|
// AppManager.swift
|
|
// AltStore
|
|
//
|
|
// Created by Riley Testut on 5/29/19.
|
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import UserNotifications
|
|
import MobileCoreServices
|
|
import Intents
|
|
import Combine
|
|
import WidgetKit
|
|
|
|
import minimuxer
|
|
import AltStoreCore
|
|
import AltSign
|
|
import Roxas
|
|
|
|
extension AppManager
|
|
{
|
|
static let didFetchSourceNotification = Notification.Name("io.altstore.AppManager.didFetchSource")
|
|
static let didUpdatePatronsNotification = Notification.Name("io.altstore.AppManager.didUpdatePatrons")
|
|
|
|
static let expirationWarningNotificationID = "altstore-expiration-warning"
|
|
static let enableJITResultNotificationID = "altstore-enable-jit"
|
|
}
|
|
|
|
@available(iOS 13, *)
|
|
final class AppManagerPublisher: ObservableObject
|
|
{
|
|
@Published
|
|
fileprivate(set) var installationProgress = [String: Progress]()
|
|
|
|
@Published
|
|
fileprivate(set) var refreshProgress = [String: Progress]()
|
|
}
|
|
|
|
final class AppManager
|
|
{
|
|
static let shared = AppManager()
|
|
|
|
private(set) var updatePatronsResult: Result<Void, Error>?
|
|
|
|
private let operationQueue = OperationQueue()
|
|
private let serialOperationQueue = OperationQueue()
|
|
|
|
private var installationProgress = [String: Progress]() {
|
|
didSet {
|
|
guard #available(iOS 13, *) else { return }
|
|
self.publisher.installationProgress = self.installationProgress
|
|
}
|
|
}
|
|
private var refreshProgress = [String: Progress]() {
|
|
didSet {
|
|
guard #available(iOS 13, *) else { return }
|
|
self.publisher.refreshProgress = self.refreshProgress
|
|
}
|
|
}
|
|
|
|
@available(iOS 13, *)
|
|
private(set) var publisher: AppManagerPublisher {
|
|
get { _publisher as! AppManagerPublisher }
|
|
set { _publisher = newValue }
|
|
}
|
|
|
|
@available(iOS 13, *)
|
|
private(set) var cancellables: Set<AnyCancellable> {
|
|
get { _cancellables as! Set<AnyCancellable> }
|
|
set { _cancellables = newValue }
|
|
}
|
|
|
|
private lazy var _publisher: Any = {
|
|
guard #available(iOS 13, *) else { fatalError() }
|
|
return AppManagerPublisher()
|
|
}()
|
|
|
|
private lazy var _cancellables: Any = {
|
|
guard #available(iOS 13, *) else { fatalError() }
|
|
return Set<AnyCancellable>()
|
|
}()
|
|
|
|
private init()
|
|
{
|
|
self.operationQueue.name = "com.altstore.AppManager.operationQueue"
|
|
|
|
self.serialOperationQueue.name = "com.altstore.AppManager.serialOperationQueue"
|
|
self.serialOperationQueue.maxConcurrentOperationCount = 1
|
|
|
|
if #available(iOS 13, *)
|
|
{
|
|
self.prepareSubscriptions()
|
|
}
|
|
}
|
|
|
|
@available(iOS 13, *)
|
|
func prepareSubscriptions()
|
|
{
|
|
/// Every time refreshProgress is changed, update all InstalledApps in memory
|
|
/// so that app.isRefreshing == refreshProgress.keys.contains(app.bundleID)
|
|
|
|
self.publisher.$refreshProgress
|
|
.receive(on: RunLoop.main)
|
|
.map(\.keys)
|
|
.flatMap { (bundleIDs) in
|
|
DatabaseManager.shared.viewContext.registeredObjects.publisher
|
|
.compactMap { $0 as? InstalledApp }
|
|
.map { ($0, bundleIDs.contains($0.bundleIdentifier)) }
|
|
}
|
|
.sink { (installedApp, isRefreshing) in
|
|
installedApp.isRefreshing = isRefreshing
|
|
}
|
|
.store(in: &self.cancellables)
|
|
}
|
|
}
|
|
|
|
extension AppManager
|
|
{
|
|
func update()
|
|
{
|
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
#if targetEnvironment(simulator)
|
|
// Apps aren't ever actually installed to simulator, so just do nothing rather than delete them from database.
|
|
#else
|
|
do
|
|
{
|
|
let installedApps = InstalledApp.all(in: context)
|
|
|
|
if UserDefaults.standard.legacySideloadedApps == nil
|
|
{
|
|
// First time updating apps since updating AltStore to use custom UTIs,
|
|
// so cache all existing apps temporarily to prevent us from accidentally
|
|
// deleting them due to their custom UTI not existing (yet).
|
|
let apps = installedApps.map { $0.bundleIdentifier }
|
|
UserDefaults.standard.legacySideloadedApps = apps
|
|
}
|
|
|
|
let legacySideloadedApps = Set(UserDefaults.standard.legacySideloadedApps ?? [])
|
|
|
|
for app in installedApps
|
|
{
|
|
guard app.bundleIdentifier != StoreApp.altstoreAppID else {
|
|
self.scheduleExpirationWarningLocalNotification(for: app)
|
|
continue
|
|
}
|
|
|
|
guard !self.isActivelyManagingApp(withBundleID: app.bundleIdentifier) else { continue }
|
|
|
|
if !UserDefaults.standard.isLegacyDeactivationSupported
|
|
{
|
|
// We can't (ab)use provisioning profiles to deactivate apps,
|
|
// which means we must delete apps to free up active slots.
|
|
// So, only check if active apps are installed to prevent
|
|
// false positives when checking inactive apps.
|
|
guard app.isActive else { continue }
|
|
}
|
|
|
|
let uti = UTTypeCopyDeclaration(app.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?
|
|
if uti == nil && !legacySideloadedApps.contains(app.bundleIdentifier)
|
|
{
|
|
// This UTI is not declared by any apps, which means this app has been deleted by the user.
|
|
// This app is also not a legacy sideloaded app, so we can assume it's fine to delete it.
|
|
context.delete(app)
|
|
|
|
if var patchedApps = UserDefaults.standard.patchedApps, let index = patchedApps.firstIndex(of: app.bundleIdentifier)
|
|
{
|
|
patchedApps.remove(at: index)
|
|
UserDefaults.standard.patchedApps = patchedApps
|
|
}
|
|
}
|
|
}
|
|
|
|
try context.save()
|
|
}
|
|
catch
|
|
{
|
|
print("Error while fetching installed apps.", error)
|
|
}
|
|
#endif
|
|
|
|
do
|
|
{
|
|
let installedAppBundleIDs = InstalledApp.all(in: context).map { $0.bundleIdentifier }
|
|
|
|
let cachedAppDirectories = try FileManager.default.contentsOfDirectory(at: InstalledApp.appsDirectoryURL,
|
|
includingPropertiesForKeys: [.isDirectoryKey, .nameKey],
|
|
options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles])
|
|
for appDirectory in cachedAppDirectories
|
|
{
|
|
do
|
|
{
|
|
let resourceValues = try appDirectory.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
|
|
guard let isDirectory = resourceValues.isDirectory, let bundleID = resourceValues.name else { continue }
|
|
|
|
if isDirectory && !installedAppBundleIDs.contains(bundleID) && !self.isActivelyManagingApp(withBundleID: bundleID)
|
|
{
|
|
print("DELETING CACHED APP:", bundleID)
|
|
try FileManager.default.removeItem(at: appDirectory)
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
print("Failed to remove cached app directory.", error)
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
print("Failed to remove cached apps.", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func authenticate(presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<(ALTTeam, ALTCertificate, ALTAppleAPISession), Error>) -> Void) -> AuthenticationOperation
|
|
{
|
|
if let operation = context.authenticationOperation
|
|
{
|
|
return operation
|
|
}
|
|
|
|
let authenticationOperation = AuthenticationOperation(context: context, presentingViewController: presentingViewController)
|
|
authenticationOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success: break
|
|
}
|
|
|
|
completionHandler(result)
|
|
}
|
|
|
|
self.run([authenticationOperation], context: context)
|
|
|
|
return authenticationOperation
|
|
}
|
|
|
|
func deactivateApps(for app: ALTApplication, presentingViewController: UIViewController, completion: @escaping (Result<Void, Error>) -> Void)
|
|
{
|
|
guard let activeAppsLimit = UserDefaults.standard.activeAppsLimit else { return completion(.success(())) }
|
|
|
|
DispatchQueue.main.async {
|
|
let activeApps = InstalledApp.fetchActiveApps(in: DatabaseManager.shared.viewContext)
|
|
.filter { $0.bundleIdentifier != app.bundleIdentifier } // Don't count app towards total if it matches activating app
|
|
.sorted { ($0.name, $0.refreshedDate) < ($1.name, $1.refreshedDate) }
|
|
|
|
var title: String = NSLocalizedString("Cannot Activate More than 3 Apps", comment: "")
|
|
let message: String
|
|
|
|
if UserDefaults.standard.activeAppLimitIncludesExtensions
|
|
{
|
|
if app.appExtensions.isEmpty
|
|
{
|
|
message = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions. Please choose an app to deactivate.", comment: "")
|
|
}
|
|
else
|
|
{
|
|
title = NSLocalizedString("Cannot Activate More than 3 Apps and App Extensions", comment: "")
|
|
|
|
let appExtensionText = app.appExtensions.count == 1 ? NSLocalizedString("app extension", comment: "") : NSLocalizedString("app extensions", comment: "")
|
|
message = String(format: NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions, and “%@” contains %@ %@. Please choose an app to deactivate.", comment: ""), app.name, NSNumber(value: app.appExtensions.count), appExtensionText)
|
|
}
|
|
}
|
|
else
|
|
{
|
|
message = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps. Please choose an app to deactivate.", comment: "")
|
|
}
|
|
|
|
let activeAppsCount = activeApps.map { $0.requiredActiveSlots }.reduce(0, +)
|
|
|
|
let availableActiveApps = max(activeAppsLimit - activeAppsCount, 0)
|
|
let requiredActiveSlots = UserDefaults.standard.activeAppLimitIncludesExtensions ? (1 + app.appExtensions.count) : 1
|
|
guard requiredActiveSlots > availableActiveApps else { return completion(.success(())) }
|
|
|
|
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in
|
|
completion(.failure(OperationError.cancelled))
|
|
})
|
|
|
|
for activeApp in activeApps where activeApp.bundleIdentifier != StoreApp.altstoreAppID
|
|
{
|
|
alertController.addAction(UIAlertAction(title: activeApp.name, style: .default) { (action) in
|
|
activeApp.isActive = false
|
|
|
|
self.deactivate(activeApp, presentingViewController: presentingViewController) { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error):
|
|
activeApp.managedObjectContext?.perform {
|
|
activeApp.isActive = true
|
|
completion(.failure(error))
|
|
}
|
|
|
|
case .success:
|
|
self.deactivateApps(for: app, presentingViewController: presentingViewController, completion: completion)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
presentingViewController.present(alertController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
func clearAppCache(completion: @escaping (Result<Void, Error>) -> Void)
|
|
{
|
|
let clearAppCacheOperation = ClearAppCacheOperation()
|
|
clearAppCacheOperation.resultHandler = { result in
|
|
completion(result)
|
|
}
|
|
|
|
self.run([clearAppCacheOperation], context: nil)
|
|
}
|
|
|
|
func log(_ error: Error, operation: LoggedError.Operation, app: AppProtocol)
|
|
{
|
|
switch error {
|
|
case ~OperationError.Code.cancelled: return // Don't log cancelled events
|
|
default: break
|
|
}
|
|
// Sanitize NSError on same thread before performing background task.
|
|
let sanitizedError = (error as NSError).sanitizedForSerialization()
|
|
|
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
|
var app = app
|
|
if let managedApp = app as? NSManagedObject, let tempApp = context.object(with: managedApp.objectID) as? AppProtocol
|
|
{
|
|
app = tempApp
|
|
}
|
|
|
|
do
|
|
{
|
|
_ = LoggedError(error: sanitizedError, app: app, operation: operation, context: context)
|
|
try context.save()
|
|
}
|
|
catch let saveError
|
|
{
|
|
print("[ALTLog] Failed to log error \(sanitizedError.domain) code \(sanitizedError.code) for \(app.bundleIdentifier):", saveError)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension AppManager
|
|
{
|
|
func fetchSource(sourceURL: URL,
|
|
managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(),
|
|
dependencies: [Foundation.Operation] = [],
|
|
completionHandler: @escaping (Result<Source, Error>) -> Void)
|
|
{
|
|
let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL, managedObjectContext: managedObjectContext)
|
|
fetchSourceOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error):
|
|
completionHandler(.failure(error))
|
|
|
|
case .success(let source):
|
|
completionHandler(.success(source))
|
|
}
|
|
}
|
|
|
|
for dependency in dependencies
|
|
{
|
|
fetchSourceOperation.addDependency(dependency)
|
|
}
|
|
|
|
self.run([fetchSourceOperation], context: nil)
|
|
}
|
|
|
|
func fetchSources(completionHandler: @escaping (Result<(Set<Source>, NSManagedObjectContext), FetchSourcesError>) -> Void)
|
|
{
|
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
let sources = Source.all(in: context)
|
|
guard !sources.isEmpty else { return completionHandler(.failure(.init(OperationError.noSources))) }
|
|
|
|
let dispatchGroup = DispatchGroup()
|
|
var fetchedSources = Set<Source>()
|
|
|
|
var errors = [Source: Error]()
|
|
|
|
let managedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
|
|
let operations = sources.map { (source) -> FetchSourceOperation in
|
|
dispatchGroup.enter()
|
|
|
|
let fetchSourceOperation = FetchSourceOperation(sourceURL: source.sourceURL, managedObjectContext: managedObjectContext)
|
|
fetchSourceOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .success(let source): fetchedSources.insert(source)
|
|
case .failure(let error):
|
|
let source = managedObjectContext.object(with: source.objectID) as! Source
|
|
source.error = (error as NSError).sanitizedForSerialization()
|
|
errors[source] = error
|
|
}
|
|
|
|
dispatchGroup.leave()
|
|
}
|
|
|
|
return fetchSourceOperation
|
|
}
|
|
|
|
dispatchGroup.notify(queue: .global()) {
|
|
managedObjectContext.perform {
|
|
if !errors.isEmpty
|
|
{
|
|
let sources = Set(sources.compactMap { managedObjectContext.object(with: $0.objectID) as? Source })
|
|
completionHandler(.failure(.init(sources: sources, errors: errors, context: managedObjectContext)))
|
|
}
|
|
else
|
|
{
|
|
completionHandler(.success((fetchedSources, managedObjectContext)))
|
|
}
|
|
}
|
|
|
|
NotificationCenter.default.post(name: AppManager.didFetchSourceNotification, object: self)
|
|
}
|
|
|
|
self.run(operations, context: nil)
|
|
}
|
|
}
|
|
|
|
func fetchAppIDs(completionHandler: @escaping (Result<([AppID], NSManagedObjectContext), Error>) -> Void)
|
|
{
|
|
let authenticationOperation = self.authenticate(presentingViewController: nil) { (result) in
|
|
// result contains name, email, auth token, OTP and other possibly personal/account specific info. we don't want this logged
|
|
//print("Authenticated for fetching App IDs with result:", result)
|
|
}
|
|
|
|
let fetchAppIDsOperation = FetchAppIDsOperation(context: authenticationOperation.context)
|
|
fetchAppIDsOperation.resultHandler = completionHandler
|
|
fetchAppIDsOperation.addDependency(authenticationOperation)
|
|
self.run([fetchAppIDsOperation], context: authenticationOperation.context)
|
|
}
|
|
|
|
@discardableResult
|
|
func fetchTrustedSources(completionHandler: @escaping (Result<[FetchTrustedSourcesOperation.TrustedSource], Error>) -> Void) -> FetchTrustedSourcesOperation
|
|
{
|
|
let fetchTrustedSourcesOperation = FetchTrustedSourcesOperation()
|
|
fetchTrustedSourcesOperation.resultHandler = completionHandler
|
|
self.run([fetchTrustedSourcesOperation], context: nil)
|
|
|
|
return fetchTrustedSourcesOperation
|
|
}
|
|
|
|
func updatePatronsIfNeeded()
|
|
{
|
|
guard self.operationQueue.operations.allSatisfy({ !($0 is UpdatePatronsOperation) }) else {
|
|
// There's already an UpdatePatronsOperation running.
|
|
return
|
|
}
|
|
|
|
self.updatePatronsResult = nil
|
|
|
|
let updatePatronsOperation = UpdatePatronsOperation()
|
|
updatePatronsOperation.resultHandler = { (result) in
|
|
do
|
|
{
|
|
try result.get()
|
|
self.updatePatronsResult = .success(())
|
|
}
|
|
catch
|
|
{
|
|
print("Error updating Friend Zone Patrons:", error)
|
|
self.updatePatronsResult = .failure(error)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: AppManager.didUpdatePatronsNotification, object: self)
|
|
}
|
|
|
|
self.run([updatePatronsOperation], context: nil)
|
|
}
|
|
|
|
@discardableResult
|
|
func install<T: AppProtocol>(_ app: T, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> RefreshGroup
|
|
{
|
|
let group = RefreshGroup(context: context)
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw context.error ?? OperationError.unknown() }
|
|
completionHandler(result)
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
let operation = AppOperation.install(app)
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
|
|
return group
|
|
}
|
|
|
|
@discardableResult
|
|
func update(_ app: InstalledApp, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
guard let storeApp = app.storeApp else {
|
|
completionHandler(.failure(OperationError.appNotFound(name: app.name)))
|
|
return Progress.discreteProgress(totalUnitCount: 1)
|
|
}
|
|
|
|
let group = RefreshGroup(context: context)
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown() }
|
|
completionHandler(result)
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
let operation = AppOperation.update(storeApp)
|
|
assert(operation.app as AnyObject === storeApp) // Make sure we never accidentally "update" to already installed app.
|
|
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
|
|
return group.progress
|
|
}
|
|
|
|
@discardableResult
|
|
func refresh(_ installedApps: [InstalledApp], presentingViewController: UIViewController?, group: RefreshGroup? = nil) -> RefreshGroup
|
|
{
|
|
let group = group ?? RefreshGroup()
|
|
|
|
let operations = installedApps.map { AppOperation.refresh($0) }
|
|
return self.perform(operations, presentingViewController: presentingViewController, group: group)
|
|
}
|
|
|
|
func activate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
|
{
|
|
let group = RefreshGroup()
|
|
|
|
let operation = AppOperation.activate(installedApp)
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown() }
|
|
|
|
let installedApp = try result.get()
|
|
assert(installedApp.managedObjectContext != nil)
|
|
|
|
installedApp.managedObjectContext?.perform {
|
|
installedApp.isActive = true
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func deactivate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
|
{
|
|
if UserDefaults.standard.isLegacyDeactivationSupported
|
|
{
|
|
// Normally we pipe everything down into perform(),
|
|
// but the pre-iOS 13.5 deactivation method doesn't require
|
|
// authentication, so we keep it separate.
|
|
let context = OperationContext()
|
|
|
|
let deactivateAppOperation = DeactivateAppOperation(app: installedApp, context: context)
|
|
deactivateAppOperation.resultHandler = { (result) in
|
|
completionHandler(result)
|
|
}
|
|
|
|
self.run([deactivateAppOperation], context: context, requiresSerialQueue: true)
|
|
}
|
|
else
|
|
{
|
|
let group = RefreshGroup()
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown() }
|
|
|
|
let installedApp = try result.get()
|
|
assert(installedApp.managedObjectContext != nil)
|
|
|
|
installedApp.managedObjectContext?.perform {
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
let operation = AppOperation.deactivate(installedApp)
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
}
|
|
}
|
|
|
|
func backup(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
|
{
|
|
let group = RefreshGroup()
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown() }
|
|
|
|
let installedApp = try result.get()
|
|
assert(installedApp.managedObjectContext != nil)
|
|
|
|
installedApp.managedObjectContext?.perform {
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
let operation = AppOperation.backup(installedApp)
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
}
|
|
|
|
func restore(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
|
{
|
|
let group = RefreshGroup()
|
|
group.completionHandler = { (results) in
|
|
do
|
|
{
|
|
guard let result = results.values.first else { throw OperationError.unknown() }
|
|
|
|
let installedApp = try result.get()
|
|
assert(installedApp.managedObjectContext != nil)
|
|
|
|
installedApp.managedObjectContext?.perform {
|
|
installedApp.isActive = true
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
let operation = AppOperation.restore(installedApp)
|
|
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
|
}
|
|
|
|
func remove(_ installedApp: InstalledApp, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
|
{
|
|
let authenticationContext = AuthenticatedOperationContext()
|
|
let appContext = InstallAppOperationContext(bundleIdentifier: installedApp.bundleIdentifier, authenticatedContext: authenticationContext)
|
|
appContext.installedApp = installedApp
|
|
|
|
let removeAppOperation = RSTAsyncBlockOperation { (operation) in
|
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
|
let installedApp = context.object(with: installedApp.objectID) as! InstalledApp
|
|
context.delete(installedApp)
|
|
|
|
do { try context.save() }
|
|
catch { appContext.error = error }
|
|
|
|
operation.finish()
|
|
}
|
|
}
|
|
|
|
let removeAppBackupOperation = RemoveAppBackupOperation(context: appContext)
|
|
removeAppBackupOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .success: break
|
|
case .failure(let error): print("Failed to remove app backup.", error)
|
|
}
|
|
|
|
// Throw the error from removeAppOperation,
|
|
// since that's the error we really care about.
|
|
if let error = appContext.error
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
else
|
|
{
|
|
completionHandler(.success(()))
|
|
}
|
|
}
|
|
removeAppBackupOperation.addDependency(removeAppOperation)
|
|
|
|
self.run([removeAppOperation, removeAppBackupOperation], context: authenticationContext)
|
|
}
|
|
|
|
@available(iOS 14, *)
|
|
func enableJIT(for installedApp: InstalledApp, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
|
{
|
|
final class Context: OperationContext, EnableJITContext
|
|
{
|
|
var installedApp: InstalledApp?
|
|
}
|
|
|
|
let appName = installedApp.name
|
|
let context = Context()
|
|
context.installedApp = installedApp
|
|
|
|
|
|
let enableJITOperation = EnableJITOperation(context: context)
|
|
enableJITOperation.resultHandler = { (result) in
|
|
switch result {
|
|
case .success: completionHandler(.success(()))
|
|
case .failure(let nsError as NSError):
|
|
let localizedTitle = String(format: NSLocalizedString("Failed to enable JIT for %@", comment: ""), appName)
|
|
let error = nsError.withLocalizedTitle(localizedTitle)
|
|
self.log(error, operation: .enableJIT, app: installedApp)
|
|
}
|
|
}
|
|
|
|
self.run([enableJITOperation], context: context, requiresSerialQueue: true)
|
|
}
|
|
|
|
@available(iOS 14.0, *)
|
|
func patch(resignedApp: ALTApplication, presentingViewController: UIViewController, context authContext: AuthenticatedOperationContext, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> PatchAppOperation
|
|
{
|
|
final class Context: InstallAppOperationContext, PatchAppContext
|
|
{
|
|
}
|
|
|
|
guard let originalBundleID = resignedApp.bundle.infoDictionary?[Bundle.Info.altBundleID] as? String else {
|
|
let context = Context(bundleIdentifier: resignedApp.bundleIdentifier, authenticatedContext: authContext)
|
|
completionHandler(.failure(OperationError.invalidApp))
|
|
|
|
return PatchAppOperation(context: context)
|
|
}
|
|
|
|
let context = Context(bundleIdentifier: originalBundleID, authenticatedContext: authContext)
|
|
context.resignedApp = resignedApp
|
|
|
|
let patchAppOperation = PatchAppOperation(context: context)
|
|
let sendAppOperation = SendAppOperation(context: context)
|
|
let installOperation = InstallAppOperation(context: context)
|
|
|
|
let installationProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
installationProgress.addChild(sendAppOperation.progress, withPendingUnitCount: 40)
|
|
installationProgress.addChild(installOperation.progress, withPendingUnitCount: 60)
|
|
|
|
/* Patch */
|
|
patchAppOperation.resultHandler = { [weak patchAppOperation] (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success:
|
|
// Kinda hacky that we're calling patchAppOperation's progressHandler manually, but YOLO.
|
|
patchAppOperation?.progressHandler?(installationProgress, NSLocalizedString("Patching placeholder app...", comment: ""))
|
|
}
|
|
}
|
|
|
|
/* Send */
|
|
sendAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(_): print("App sent over AFC")
|
|
}
|
|
}
|
|
sendAppOperation.addDependency(patchAppOperation)
|
|
|
|
|
|
/* Install */
|
|
installOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): completionHandler(.failure(error))
|
|
case .success(let installedApp): completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
installOperation.addDependency(sendAppOperation)
|
|
|
|
self.run([patchAppOperation, sendAppOperation, installOperation], context: context.authenticatedContext)
|
|
return patchAppOperation
|
|
}
|
|
|
|
func installationProgress(for app: AppProtocol) -> Progress?
|
|
{
|
|
let progress = self.installationProgress[app.bundleIdentifier]
|
|
return progress
|
|
}
|
|
|
|
func refreshProgress(for app: AppProtocol) -> Progress?
|
|
{
|
|
let progress = self.refreshProgress[app.bundleIdentifier]
|
|
return progress
|
|
}
|
|
|
|
func isActivelyManagingApp(withBundleID bundleID: String) -> Bool
|
|
{
|
|
let isActivelyManaging = self.installationProgress.keys.contains(bundleID) || self.refreshProgress.keys.contains(bundleID)
|
|
return isActivelyManaging
|
|
}
|
|
}
|
|
|
|
extension AppManager
|
|
{
|
|
@discardableResult
|
|
func backgroundRefresh(_ installedApps: [InstalledApp], presentsNotifications: Bool = false, completionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void) -> BackgroundRefreshAppsOperation
|
|
{
|
|
let backgroundRefreshAppsOperation = BackgroundRefreshAppsOperation(installedApps: installedApps)
|
|
backgroundRefreshAppsOperation.resultHandler = completionHandler
|
|
backgroundRefreshAppsOperation.presentsFinishedNotification = presentsNotifications
|
|
self.run([backgroundRefreshAppsOperation], context: nil)
|
|
|
|
return backgroundRefreshAppsOperation
|
|
}
|
|
}
|
|
|
|
private extension AppManager
|
|
{
|
|
enum AppOperation
|
|
{
|
|
case install(AppProtocol)
|
|
case update(AppProtocol)
|
|
case refresh(InstalledApp)
|
|
case activate(InstalledApp)
|
|
case deactivate(InstalledApp)
|
|
case backup(InstalledApp)
|
|
case restore(InstalledApp)
|
|
|
|
var app: AppProtocol {
|
|
switch self
|
|
{
|
|
case .install(let app), .update(let app), .refresh(let app as AppProtocol),
|
|
.activate(let app as AppProtocol), .deactivate(let app as AppProtocol),
|
|
.backup(let app as AppProtocol), .restore(let app as AppProtocol):
|
|
return app
|
|
}
|
|
}
|
|
|
|
var bundleIdentifier: String {
|
|
var bundleIdentifier: String!
|
|
|
|
if let context = (self.app as? NSManagedObject)?.managedObjectContext
|
|
{
|
|
context.performAndWait { bundleIdentifier = self.app.bundleIdentifier }
|
|
}
|
|
else
|
|
{
|
|
bundleIdentifier = self.app.bundleIdentifier
|
|
}
|
|
|
|
return bundleIdentifier
|
|
}
|
|
|
|
var loggedErrorOperation: LoggedError.Operation {
|
|
switch self {
|
|
case .install: return .install
|
|
case .update: return .update
|
|
case .refresh: return .refresh
|
|
case .activate: return .activate
|
|
case .deactivate: return .deactivate
|
|
case .backup: return .backup
|
|
case .restore: return .restore
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) -> RefreshGroup
|
|
{
|
|
let operations = operations.filter { self.progress(for: $0) == nil || self.progress(for: $0)?.isCancelled == true }
|
|
|
|
for operation in operations
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
self.set(progress, for: operation)
|
|
}
|
|
|
|
if let viewController = presentingViewController
|
|
{
|
|
group.context.presentingViewController = viewController
|
|
}
|
|
|
|
/* Authenticate (if necessary) */
|
|
var authenticationOperation: AuthenticationOperation?
|
|
if group.context.session == nil
|
|
{
|
|
authenticationOperation = self.authenticate(presentingViewController: presentingViewController, context: group.context) { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): group.context.error = error
|
|
case .success: break
|
|
}
|
|
}
|
|
}
|
|
|
|
func performAppOperations()
|
|
{
|
|
for operation in operations
|
|
{
|
|
let progress = self.progress(for: operation)
|
|
|
|
if let progress = progress
|
|
{
|
|
group.progress.totalUnitCount += 1
|
|
group.progress.addChild(progress, withPendingUnitCount: 1)
|
|
|
|
if group.context.session != nil
|
|
{
|
|
// Finished authenticating, so increase completed unit count.
|
|
progress.completedUnitCount += 20
|
|
}
|
|
}
|
|
|
|
switch operation
|
|
{
|
|
case .install(let app), .update(let app):
|
|
let installProgress = self._install(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(installProgress, withPendingUnitCount: 80)
|
|
|
|
case .activate(let app) where UserDefaults.standard.isLegacyDeactivationSupported: fallthrough
|
|
case .refresh(let app):
|
|
// Check if backup app is installed in place of real app.
|
|
let uti = UTTypeCopyDeclaration(app.installedBackupAppUTI as CFString)?.takeRetainedValue() as NSDictionary?
|
|
|
|
if app.certificateSerialNumber != group.context.certificate?.serialNumber ||
|
|
uti != nil ||
|
|
app.needsResign ||
|
|
// We need to reinstall ourselves on refresh to ensure the new provisioning profile is used
|
|
app.bundleIdentifier == StoreApp.altstoreAppID
|
|
{
|
|
// Resign app instead of just refreshing profiles because either:
|
|
// * Refreshing using different certificate
|
|
// * Backup app is still installed
|
|
// * App explicitly needs resigning
|
|
// * Device is jailbroken and using AltDaemon on iOS 14.0 or later (b/c refreshing with provisioning profiles is broken)
|
|
|
|
let installProgress = self._install(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(installProgress, withPendingUnitCount: 80)
|
|
}
|
|
else
|
|
{
|
|
// Refreshing with same certificate as last time, and backup app isn't still installed,
|
|
// so we can just refresh provisioning profiles.
|
|
|
|
let refreshProgress = self._refresh(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(refreshProgress, withPendingUnitCount: 80)
|
|
}
|
|
|
|
case .activate(let app):
|
|
let activateProgress = self._activate(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(activateProgress, withPendingUnitCount: 80)
|
|
|
|
case .deactivate(let app):
|
|
let deactivateProgress = self._deactivate(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(deactivateProgress, withPendingUnitCount: 80)
|
|
|
|
case .backup(let app):
|
|
let backupProgress = self._backup(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(backupProgress, withPendingUnitCount: 80)
|
|
|
|
case .restore(let app):
|
|
// Restoring, which is effectively just activating an app.
|
|
|
|
let activateProgress = self._activate(app, operation: operation, group: group) { (result) in
|
|
self.finish(operation, result: result, group: group, progress: progress)
|
|
}
|
|
progress?.addChild(activateProgress, withPendingUnitCount: 80)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let authenticationOperation = authenticationOperation
|
|
{
|
|
let awaitAuthenticationOperation = BlockOperation {
|
|
if let managedObjectContext = operations.lazy.compactMap({ ($0.app as? NSManagedObject)?.managedObjectContext }).first
|
|
{
|
|
managedObjectContext.perform { performAppOperations() }
|
|
}
|
|
else
|
|
{
|
|
performAppOperations()
|
|
}
|
|
}
|
|
awaitAuthenticationOperation.addDependency(authenticationOperation)
|
|
self.run([awaitAuthenticationOperation], context: group.context, requiresSerialQueue: true)
|
|
}
|
|
else
|
|
{
|
|
DispatchQueue.main.schedule {
|
|
UIApplication.shared.isIdleTimerDisabled = UserDefaults.standard.isIdleTimeoutDisableEnabled
|
|
}
|
|
performAppOperations()
|
|
DispatchQueue.main.schedule {
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
}
|
|
}
|
|
|
|
return group
|
|
}
|
|
|
|
private func _install(_ app: AppProtocol, operation appOperation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext? = nil, additionalEntitlements: [ALTEntitlement: Any]? = nil, cacheApp: Bool = true, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
let context = context ?? InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
assert(context.authenticatedContext === group.context)
|
|
|
|
context.beginInstallationHandler = { (installedApp) in
|
|
switch appOperation
|
|
{
|
|
case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID:
|
|
// AltStore will quit before installation finishes,
|
|
// so assume if we get this far the update will finish successfully.
|
|
let event = AnalyticsManager.Event.updatedApp(installedApp)
|
|
AnalyticsManager.shared.trackEvent(event)
|
|
|
|
default: break
|
|
}
|
|
|
|
group.beginInstallationHandler?(installedApp)
|
|
}
|
|
|
|
var downloadingApp = app
|
|
|
|
if let installedApp = app as? InstalledApp
|
|
{
|
|
if let storeApp = installedApp.storeApp, !FileManager.default.fileExists(atPath: installedApp.fileURL.path)
|
|
{
|
|
// Cached app has been deleted, so we need to redownload it.
|
|
downloadingApp = storeApp
|
|
}
|
|
|
|
if installedApp.hasAlternateIcon
|
|
{
|
|
context.alternateIconURL = installedApp.alternateIconURL
|
|
}
|
|
}
|
|
|
|
let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app")
|
|
|
|
/* Download */
|
|
let downloadOperation = DownloadAppOperation(app: downloadingApp, destinationURL: downloadedAppURL, context: context)
|
|
downloadOperation.resultHandler = { (result) in
|
|
do
|
|
{
|
|
let app = try result.get()
|
|
context.app = app
|
|
|
|
if cacheApp
|
|
{
|
|
try FileManager.default.copyItem(at: app.fileURL, to: InstalledApp.fileURL(for: app), shouldReplace: true)
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
context.error = error
|
|
}
|
|
}
|
|
progress.addChild(downloadOperation.progress, withPendingUnitCount: 25)
|
|
|
|
|
|
/* Verify App */
|
|
let verifyOperation = VerifyAppOperation(context: context)
|
|
verifyOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success: break
|
|
}
|
|
}
|
|
verifyOperation.addDependency(downloadOperation)
|
|
|
|
|
|
/* Refresh Anisette Data */
|
|
let refreshAnisetteDataOperation = FetchAnisetteDataOperation(context: group.context)
|
|
refreshAnisetteDataOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let anisetteData): group.context.session?.anisetteData = anisetteData
|
|
}
|
|
}
|
|
refreshAnisetteDataOperation.addDependency(verifyOperation)
|
|
|
|
|
|
/* Fetch Provisioning Profiles */
|
|
let fetchProvisioningProfilesOperation = FetchProvisioningProfilesOperation(context: context)
|
|
fetchProvisioningProfilesOperation.additionalEntitlements = additionalEntitlements
|
|
fetchProvisioningProfilesOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let provisioningProfiles):
|
|
context.provisioningProfiles = provisioningProfiles
|
|
print("PROVISIONING PROFILES \(context.provisioningProfiles)")
|
|
}
|
|
}
|
|
fetchProvisioningProfilesOperation.addDependency(refreshAnisetteDataOperation)
|
|
progress.addChild(fetchProvisioningProfilesOperation.progress, withPendingUnitCount: 5)
|
|
|
|
|
|
/* Deactivate Apps (if necessary) */
|
|
let deactivateAppsOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
do
|
|
{
|
|
// Only attempt to deactivate apps if we're installing a new app.
|
|
// We handle deactivating apps separately when activating an app.
|
|
guard case .install = appOperation else {
|
|
operation.finish()
|
|
return
|
|
}
|
|
|
|
if let error = context.error
|
|
{
|
|
throw error
|
|
}
|
|
|
|
guard let profiles = context.provisioningProfiles else { throw OperationError.invalidParameters }
|
|
if !profiles.contains(where: { $1.isFreeProvisioningProfile == true }) {
|
|
operation.finish()
|
|
return
|
|
}
|
|
|
|
guard let app = context.app, let presentingViewController = context.authenticatedContext.presentingViewController else { throw OperationError.invalidParameters }
|
|
|
|
self?.deactivateApps(for: app, presentingViewController: presentingViewController) { result in
|
|
switch result
|
|
{
|
|
case .failure(let error): group.context.error = error
|
|
case .success: break
|
|
}
|
|
|
|
operation.finish()
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
group.context.error = error
|
|
operation.finish()
|
|
}
|
|
}
|
|
deactivateAppsOperation.addDependency(fetchProvisioningProfilesOperation)
|
|
|
|
|
|
/* Patch App */
|
|
let patchAppOperation = RSTAsyncBlockOperation { operation in
|
|
do
|
|
{
|
|
// Only attempt to patch app if we're installing a new app, not refreshing existing app.
|
|
// Post reboot, we install the correct jailbreak app by refreshing the patched app,
|
|
// so this check avoids infinite recursion.
|
|
guard case .install = appOperation else {
|
|
operation.finish()
|
|
return
|
|
}
|
|
|
|
guard let presentingViewController = context.presentingViewController, #available(iOS 14, *) else { return operation.finish() }
|
|
|
|
if let error = context.error
|
|
{
|
|
throw error
|
|
}
|
|
|
|
guard let app = context.app else { throw OperationError.invalidParameters }
|
|
|
|
guard let isUntetherRequired = app.bundle.infoDictionary?[Bundle.Info.untetherRequired] as? Bool,
|
|
let minimumiOSVersionString = app.bundle.infoDictionary?[Bundle.Info.untetherMinimumiOSVersion] as? String,
|
|
let maximumiOSVersionString = app.bundle.infoDictionary?[Bundle.Info.untetherMaximumiOSVersion] as? String,
|
|
case let minimumiOSVersion = OperatingSystemVersion(string: minimumiOSVersionString),
|
|
case let maximumiOSVersion = OperatingSystemVersion(string: maximumiOSVersionString)
|
|
else { return operation.finish() }
|
|
|
|
let iOSVersion = ProcessInfo.processInfo.operatingSystemVersion
|
|
let iOSVersionSupported = ProcessInfo.processInfo.isOperatingSystemAtLeast(minimumiOSVersion) &&
|
|
(!ProcessInfo.processInfo.isOperatingSystemAtLeast(maximumiOSVersion) || maximumiOSVersion == iOSVersion)
|
|
|
|
guard isUntetherRequired, iOSVersionSupported, UIDevice.current.supportsFugu14 else { return operation.finish() }
|
|
|
|
guard let patchAppLink = app.bundle.infoDictionary?[Bundle.Info.untetherURL] as? String,
|
|
let patchAppURL = URL(string: patchAppLink)
|
|
else { throw OperationError.invalidApp }
|
|
|
|
let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL)
|
|
|
|
DispatchQueue.main.async {
|
|
let storyboard = UIStoryboard(name: "PatchApp", bundle: nil)
|
|
let navigationController = storyboard.instantiateInitialViewController() as! UINavigationController
|
|
|
|
let patchViewController = navigationController.topViewController as! PatchViewController
|
|
patchViewController.patchApp = patchApp
|
|
patchViewController.completionHandler = { [weak presentingViewController] (result) in
|
|
switch result
|
|
{
|
|
case .failure(OperationError.cancelled): break // Ignore
|
|
case .failure(let error): group.context.error = error
|
|
case .success: group.context.error = OperationError.cancelled
|
|
}
|
|
|
|
operation.finish()
|
|
|
|
DispatchQueue.main.async {
|
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
|
}
|
|
}
|
|
presentingViewController.present(navigationController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
group.context.error = error
|
|
operation.finish()
|
|
}
|
|
}
|
|
patchAppOperation.addDependency(deactivateAppsOperation)
|
|
|
|
|
|
/* Resign */
|
|
let resignAppOperation = ResignAppOperation(context: context)
|
|
resignAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let resignedApp): context.resignedApp = resignedApp
|
|
}
|
|
}
|
|
resignAppOperation.addDependency(patchAppOperation)
|
|
progress.addChild(resignAppOperation.progress, withPendingUnitCount: 20)
|
|
|
|
|
|
/* Send */
|
|
let sendAppOperation = SendAppOperation(context: context)
|
|
sendAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(_): print("App reported as installed")
|
|
}
|
|
}
|
|
sendAppOperation.addDependency(resignAppOperation)
|
|
progress.addChild(sendAppOperation.progress, withPendingUnitCount: 20)
|
|
|
|
|
|
/* Install */
|
|
let installOperation = InstallAppOperation(context: context)
|
|
installOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): completionHandler(.failure(error))
|
|
case .success(let installedApp):
|
|
context.installedApp = installedApp
|
|
|
|
if let app = app as? StoreApp, let storeApp = installedApp.managedObjectContext?.object(with: app.objectID) as? StoreApp
|
|
{
|
|
installedApp.storeApp = storeApp
|
|
}
|
|
|
|
if let index = UserDefaults.standard.legacySideloadedApps?.firstIndex(of: installedApp.bundleIdentifier)
|
|
{
|
|
// No longer a legacy sideloaded app, so remove it from cached list.
|
|
UserDefaults.standard.legacySideloadedApps?.remove(at: index)
|
|
}
|
|
|
|
completionHandler(.success(installedApp))
|
|
}
|
|
}
|
|
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
|
|
installOperation.addDependency(sendAppOperation)
|
|
|
|
let operations = [downloadOperation, verifyOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, deactivateAppsOperation, patchAppOperation, resignAppOperation, sendAppOperation, installOperation]
|
|
group.add(operations)
|
|
self.run(operations, context: group.context)
|
|
|
|
return progress
|
|
}
|
|
|
|
private func _refresh(_ app: InstalledApp, operation: AppOperation, group: RefreshGroup, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
let context = AppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
context.app = ALTApplication(fileURL: app.fileURL)
|
|
|
|
/* Fetch Provisioning Profiles */
|
|
let fetchProvisioningProfilesOperation = FetchProvisioningProfilesOperation(context: context)
|
|
fetchProvisioningProfilesOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success(let provisioningProfiles): context.provisioningProfiles = provisioningProfiles
|
|
}
|
|
}
|
|
progress.addChild(fetchProvisioningProfilesOperation.progress, withPendingUnitCount: 60)
|
|
|
|
/* Refresh */
|
|
let refreshAppOperation = RefreshAppOperation(context: context)
|
|
refreshAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .success(let installedApp):
|
|
completionHandler(.success(installedApp))
|
|
|
|
case .failure(MinimuxerError.ProfileInstall):
|
|
completionHandler(.failure(OperationError.noWiFi))
|
|
|
|
case .failure(ALTServerError.unknownRequest), .failure(OperationError.appNotFound(name: app.name)):
|
|
// Fall back to installation if AltServer doesn't support newer provisioning profile requests,
|
|
// OR if the cached app could not be found and we may need to redownload it.
|
|
app.managedObjectContext?.performAndWait { // Must performAndWait to ensure we add operations before we return.
|
|
if minimuxer.ready() {
|
|
let installProgress = self._install(app, operation: operation, group: group) { (result) in
|
|
completionHandler(result)
|
|
}
|
|
progress.addChild(installProgress, withPendingUnitCount: 40)
|
|
} else {
|
|
completionHandler(.failure(OperationError.noWiFi))
|
|
}
|
|
}
|
|
|
|
case .failure(let error):
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
progress.addChild(refreshAppOperation.progress, withPendingUnitCount: 40)
|
|
refreshAppOperation.addDependency(fetchProvisioningProfilesOperation)
|
|
|
|
let operations = [fetchProvisioningProfilesOperation, refreshAppOperation]
|
|
group.add(operations)
|
|
self.run(operations, context: group.context)
|
|
|
|
return progress
|
|
}
|
|
|
|
private func _activate(_ app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
let restoreContext = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
let appContext = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
|
|
let installBackupAppProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let installBackupAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
app.managedObjectContext?.perform {
|
|
guard let self = self else { return }
|
|
|
|
let progress = self._installBackupApp(for: app, operation: appOperation, group: group, context: restoreContext) { (result) in
|
|
switch result
|
|
{
|
|
case .success(let installedApp): restoreContext.installedApp = installedApp
|
|
case .failure(let error):
|
|
restoreContext.error = error
|
|
appContext.error = error
|
|
}
|
|
|
|
operation.finish()
|
|
}
|
|
installBackupAppProgress.addChild(progress, withPendingUnitCount: 100)
|
|
}
|
|
}
|
|
progress.addChild(installBackupAppProgress, withPendingUnitCount: 30)
|
|
|
|
let restoreAppOperation = BackupAppOperation(action: .restore, context: restoreContext)
|
|
restoreAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .success: break
|
|
case .failure(let error):
|
|
restoreContext.error = error
|
|
appContext.error = error
|
|
}
|
|
}
|
|
restoreAppOperation.addDependency(installBackupAppOperation)
|
|
progress.addChild(restoreAppOperation.progress, withPendingUnitCount: 15)
|
|
|
|
let installAppProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let installAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
app.managedObjectContext?.perform {
|
|
guard let self = self else { return }
|
|
|
|
let progress = self._install(app, operation: appOperation, group: group, context: appContext) { (result) in
|
|
switch result
|
|
{
|
|
case .success(let installedApp): appContext.installedApp = installedApp
|
|
case .failure(let error): appContext.error = error
|
|
}
|
|
|
|
operation.finish()
|
|
}
|
|
installAppProgress.addChild(progress, withPendingUnitCount: 100)
|
|
}
|
|
}
|
|
installAppOperation.addDependency(restoreAppOperation)
|
|
progress.addChild(installAppProgress, withPendingUnitCount: 50)
|
|
|
|
let cleanUpProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let cleanUpOperation = RSTAsyncBlockOperation { (operation) in
|
|
do
|
|
{
|
|
let installedApp = try Result(appContext.installedApp, appContext.error).get()
|
|
|
|
var result: Result<Void, Error>!
|
|
installedApp.managedObjectContext?.performAndWait {
|
|
result = Result { try installedApp.managedObjectContext?.save() }
|
|
}
|
|
try result.get()
|
|
|
|
// Successfully saved, so _now_ we can remove backup.
|
|
|
|
let removeAppBackupOperation = RemoveAppBackupOperation(context: appContext)
|
|
removeAppBackupOperation.resultHandler = { (result) in
|
|
installedApp.managedObjectContext?.perform {
|
|
switch result
|
|
{
|
|
case .failure(let error):
|
|
// Don't report error, since it doesn't really matter.
|
|
print("Failed to delete app backup.", error)
|
|
|
|
case .success: break
|
|
}
|
|
|
|
completionHandler(.success(installedApp))
|
|
operation.finish()
|
|
}
|
|
}
|
|
cleanUpProgress.addChild(removeAppBackupOperation.progress, withPendingUnitCount: 100)
|
|
|
|
group.add([removeAppBackupOperation])
|
|
self.run([removeAppBackupOperation], context: group.context)
|
|
}
|
|
catch let error where restoreContext.installedApp != nil
|
|
{
|
|
// Activation failed, but restore app was installed, so remove the app.
|
|
|
|
// Remove error so operation doesn't quit early,
|
|
restoreContext.error = nil
|
|
|
|
let removeAppOperation = RemoveAppOperation(context: restoreContext)
|
|
removeAppOperation.resultHandler = { (result) in
|
|
completionHandler(.failure(error))
|
|
operation.finish()
|
|
}
|
|
cleanUpProgress.addChild(removeAppOperation.progress, withPendingUnitCount: 100)
|
|
|
|
group.add([removeAppOperation])
|
|
self.run([removeAppOperation], context: group.context)
|
|
}
|
|
catch
|
|
{
|
|
// Activation failed.
|
|
completionHandler(.failure(error))
|
|
operation.finish()
|
|
}
|
|
}
|
|
cleanUpOperation.addDependency(installAppOperation)
|
|
progress.addChild(cleanUpProgress, withPendingUnitCount: 5)
|
|
|
|
group.add([installBackupAppOperation, restoreAppOperation, installAppOperation, cleanUpOperation])
|
|
self.run([installBackupAppOperation, installAppOperation, restoreAppOperation, cleanUpOperation], context: group.context)
|
|
|
|
return progress
|
|
}
|
|
|
|
private func _deactivate(_ app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
|
|
let installBackupAppProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let installBackupAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
app.managedObjectContext?.perform {
|
|
guard let self = self else { return }
|
|
|
|
let progress = self._installBackupApp(for: app, operation: appOperation, group: group, context: context) { (result) in
|
|
switch result
|
|
{
|
|
case .success(let installedApp): context.installedApp = installedApp
|
|
case .failure(let error): context.error = error
|
|
}
|
|
|
|
operation.finish()
|
|
}
|
|
installBackupAppProgress.addChild(progress, withPendingUnitCount: 100)
|
|
}
|
|
}
|
|
progress.addChild(installBackupAppProgress, withPendingUnitCount: 70)
|
|
|
|
let backupAppOperation = BackupAppOperation(action: .backup, context: context)
|
|
backupAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .failure(let error): context.error = error
|
|
case .success: break
|
|
}
|
|
}
|
|
backupAppOperation.addDependency(installBackupAppOperation)
|
|
progress.addChild(backupAppOperation.progress, withPendingUnitCount: 15)
|
|
|
|
let removeAppOperation = RemoveAppOperation(context: context)
|
|
removeAppOperation.resultHandler = { (result) in
|
|
completionHandler(result)
|
|
}
|
|
removeAppOperation.addDependency(backupAppOperation)
|
|
progress.addChild(removeAppOperation.progress, withPendingUnitCount: 15)
|
|
|
|
group.add([installBackupAppOperation, backupAppOperation, removeAppOperation])
|
|
self.run([installBackupAppOperation, backupAppOperation, removeAppOperation], context: group.context)
|
|
|
|
return progress
|
|
}
|
|
|
|
private func _backup(_ app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
let restoreContext = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
let appContext = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
|
|
|
let installBackupAppProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let installBackupAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
app.managedObjectContext?.perform {
|
|
guard let self = self else { return }
|
|
|
|
let progress = self._installBackupApp(for: app, operation: appOperation, group: group, context: restoreContext) { (result) in
|
|
switch result
|
|
{
|
|
case .success(let installedApp): restoreContext.installedApp = installedApp
|
|
case .failure(let error):
|
|
restoreContext.error = error
|
|
appContext.error = error
|
|
}
|
|
|
|
operation.finish()
|
|
}
|
|
installBackupAppProgress.addChild(progress, withPendingUnitCount: 100)
|
|
}
|
|
}
|
|
progress.addChild(installBackupAppProgress, withPendingUnitCount: 30)
|
|
|
|
let backupAppOperation = BackupAppOperation(action: .backup, context: restoreContext)
|
|
backupAppOperation.resultHandler = { (result) in
|
|
switch result
|
|
{
|
|
case .success: break
|
|
case .failure(let error):
|
|
restoreContext.error = error
|
|
appContext.error = error
|
|
}
|
|
}
|
|
backupAppOperation.addDependency(installBackupAppOperation)
|
|
progress.addChild(backupAppOperation.progress, withPendingUnitCount: 15)
|
|
|
|
let installAppProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let installAppOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
app.managedObjectContext?.perform {
|
|
guard let self = self else { return }
|
|
|
|
let progress = self._install(app, operation: appOperation, group: group, context: appContext) { (result) in
|
|
completionHandler(result)
|
|
operation.finish()
|
|
}
|
|
installAppProgress.addChild(progress, withPendingUnitCount: 100)
|
|
}
|
|
}
|
|
installAppOperation.addDependency(backupAppOperation)
|
|
progress.addChild(installAppProgress, withPendingUnitCount: 55)
|
|
|
|
group.add([installBackupAppOperation, backupAppOperation, installAppOperation])
|
|
self.run([installBackupAppOperation, installAppOperation, backupAppOperation], context: group.context)
|
|
|
|
return progress
|
|
}
|
|
|
|
private func _installBackupApp(for app: InstalledApp, operation appOperation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
|
|
{
|
|
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
|
|
|
if let error = context.error
|
|
{
|
|
completionHandler(.failure(error))
|
|
return progress
|
|
}
|
|
|
|
guard let application = ALTApplication(fileURL: app.fileURL) else {
|
|
completionHandler(.failure(OperationError.appNotFound(name: app.name)))
|
|
return progress
|
|
}
|
|
|
|
let prepareProgress = Progress.discreteProgress(totalUnitCount: 1)
|
|
let prepareOperation = RSTAsyncBlockOperation { (operation) in
|
|
app.managedObjectContext?.perform {
|
|
do
|
|
{
|
|
let temporaryDirectoryURL = context.temporaryDirectory.appendingPathComponent("AltBackup-" + UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
|
|
|
guard let altbackupFileURL = Bundle.main.url(forResource: "AltBackup", withExtension: "ipa") else { throw OperationError.appNotFound(name: app.name) }
|
|
|
|
let unzippedAppBundleURL = try FileManager.default.unzipAppBundle(at: altbackupFileURL, toDirectory: temporaryDirectoryURL)
|
|
guard let unzippedAppBundle = Bundle(url: unzippedAppBundleURL) else { throw OperationError.invalidApp }
|
|
|
|
if var infoDictionary = unzippedAppBundle.infoDictionary
|
|
{
|
|
// Replace name + bundle identifier so AltStore treats it as the same app.
|
|
infoDictionary["CFBundleDisplayName"] = app.name
|
|
infoDictionary[kCFBundleIdentifierKey as String] = app.bundleIdentifier
|
|
|
|
// Add app-specific exported UTI so we can check later if this temporary backup app is still installed or not.
|
|
let installedAppUTI = ["UTTypeConformsTo": [],
|
|
"UTTypeDescription": "AltStore Backup App",
|
|
"UTTypeIconFiles": [],
|
|
"UTTypeIdentifier": app.installedBackupAppUTI,
|
|
"UTTypeTagSpecification": [:]] as [String : Any]
|
|
|
|
var exportedUTIs = infoDictionary[Bundle.Info.exportedUTIs] as? [[String: Any]] ?? []
|
|
exportedUTIs.append(installedAppUTI)
|
|
infoDictionary[Bundle.Info.exportedUTIs] = exportedUTIs
|
|
|
|
if let cachedApp = ALTApplication(fileURL: app.fileURL), let icon = cachedApp.icon?.resizing(to: CGSize(width: 180, height: 180))
|
|
{
|
|
let iconFileURL = unzippedAppBundleURL.appendingPathComponent("AppIcon.png")
|
|
|
|
if let iconData = icon.pngData()
|
|
{
|
|
do
|
|
{
|
|
try iconData.write(to: iconFileURL, options: .atomic)
|
|
|
|
let bundleIcons = ["CFBundlePrimaryIcon": ["CFBundleIconFiles": [iconFileURL.lastPathComponent]]]
|
|
infoDictionary["CFBundleIcons"] = bundleIcons
|
|
}
|
|
catch
|
|
{
|
|
print("Failed to write app icon data.", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
try (infoDictionary as NSDictionary).write(to: unzippedAppBundle.infoPlistURL)
|
|
}
|
|
|
|
guard let backupApp = ALTApplication(fileURL: unzippedAppBundleURL) else { throw OperationError.invalidApp }
|
|
context.app = backupApp
|
|
|
|
prepareProgress.completedUnitCount += 1
|
|
}
|
|
catch
|
|
{
|
|
print(error)
|
|
|
|
context.error = error
|
|
}
|
|
|
|
operation.finish()
|
|
}
|
|
}
|
|
progress.addChild(prepareProgress, withPendingUnitCount: 20)
|
|
|
|
let installProgress = Progress.discreteProgress(totalUnitCount: 100)
|
|
let installOperation = RSTAsyncBlockOperation { [weak self] (operation) in
|
|
guard let self = self else { return }
|
|
|
|
guard let backupApp = context.app else {
|
|
context.error = OperationError.invalidApp
|
|
operation.finish()
|
|
return
|
|
}
|
|
|
|
var appGroups = application.entitlements[.appGroups] as? [String] ?? []
|
|
appGroups.append(Bundle.baseAltStoreAppGroupID)
|
|
|
|
let additionalEntitlements: [ALTEntitlement: Any] = [.appGroups: appGroups]
|
|
let progress = self._install(backupApp, operation: appOperation, group: group, context: context, additionalEntitlements: additionalEntitlements, cacheApp: false) { (result) in
|
|
completionHandler(result)
|
|
operation.finish()
|
|
}
|
|
installProgress.addChild(progress, withPendingUnitCount: 100)
|
|
}
|
|
installOperation.addDependency(prepareOperation)
|
|
progress.addChild(installProgress, withPendingUnitCount: 80)
|
|
|
|
group.add([prepareOperation, installOperation])
|
|
self.run([prepareOperation, installOperation], context: group.context)
|
|
|
|
return progress
|
|
}
|
|
|
|
func finish(_ operation: AppOperation, result: Result<InstalledApp, Error>, group: RefreshGroup, progress: Progress?)
|
|
{
|
|
// Must remove before saving installedApp.
|
|
if let currentProgress = self.progress(for: operation), currentProgress == progress
|
|
{
|
|
// Only remove progress if it hasn't been replaced by another one.
|
|
self.set(nil, for: operation)
|
|
}
|
|
|
|
do
|
|
{
|
|
let installedApp = try result.get()
|
|
group.set(.success(installedApp), forAppWithBundleIdentifier: installedApp.bundleIdentifier)
|
|
|
|
if installedApp.bundleIdentifier == StoreApp.altstoreAppID
|
|
{
|
|
self.scheduleExpirationWarningLocalNotification(for: installedApp)
|
|
}
|
|
|
|
let event: AnalyticsManager.Event?
|
|
|
|
switch operation
|
|
{
|
|
case .install: event = .installedApp(installedApp)
|
|
case .refresh: event = .refreshedApp(installedApp)
|
|
case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID:
|
|
// AltStore quits before update finishes, so we've preemptively logged this update event.
|
|
// In case AltStore doesn't quit, such as when update has a different bundle identifier,
|
|
// make sure we don't log this update event a second time.
|
|
event = nil
|
|
|
|
case .update: event = .updatedApp(installedApp)
|
|
case .activate, .deactivate, .backup, .restore: event = nil
|
|
}
|
|
|
|
if let event = event
|
|
{
|
|
AnalyticsManager.shared.trackEvent(event)
|
|
}
|
|
|
|
if #available(iOS 14, *)
|
|
{
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
do { try installedApp.managedObjectContext?.save() }
|
|
catch { print("Error saving installed app.", error) }
|
|
}
|
|
catch let nsError as NSError
|
|
{
|
|
var appName: String!
|
|
if let app = operation.app as? (NSManagedObject & AppProtocol) {
|
|
if let context = app.managedObjectContext {
|
|
context.performAndWait {
|
|
appName = app.name
|
|
}
|
|
} else {
|
|
appName = NSLocalizedString("Unknown App", comment: "")
|
|
}
|
|
} else {
|
|
appName = operation.app.name
|
|
}
|
|
|
|
let localizedTitle: String
|
|
switch operation {
|
|
case .install: localizedTitle = String(format: NSLocalizedString("Failed to Install %@", comment: ""), appName)
|
|
case .refresh: localizedTitle = String(format: NSLocalizedString("Failed to Refresh %@", comment: ""), appName)
|
|
case .update: localizedTitle = String(format: NSLocalizedString("Failed to Update %@", comment: ""), appName)
|
|
case .activate: localizedTitle = String(format: NSLocalizedString("Failed to Activate %@", comment: ""), appName)
|
|
case .deactivate: localizedTitle = String(format: NSLocalizedString("Failed to Deactivate %@", comment: ""), appName)
|
|
case .backup: localizedTitle = String(format: NSLocalizedString("Failed to Backup %@", comment: ""), appName)
|
|
case .restore: localizedTitle = String(format: NSLocalizedString("Failed to Restore %@ Backup", comment: ""), appName)
|
|
}
|
|
let error = nsError.withLocalizedTitle(localizedTitle)
|
|
group.set(.failure(error), forAppWithBundleIdentifier: operation.bundleIdentifier)
|
|
|
|
self.log(error, operation: operation.loggedErrorOperation, app: operation.app)
|
|
}
|
|
}
|
|
|
|
func scheduleExpirationWarningLocalNotification(for app: InstalledApp)
|
|
{
|
|
let notificationDate = app.expirationDate.addingTimeInterval(-1 * 60 * 60 * 24) // 24 hours before expiration.
|
|
|
|
let timeIntervalUntilNotification = notificationDate.timeIntervalSinceNow
|
|
guard timeIntervalUntilNotification > 0 else {
|
|
// Crashes if we pass negative value to UNTimeIntervalNotificationTrigger initializer.
|
|
return
|
|
}
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeIntervalUntilNotification, repeats: false)
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = NSLocalizedString("SideStore Expiring Soon", comment: "")
|
|
content.body = NSLocalizedString("SideStore will expire in 24 hours. Open the app and refresh it to prevent it from expiring.", comment: "")
|
|
content.sound = .default
|
|
|
|
let request = UNNotificationRequest(identifier: AppManager.expirationWarningNotificationID, content: content, trigger: trigger)
|
|
UNUserNotificationCenter.current().add(request)
|
|
}
|
|
|
|
|
|
func run(_ operations: [Foundation.Operation], context: OperationContext?, requiresSerialQueue: Bool = false)
|
|
{
|
|
// Find "Install AltStore" operation if it already exists in `context`
|
|
// so we can ensure it runs after any additional serial operations in `operations`.
|
|
let installAltStoreOperation = context?.operations.allObjects.lazy.compactMap { $0 as? InstallAppOperation }.first { $0.context.bundleIdentifier == StoreApp.altstoreAppID }
|
|
|
|
for operation in operations
|
|
{
|
|
switch operation
|
|
{
|
|
case _ where requiresSerialQueue: fallthrough
|
|
case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation:
|
|
if let installAltStoreOperation = operation as? InstallAppOperation, installAltStoreOperation.context.bundleIdentifier == StoreApp.altstoreAppID
|
|
{
|
|
// Add dependencies on previous serial operations in `context` to ensure re-installing AltStore goes last.
|
|
let previousSerialOperations = context?.operations.allObjects.filter { self.serialOperationQueue.operations.contains($0) }
|
|
previousSerialOperations?.forEach { installAltStoreOperation.addDependency($0) }
|
|
}
|
|
else if let installAltStoreOperation = installAltStoreOperation
|
|
{
|
|
// Re-installing AltStore should _always_ be the last serial operation in `context`.
|
|
installAltStoreOperation.addDependency(operation)
|
|
}
|
|
|
|
self.serialOperationQueue.addOperation(operation)
|
|
|
|
default: self.operationQueue.addOperation(operation)
|
|
}
|
|
|
|
context?.operations.add(operation)
|
|
}
|
|
}
|
|
|
|
func progress(for operation: AppOperation) -> Progress?
|
|
{
|
|
switch operation
|
|
{
|
|
case .install, .update: return self.installationProgress[operation.bundleIdentifier]
|
|
case .refresh, .activate, .deactivate, .backup, .restore: return self.refreshProgress[operation.bundleIdentifier]
|
|
}
|
|
}
|
|
|
|
func set(_ progress: Progress?, for operation: AppOperation)
|
|
{
|
|
switch operation
|
|
{
|
|
case .install, .update: self.installationProgress[operation.bundleIdentifier] = progress
|
|
case .refresh, .activate, .deactivate, .backup, .restore: self.refreshProgress[operation.bundleIdentifier] = progress
|
|
}
|
|
}
|
|
}
|