Files
SideStore/AltStore/Managing Apps/AppManager.swift
Riley Testut 2017584da4 Verifies Sources don’t contain duplicate bundle IDs
AltStore assumes all apps have unique bundle IDs per source. Weird bugs can occur when this is not the case (such as merging multiple store listings into one), so we now verify upfront whether source contains duplicate bundle IDs before saving.
2024-12-26 21:15:29 +05:30

2142 lines
95 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 SwiftUI
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"
enum PreferredAppVersion
{
case latestSupportedVersion
case latestAvailableVersionWithFallback
}
}
@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
}
}
private lazy var progressLock: UnsafeMutablePointer<os_unfair_lock> = {
// Can't safely pass &os_unfair_lock to os_unfair_lock functions in Swift,
// so pass UnsafeMutablePointer instead which is guaranteed to be safe.
// https://stackoverflow.com/a/68615042
let lock = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
lock.initialize(to: .init())
return lock
}()
@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()
}
}
deinit
{
// Should never be called, but do bookkeeping anyway.
self.progressLock.deinitialize(count: 1)
self.progressLock.deallocate()
}
@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 !UserDefaults.standard.isAppLimitDisabled, 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 nsError as NSError):
let source = managedObjectContext.object(with: source.objectID) as! Source
let title = String(format: NSLocalizedString("Unable to Refresh “%@” Source", comment: ""), source.name)
let error = nsError.withLocalizedTitle(title)
errors[source] = error
source.error = error.sanitizedForSerialization()
}
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(_ installedApp: InstalledApp, to preferredAppVersion: PreferredAppVersion = .latestSupportedVersion, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
{
let preferredApp: AppProtocol?
switch preferredAppVersion
{
case .latestSupportedVersion: preferredApp = installedApp.storeApp?.latestSupportedVersion
case .latestAvailableVersionWithFallback: preferredApp = installedApp.storeApp // Use StoreApp directly to correctly handle min/max OS versions in DownloadAppOperation.
}
guard let app = preferredApp else {
completionHandler(.failure(OperationError.appNotFound(name: installedApp.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(app)
assert(operation.app as AnyObject !== installedApp) // 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 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)
completionHandler(.failure(error))
}
}
enableJITOperation.addDependency(findServerOperation)
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?
{
os_unfair_lock_lock(self.progressLock)
defer { os_unfair_lock_unlock(self.progressLock) }
let progress = self.installationProgress[app.bundleIdentifier]
return progress
}
func refreshProgress(for app: AppProtocol) -> Progress?
{
os_unfair_lock_lock(self.progressLock)
defer { os_unfair_lock_unlock(self.progressLock) }
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
}
func removeAppExtensions(
from application: ALTApplication,
existingApp: InstalledApp?,
extensions: Set<ALTApplication>,
_ presentingViewController: UIViewController?,
completion: @escaping (Result<Void, Error>) -> Void
) {
// App-Extensions: Ensure existing app's extensions and currently installing app's extensions must match
if let existingApp {
_ = RSTAsyncBlockOperation { _ in
let existingAppEx: Set<InstalledExtension> = existingApp.appExtensions
let currentAppEx: Set<ALTApplication> = application.appExtensions
let currentAppExNames = currentAppEx.map{ appEx in appEx.bundleIdentifier}
let existingAppExNames = existingAppEx.map{ appEx in appEx.bundleIdentifier}
let excessExtensions = currentAppEx.filter{
!(existingAppExNames.contains($0.bundleIdentifier))
}
let isMatching = (currentAppEx.count == existingAppEx.count) && excessExtensions.isEmpty
let diagnosticsMsg = "AppManager.removeAppExtensions: App Extensions in existingApp and currentApp are matching: \(isMatching)\n"
+ "AppManager.removeAppExtensions: existingAppEx: \(existingAppExNames); currentAppEx: \(String(describing: currentAppExNames))\n"
print(diagnosticsMsg)
// if background mode, then remove only the excess extensions
guard let presentingViewController: UIViewController = presentingViewController else {
// perform silent extensions cleanup for those that aren't already present in existing app
print("\n Performing background mode Extensions removal \n")
print("AppManager.removeAppExtensions: Excess Extensions: \(excessExtensions)")
do {
for appExtension in excessExtensions {
print("Deleting extension \(appExtension.bundleIdentifier)")
try FileManager.default.removeItem(at: appExtension.fileURL)
}
return completion(.success(()))
} catch {
return completion(.failure(error))
}
}
}
}
guard !application.appExtensions.isEmpty else { return completion(.success(())) }
DispatchQueue.main.async {
let firstSentence: String
if UserDefaults.standard.activeAppLimitIncludesExtensions
{
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions.", comment: "")
}
else
{
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to creating 10 App IDs per week.", comment: "")
}
let message = firstSentence + " " + NSLocalizedString("Would you like to remove this app's extensions so they don't count towards your limit? There are \(extensions.count) Extensions", comment: "")
let alertController = UIAlertController(title: NSLocalizedString("App Contains Extensions", comment: ""), message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in
completion(.failure(OperationError.cancelled))
}))
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions", comment: ""), style: .default) { (action) in
completion(.success(()))
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { (action) in
do
{
for appExtension in application.appExtensions
{
print("Deleting extension \(appExtension.bundleIdentifier)")
try FileManager.default.removeItem(at: appExtension.fileURL)
}
completion(.success(()))
}
catch
{
completion(.failure(error))
}
})
if let presentingViewController {
alertController.addAction(UIAlertAction(title: NSLocalizedString("Choose App Extensions", comment: ""), style: .default) { (action) in
let popoverContentController = AppExtensionViewHostingController(extensions: extensions) { (selection) in
do
{
for appExtension in selection
{
print("Deleting extension \(appExtension.bundleIdentifier)")
try FileManager.default.removeItem(at: appExtension.fileURL)
}
completion(.success(()))
}
catch
{
completion(.failure(error))
}
return nil
}
let suiview = popoverContentController.view!
suiview.translatesAutoresizingMaskIntoConstraints = false
popoverContentController.modalPresentationStyle = .popover
if let popoverPresentationController = popoverContentController.popoverPresentationController {
popoverPresentationController.sourceView = presentingViewController.view
popoverPresentationController.sourceRect = CGRect(x: 50, y: 50, width: 4, height: 4)
popoverPresentationController.delegate = popoverContentController
presentingViewController.present(popoverContentController, animated: true)
}
})
presentingViewController.present(alertController, animated: true)
}
}
}
private func _install(_ app: AppProtocol, operation appOperation: AppOperation, group: RefreshGroup, context: InstallAppOperationContext? = nil, additionalEntitlements: [ALTEntitlement: Any] = [.increasedDebuggingMemoryLimit: ALTEntitlement.increasedDebuggingMemoryLimit, .increasedMemoryLimit: ALTEntitlement.increasedMemoryLimit, .extendedVirtualAddressing: ALTEntitlement.extendedVirtualAddressing], 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)
/* Remove App Extensions */
let removeAppExtensionsOperation = RSTAsyncBlockOperation { [weak self] (operation) in
do
{
if let error = context.error
{
throw error
}
/*
guard case .install = appOperation else {
operation.finish()
return
}
*/
guard let extensions = context.app?.appExtensions else {
throw OperationError.invalidParameters("AppManager._install.removeAppExtensionsOperation: context.app?.appExtensions is nil")
}
guard let currentApp = context.app else {
throw OperationError.invalidParameters("AppManager._install.removeAppExtensionsOperation: context.app is nil")
}
self?.removeAppExtensions(from: currentApp,
existingApp: app as? InstalledApp,
extensions: extensions,
context.authenticatedContext.presentingViewController
) { result in
switch result {
case .success(): break
case .failure(let error): context.error = error
}
operation.finish()
}
}
catch
{
context.error = error
operation.finish()
}
}
removeAppExtensionsOperation.addDependency(verifyOperation)
/* 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(removeAppExtensionsOperation)
/* 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("AppManager._install.deactivateAppsOperation: context.provisioningProfiles is nil")
}
if !profiles.contains(where: { $1.isFreeProvisioningProfile == true }) {
operation.finish()
return
}
guard
let app = context.app,
let presentingViewController = context.authenticatedContext.presentingViewController
else {
throw OperationError.invalidParameters("AppManager._install.deactivateAppsOperation: self.context.app or context.authenticatedContext.presentingViewController is nil")
}
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("AppManager._install.patchAppOperation: context.app is nil")
}
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, removeAppExtensionsOperation, 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)
//App-Extensions: Ensure DB data and disk state must match
let dbAppEx: Set<InstalledExtension> = Set(app.appExtensions)
let diskAppEx: Set<ALTApplication> = Set(context.app!.appExtensions)
let diskAppExNames = diskAppEx.map { $0.bundleIdentifier }
let dbAppExNames = dbAppEx.map{ $0.bundleIdentifier }
let isMatching = Set(dbAppExNames) == Set(diskAppExNames)
let validateAppExtensionsOperation = RSTAsyncBlockOperation { op in
let errMessage = "AppManager.refresh: App Extensions in DB and Disk are matching: \(isMatching)\n"
+ "AppManager.refresh: dbAppEx: \(dbAppExNames); diskAppEx: \(String(describing: diskAppExNames))\n"
print(errMessage)
if(!isMatching){
completionHandler(.failure(OperationError.refreshAppFailed(message: errMessage)))
}
op.finish()
}
/* 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)
fetchProvisioningProfilesOperation.addDependency(validateAppExtensionsOperation)
/* 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 = [validateAppExtensionsOperation, 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 log(_ error: Error, operation: LoggedError.Operation, app: AppProtocol)
{
switch error
{
case ~OperationError.Code.cancelled: return // Don't log OperationError.cancelled
default: break
}
// Sanitize NSError on same thread before performing background task.
let sanitizedError = (error as NSError).sanitizedForSerialization()
let loggedErrorOperation: LoggedError.Operation = {
switch operation
{
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
}
}()
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)
}
}
}
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?
{
os_unfair_lock_lock(self.progressLock)
defer { os_unfair_lock_unlock(self.progressLock) }
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)
{
os_unfair_lock_lock(self.progressLock)
defer { os_unfair_lock_unlock(self.progressLock) }
switch operation
{
case .install, .update: self.installationProgress[operation.bundleIdentifier] = progress
case .refresh, .activate, .deactivate, .backup, .restore: self.refreshProgress[operation.bundleIdentifier] = progress
}
}
}