XCode project for app, moved app project to folder

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,443 @@
//
// PatchViewController.swift
// AltStore
//
// Created by Riley Testut on 10/20/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import Combine
import UIKit
import AltSign
import SideStoreCore
import RoxasUIKit
@available(iOS 14.0, *)
extension PatchViewController {
enum Step {
case confirm
case install
case openApp
case patchApp
case reboot
case refresh
case finish
}
}
@available(iOS 14.0, *)
public final class PatchViewController: UIViewController {
var patchApp: AnyApp?
var installedApp: InstalledApp?
var completionHandler: ((Result<Void, Error>) -> Void)?
private let context = AuthenticatedOperationContext()
private var currentStep: Step = .confirm {
didSet {
DispatchQueue.main.async {
self.update()
}
}
}
private var buttonHandler: (() -> Void)?
private var resignedApp: ALTApplication?
private lazy var temporaryDirectory: URL = FileManager.default.uniqueTemporaryURL()
private var didEnterBackgroundObservation: NSObjectProtocol?
private weak var cancellableProgress: Progress?
@IBOutlet private var placeholderView: RSTPlaceholderView!
@IBOutlet private var taskDescriptionLabel: UILabel!
@IBOutlet private var pillButton: PillButton!
@IBOutlet private var cancelBarButtonItem: UIBarButtonItem!
@IBOutlet private var cancelButton: UIButton!
public override func viewDidLoad() {
super.viewDidLoad()
isModalInPresentation = true
placeholderView.stackView.spacing = 20
placeholderView.textLabel.textColor = .white
placeholderView.detailTextLabel.textAlignment = .left
placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
buttonHandler = { [weak self] in
self?.startProcess()
}
do {
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
} catch {
print("Failed to create temporary directory:", error)
}
update()
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if installedApp != nil {
refreshApp()
}
}
}
@available(iOS 14.0, *)
private extension PatchViewController {
func update() {
cancelButton.alpha = 0.0
switch currentStep {
case .confirm:
guard let app = patchApp else { break }
if UIDevice.current.isUntetheredJailbreakRequired {
placeholderView.textLabel.text = NSLocalizedString("Jailbreak Requires Untethering", comment: "")
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak is untethered, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
} else {
placeholderView.textLabel.text = NSLocalizedString("Jailbreak Supports Untethering", comment: "")
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak has an untethered version, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
}
pillButton.setTitle(NSLocalizedString("Install Untethered Jailbreak", comment: ""), for: .normal)
cancelButton.alpha = 1.0
case .install:
guard let app = patchApp else { break }
placeholderView.textLabel.text = String(format: NSLocalizedString("Installing %@ placeholder…", comment: ""), app.name)
placeholderView.detailTextLabel.text = NSLocalizedString("A placeholder app needs to be installed in order to prepare your device for untethering.\n\nThis may take a few moments.", comment: "")
case .openApp:
placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
case .patchApp:
guard let app = patchApp else { break }
placeholderView.textLabel.text = String(format: NSLocalizedString("Patching %@ placeholder…", comment: ""), app.name)
placeholderView.detailTextLabel.text = NSLocalizedString("This will take a few moments. Please do not turn off the screen or leave the app until patching is complete.", comment: "")
pillButton.setTitle(NSLocalizedString("Patch Placeholder", comment: ""), for: .normal)
case .reboot:
placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
case .refresh:
guard let installedApp = installedApp else { break }
placeholderView.textLabel.text = String(format: NSLocalizedString("Finish installing %@?", comment: ""), installedApp.name)
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("In order to finish jailbreaking this device, you need to install %@ then follow the instructions in the app.", comment: ""), installedApp.name)
pillButton.setTitle(String(format: NSLocalizedString("Install %@", comment: ""), installedApp.name), for: .normal)
case .finish:
guard let installedApp = installedApp else { break }
placeholderView.textLabel.text = String(format: NSLocalizedString("Finish in %@", comment: ""), installedApp.name)
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("Follow the instructions in %@ to finish jailbreaking this device.", comment: ""), installedApp.name)
pillButton.setTitle(String(format: NSLocalizedString("Open %@", comment: ""), installedApp.name), for: .normal)
}
}
func present(_ error: Error, title: String) {
DispatchQueue.main.async {
let nsError = error as NSError
let alertController = UIAlertController(title: nsError.localizedFailure ?? title, message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(.ok)
self.present(alertController, animated: true, completion: nil)
self.setProgress(nil, description: nil)
}
}
func setProgress(_ progress: Progress?, description: String?) {
DispatchQueue.main.async {
self.pillButton.progress = progress
self.taskDescriptionLabel.text = description ?? " " // Use non-empty string to prevent label resizing itself.
}
}
func finish(with result: Result<Void, Error>) {
do {
try FileManager.default.removeItem(at: temporaryDirectory)
} catch {
print("Failed to remove temporary directory:", error)
}
if let observation = didEnterBackgroundObservation {
NotificationCenter.default.removeObserver(observation)
}
completionHandler?(result)
completionHandler = nil
}
}
@available(iOS 14.0, *)
private extension PatchViewController {
@IBAction func performButtonAction() {
buttonHandler?()
}
@IBAction func cancel() {
finish(with: .success(()))
cancellableProgress?.cancel()
}
@IBAction func installRegularJailbreak() {
guard let app = patchApp else { return }
let title: String
let message: String
if UIDevice.current.isUntetheredJailbreakRequired {
title = NSLocalizedString("Untethering Required", comment: "")
message = String(format: NSLocalizedString("%@ can not jailbreak this device unless you untether it first. Are you sure you want to install without untethering?", comment: ""), app.name)
} else {
title = NSLocalizedString("Untethering Recommended", comment: "")
message = String(format: NSLocalizedString("Untethering this jailbreak will prevent %@ from expiring, even after 7 days or rebooting the device. Are you sure you want to install without untethering?", comment: ""), app.name)
}
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Install Without Untethering", comment: ""), style: .default) { _ in
self.finish(with: .failure(OperationError.cancelled))
})
alertController.addAction(.cancel)
present(alertController, animated: true, completion: nil)
}
}
@available(iOS 14.0, *)
private extension PatchViewController {
func startProcess() {
guard let patchApp = patchApp else { return }
currentStep = .install
if let progress = AppManager.shared.installationProgress(for: patchApp) {
// Cancel pending jailbreak app installation so we can start a new one.
progress.cancel()
}
let appURL = InstalledApp.fileURL(for: patchApp)
let cachedAppURL = temporaryDirectory.appendingPathComponent("Cached.app")
do {
// Make copy of original app, so we can replace the cached patch app with it later.
try FileManager.default.copyItem(at: appURL, to: cachedAppURL, shouldReplace: true)
} catch {
present(error, title: NSLocalizedString("Could not back up jailbreak app.", comment: ""))
return
}
var unzippingError: Error?
let refreshGroup = AppManager.shared.install(patchApp, presentingViewController: self, context: context) { result in
do {
_ = try result.get()
if let unzippingError = unzippingError {
throw unzippingError
}
// Replace cached patch app with original app so we can resume installing it post-reboot.
try FileManager.default.copyItem(at: cachedAppURL, to: appURL, shouldReplace: true)
self.openApp()
} catch {
self.present(error, title: String(format: NSLocalizedString("Could not install %@ placeholder.", comment: ""), patchApp.name))
}
}
refreshGroup.beginInstallationHandler = { installedApp in
do {
// Replace patch app name with correct name.
installedApp.name = patchApp.name
let ipaURL = installedApp.refreshedIPAURL
let resignedAppURL = try FileManager.default.unzipAppBundle(at: ipaURL, toDirectory: self.temporaryDirectory)
self.resignedApp = ALTApplication(fileURL: resignedAppURL)
} catch {
print("Error unzipping app bundle:", error)
unzippingError = error
}
}
setProgress(refreshGroup.progress, description: nil)
cancellableProgress = refreshGroup.progress
}
func openApp() {
guard let patchApp = patchApp else { return }
setProgress(nil, description: nil)
currentStep = .openApp
// This observation is willEnterForeground because patching starts immediately upon return.
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { _ in
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
self.patchApplication()
}
buttonHandler = { [weak self] in
guard let self = self else { return }
#if !targetEnvironment(simulator)
let openURL = InstalledApp.openAppURL(for: patchApp)
UIApplication.shared.open(openURL) { success in
guard !success else { return }
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
}
#endif
}
}
func patchApplication() {
guard let resignedApp = resignedApp else { return }
currentStep = .patchApp
buttonHandler = { [weak self] in
self?.patchApplication()
}
let patchAppOperation = AppManager.shared.patch(resignedApp: resignedApp, presentingViewController: self, context: context) { result in
switch result {
case let .failure(error): self.present(error, title: String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), resignedApp.name))
case .success: self.rebootDevice()
}
}
patchAppOperation.progressHandler = { progress, description in
self.setProgress(progress, description: description)
}
cancellableProgress = patchAppOperation.progress
}
func rebootDevice() {
guard let patchApp = patchApp else { return }
setProgress(nil, description: nil)
currentStep = .reboot
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
var patchedApps = UserDefaults.standard.patchedApps ?? []
if !patchedApps.contains(patchApp.bundleIdentifier) {
patchedApps.append(patchApp.bundleIdentifier)
UserDefaults.standard.patchedApps = patchedApps
}
self.finish(with: .success(()))
}
buttonHandler = { [weak self] in
guard let self = self else { return }
#if !targetEnvironment(simulator)
let openURL = InstalledApp.openAppURL(for: patchApp)
UIApplication.shared.open(openURL) { success in
guard !success else { return }
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
}
#endif
}
}
func refreshApp() {
guard let installedApp = installedApp else { return }
currentStep = .refresh
buttonHandler = { [weak self] in
guard let self = self else { return }
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let tempApp = context.object(with: installedApp.objectID) as! InstalledApp
tempApp.needsResign = true
let errorTitle = String(format: NSLocalizedString("Could not install %@.", comment: ""), tempApp.name)
do {
try context.save()
installedApp.managedObjectContext?.perform {
// Refreshing ensures we don't attempt to patch the app again,
// since that is only checked when installing a new app.
let refreshGroup = AppManager.shared.refresh([installedApp], presentingViewController: self, group: nil)
refreshGroup.completionHandler = { [weak refreshGroup, weak self] results in
guard let self = self else { return }
do {
guard let (bundleIdentifier, result) = results.first else { throw refreshGroup?.context.error ?? OperationError.unknown }
_ = try result.get()
if var patchedApps = UserDefaults.standard.patchedApps, let index = patchedApps.firstIndex(of: bundleIdentifier) {
patchedApps.remove(at: index)
UserDefaults.standard.patchedApps = patchedApps
}
self.finish()
} catch {
self.present(error, title: errorTitle)
}
}
self.setProgress(refreshGroup.progress, description: String(format: NSLocalizedString("Installing %@...", comment: ""), installedApp.name))
}
} catch {
self.present(error, title: errorTitle)
}
}
}
}
func finish() {
guard let installedApp = installedApp else { return }
setProgress(nil, description: nil)
currentStep = .finish
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
self.finish(with: .success(()))
}
installedApp.managedObjectContext?.perform {
let appName = installedApp.name
let openURL = installedApp.openAppURL
self.buttonHandler = { [weak self] in
guard let self = self else { return }
#if !targetEnvironment(simulator)
UIApplication.shared.open(openURL) { success in
guard !success else { return }
self.present(OperationError.openAppFailed(name: appName), title: String(format: NSLocalizedString("Could not open %@.", comment: ""), appName))
}
#endif
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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