mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-11 15:53:30 +01:00
XCode project for app, moved app project to folder
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
//
|
||||
// AuthenticationOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/5/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import RoxasUIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
|
||||
enum AuthenticationError: LocalizedError {
|
||||
case noTeam
|
||||
case noCertificate
|
||||
case teamSelectorError
|
||||
|
||||
case missingPrivateKey
|
||||
case missingCertificate
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noTeam: return NSLocalizedString("Developer team could not be found.", comment: "")
|
||||
case .teamSelectorError: return NSLocalizedString("Error presenting team selector view.", comment: "")
|
||||
case .noCertificate: return NSLocalizedString("Developer certificate could not be found.", comment: "")
|
||||
case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "")
|
||||
case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(AuthenticationOperation)
|
||||
public final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppleAPISession)> {
|
||||
public let context: AuthenticatedOperationContext
|
||||
|
||||
private weak var presentingViewController: UIViewController?
|
||||
|
||||
private lazy var navigationController: UINavigationController = {
|
||||
let navigationController = self.storyboard.instantiateViewController(withIdentifier: "navigationController") as! UINavigationController
|
||||
if #available(iOS 13.0, *) {
|
||||
navigationController.isModalInPresentation = true
|
||||
}
|
||||
return navigationController
|
||||
}()
|
||||
|
||||
private lazy var storyboard = UIStoryboard(name: "Authentication", bundle: nil)
|
||||
|
||||
private var appleIDEmailAddress: String?
|
||||
private var appleIDPassword: String?
|
||||
private var shouldShowInstructions = false
|
||||
|
||||
private let operationQueue = OperationQueue()
|
||||
|
||||
private var submitCodeAction: UIAlertAction?
|
||||
|
||||
public init(context: AuthenticatedOperationContext, presentingViewController: UIViewController?) {
|
||||
self.context = context
|
||||
self.presentingViewController = presentingViewController
|
||||
|
||||
super.init()
|
||||
|
||||
self.context.authenticationOperation = self
|
||||
operationQueue.name = "com.altstore.AuthenticationOperation"
|
||||
progress.totalUnitCount = 4
|
||||
}
|
||||
|
||||
public override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
// Sign In
|
||||
signIn { result in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result {
|
||||
case let .failure(error): self.finish(.failure(error))
|
||||
case let .success((account, session)):
|
||||
self.context.session = session
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Fetch Team
|
||||
self.fetchTeam(for: account, session: session) { result in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result {
|
||||
case let .failure(error): self.finish(.failure(error))
|
||||
case let .success(team):
|
||||
self.context.team = team
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Fetch Certificate
|
||||
self.fetchCertificate(for: team, session: session) { result in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result {
|
||||
case let .failure(error): self.finish(.failure(error))
|
||||
case let .success(certificate):
|
||||
self.context.certificate = certificate
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Register Device
|
||||
self.registerCurrentDevice(for: team, session: session) { result in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result {
|
||||
case let .failure(error): self.finish(.failure(error))
|
||||
case .success:
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Save account/team to disk.
|
||||
self.save(team) { result in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result {
|
||||
case let .failure(error): self.finish(.failure(error))
|
||||
case .success:
|
||||
// Must cache App IDs _after_ saving account/team to disk.
|
||||
self.cacheAppIDs(team: team, session: session) { result in
|
||||
let result = result.map { _ in (team, certificate, session) }
|
||||
self.finish(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ altTeam: ALTTeam, completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
context.performAndWait {
|
||||
do {
|
||||
let account: Account
|
||||
let team: Team
|
||||
|
||||
if let tempAccount = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context) {
|
||||
account = tempAccount
|
||||
} else {
|
||||
account = Account(altTeam.account, context: context)
|
||||
}
|
||||
|
||||
if let tempTeam = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context) {
|
||||
team = tempTeam
|
||||
} else {
|
||||
team = Team(altTeam, account: account, context: context)
|
||||
}
|
||||
|
||||
account.update(account: altTeam.account)
|
||||
|
||||
if let providedEmailAddress = self.appleIDEmailAddress {
|
||||
// Save the user's provided email address instead of the one associated with their account (which may be outdated).
|
||||
account.appleID = providedEmailAddress
|
||||
}
|
||||
|
||||
team.update(team: altTeam)
|
||||
|
||||
try context.save()
|
||||
|
||||
completionHandler(.success(()))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func finish(_ result: Result<(ALTTeam, ALTCertificate, ALTAppleAPISession), Error>) {
|
||||
guard !isFinished else { return }
|
||||
|
||||
print("Finished authenticating with result:", result.error?.localizedDescription ?? "success")
|
||||
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
context.perform {
|
||||
do {
|
||||
let (altTeam, altCertificate, session) = try result.get()
|
||||
|
||||
guard
|
||||
let account = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context),
|
||||
let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context)
|
||||
else { throw AuthenticationError.noTeam }
|
||||
|
||||
// Account
|
||||
account.isActiveAccount = true
|
||||
|
||||
let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest<Account>
|
||||
otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier)
|
||||
|
||||
let otherAccounts = try context.fetch(otherAccountsFetchRequest)
|
||||
for account in otherAccounts {
|
||||
account.isActiveAccount = false
|
||||
}
|
||||
|
||||
// Team
|
||||
team.isActiveTeam = true
|
||||
|
||||
let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest<Team>
|
||||
otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier)
|
||||
|
||||
let otherTeams = try context.fetch(otherTeamsFetchRequest)
|
||||
for team in otherTeams {
|
||||
team.isActiveTeam = false
|
||||
}
|
||||
|
||||
let activeAppsMinimumVersion = OperatingSystemVersion(majorVersion: 13, minorVersion: 3, patchVersion: 1)
|
||||
if team.type == .free, ProcessInfo.processInfo.isOperatingSystemAtLeast(activeAppsMinimumVersion) {
|
||||
UserDefaults.standard.activeAppsLimit = ALTActiveAppsLimit
|
||||
} else {
|
||||
UserDefaults.standard.activeAppsLimit = nil
|
||||
}
|
||||
|
||||
// Save
|
||||
try context.save()
|
||||
|
||||
// Update keychain
|
||||
Keychain.shared.appleIDEmailAddress = self.appleIDEmailAddress ?? altTeam.account.appleID // Prefer the user's provided email address over the one associated with their account (which may be outdated).
|
||||
Keychain.shared.appleIDPassword = self.appleIDPassword
|
||||
|
||||
Keychain.shared.signingCertificate = altCertificate.p12Data()
|
||||
Keychain.shared.signingCertificatePassword = altCertificate.machineIdentifier
|
||||
|
||||
self.showInstructionsIfNecessary { _ in
|
||||
|
||||
let signer = ALTSigner(team: altTeam, certificate: altCertificate)
|
||||
// Refresh screen must go last since a successful refresh will cause the app to quit.
|
||||
self.showRefreshScreenIfNecessary(signer: signer, session: session) { _ in
|
||||
super.finish(result)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.navigationController.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
super.finish(result)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.navigationController.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationOperation {
|
||||
func present(_ viewController: UIViewController) -> Bool {
|
||||
guard let presentingViewController = presentingViewController else { return false }
|
||||
|
||||
navigationController.view.tintColor = .white
|
||||
|
||||
if navigationController.viewControllers.isEmpty {
|
||||
guard presentingViewController.presentedViewController == nil else { return false }
|
||||
|
||||
navigationController.setViewControllers([viewController], animated: false)
|
||||
presentingViewController.present(navigationController, animated: true, completion: nil)
|
||||
} else {
|
||||
viewController.navigationItem.leftBarButtonItem = nil
|
||||
navigationController.pushViewController(viewController, animated: true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationOperation {
|
||||
func signIn(completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void) {
|
||||
func authenticate() {
|
||||
DispatchQueue.main.async {
|
||||
let authenticationViewController = self.storyboard.instantiateViewController(withIdentifier: "authenticationViewController") as! AuthenticationViewController
|
||||
authenticationViewController.authenticationHandler = { appleID, password, completionHandler in
|
||||
self.authenticate(appleID: appleID, password: password) { result in
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
authenticationViewController.completionHandler = { result in
|
||||
if let (account, session, password) = result {
|
||||
// We presented the Auth UI and the user signed in.
|
||||
// In this case, we'll assume we should show the instructions again.
|
||||
self.shouldShowInstructions = true
|
||||
|
||||
self.appleIDPassword = password
|
||||
completionHandler(.success((account, session)))
|
||||
} else {
|
||||
completionHandler(.failure(OperationError.cancelled))
|
||||
}
|
||||
}
|
||||
|
||||
if !self.present(authenticationViewController) {
|
||||
completionHandler(.failure(OperationError.notAuthenticated))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword {
|
||||
self.authenticate(appleID: appleID, password: password) { result in
|
||||
switch result {
|
||||
case let .success((account, session)):
|
||||
self.appleIDPassword = password
|
||||
completionHandler(.success((account, session)))
|
||||
|
||||
case .failure(ALTAppleAPIError.incorrectCredentials), .failure(ALTAppleAPIError.appSpecificPasswordRequired):
|
||||
authenticate()
|
||||
|
||||
case let .failure(error):
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
authenticate()
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void) {
|
||||
appleIDEmailAddress = appleID
|
||||
|
||||
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: context)
|
||||
fetchAnisetteDataOperation.resultHandler = { result in
|
||||
switch result {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case let .success(anisetteData):
|
||||
let verificationHandler: ((@escaping (String?) -> Void) -> Void)?
|
||||
|
||||
if let presentingViewController = self.presentingViewController {
|
||||
verificationHandler = { completionHandler in
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Please enter the 6-digit verification code that was sent to your Apple devices.", comment: ""), message: nil, preferredStyle: .alert)
|
||||
alertController.addTextField { textField in
|
||||
textField.autocorrectionType = .no
|
||||
textField.autocapitalizationType = .none
|
||||
textField.keyboardType = .numberPad
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationOperation.textFieldTextDidChange(_:)), name: UITextField.textDidChangeNotification, object: textField)
|
||||
}
|
||||
|
||||
let submitAction = UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default) { _ in
|
||||
let textField = alertController.textFields?.first
|
||||
|
||||
let code = textField?.text ?? ""
|
||||
completionHandler(code)
|
||||
}
|
||||
submitAction.isEnabled = false
|
||||
alertController.addAction(submitAction)
|
||||
self.submitCodeAction = submitAction
|
||||
|
||||
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { _ in
|
||||
completionHandler(nil)
|
||||
})
|
||||
|
||||
if self.navigationController.presentingViewController != nil {
|
||||
self.navigationController.present(alertController, animated: true, completion: nil)
|
||||
} else {
|
||||
presentingViewController.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No view controller to present security code alert, so don't provide verificationHandler.
|
||||
verificationHandler = nil
|
||||
}
|
||||
|
||||
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: anisetteData,
|
||||
verificationHandler: verificationHandler) { account, session, error in
|
||||
if let account = account, let session = session {
|
||||
completionHandler(.success((account, session)))
|
||||
} else {
|
||||
completionHandler(.failure(error ?? OperationError.unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operationQueue.addOperation(fetchAnisetteDataOperation)
|
||||
}
|
||||
|
||||
func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void) {
|
||||
func selectTeam(from teams: [ALTTeam]) {
|
||||
if teams.count <= 1 {
|
||||
if let team = teams.first {
|
||||
return completionHandler(.success(team))
|
||||
} else {
|
||||
return completionHandler(.failure(AuthenticationError.noTeam))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
let selectTeamViewController = self.storyboard.instantiateViewController(withIdentifier: "selectTeamViewController") as! SelectTeamViewController
|
||||
|
||||
selectTeamViewController.teams = teams
|
||||
selectTeamViewController.completionHandler = completionHandler
|
||||
|
||||
if !self.present(selectTeamViewController) {
|
||||
return completionHandler(.failure(AuthenticationError.noTeam))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ALTAppleAPI.shared.fetchTeams(for: account, session: session) { teams, error in
|
||||
switch Result(teams, error) {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case let .success(teams):
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
if let activeTeam = DatabaseManager.shared.activeTeam(in: context), let altTeam = teams.first(where: { $0.identifier == activeTeam.identifier }) {
|
||||
completionHandler(.success(altTeam))
|
||||
} else {
|
||||
selectTeam(from: teams)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCertificate(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTCertificate, Swift.Error>) -> Void) {
|
||||
func requestCertificate() {
|
||||
let machineName = "AltStore - " + UIDevice.current.name
|
||||
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team, session: session) { certificate, error in
|
||||
do {
|
||||
let certificate = try Result(certificate, error).get()
|
||||
guard let privateKey = certificate.privateKey else { throw AuthenticationError.missingPrivateKey }
|
||||
|
||||
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { certificates, error in
|
||||
do {
|
||||
let certificates = try Result(certificates, error).get()
|
||||
|
||||
guard let certificate = certificates.first(where: { $0.serialNumber == certificate.serialNumber }) else {
|
||||
throw AuthenticationError.missingCertificate
|
||||
}
|
||||
|
||||
certificate.privateKey = privateKey
|
||||
completionHandler(.success(certificate))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replaceCertificate(from certificates: [ALTCertificate]) {
|
||||
guard let certificate = certificates.first(where: { $0.machineName?.starts(with: "AltStore") == true }) ?? certificates.first else { return completionHandler(.failure(AuthenticationError.noCertificate)) }
|
||||
|
||||
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { success, error in
|
||||
if let error = error, !success {
|
||||
completionHandler(.failure(error))
|
||||
} else {
|
||||
requestCertificate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { certificates, error in
|
||||
do {
|
||||
let certificates = try Result(certificates, error).get()
|
||||
|
||||
if
|
||||
let data = Keychain.shared.signingCertificate,
|
||||
let localCertificate = ALTCertificate(p12Data: data, password: nil),
|
||||
let certificate = certificates.first(where: { $0.serialNumber == localCertificate.serialNumber }) {
|
||||
// We have a certificate stored in the keychain and it hasn't been revoked.
|
||||
localCertificate.machineIdentifier = certificate.machineIdentifier
|
||||
completionHandler(.success(localCertificate))
|
||||
} else if
|
||||
let serialNumber = Keychain.shared.signingCertificateSerialNumber,
|
||||
let privateKey = Keychain.shared.signingCertificatePrivateKey,
|
||||
let certificate = certificates.first(where: { $0.serialNumber == serialNumber }) {
|
||||
// LEGACY
|
||||
// We have the private key for one of the certificates, so add it to certificate and use it.
|
||||
certificate.privateKey = privateKey
|
||||
completionHandler(.success(certificate))
|
||||
} else if
|
||||
let serialNumber = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.certificateID) as? String,
|
||||
let certificate = certificates.first(where: { $0.serialNumber == serialNumber }),
|
||||
let machineIdentifier = certificate.machineIdentifier,
|
||||
FileManager.default.fileExists(atPath: Bundle.main.certificateURL.path),
|
||||
let data = try? Data(contentsOf: Bundle.main.certificateURL),
|
||||
let localCertificate = ALTCertificate(p12Data: data, password: machineIdentifier) {
|
||||
// We have an embedded certificate that hasn't been revoked.
|
||||
localCertificate.machineIdentifier = machineIdentifier
|
||||
completionHandler(.success(localCertificate))
|
||||
} else if certificates.isEmpty {
|
||||
// No certificates, so request a new one.
|
||||
requestCertificate()
|
||||
} else {
|
||||
// We don't have private keys for any of the certificates,
|
||||
// so we need to revoke one and create a new one.
|
||||
replaceCertificate(from: certificates)
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerCurrentDevice(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void) {
|
||||
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else {
|
||||
return completionHandler(.failure(OperationError.unknownUDID))
|
||||
}
|
||||
|
||||
ALTAppleAPI.shared.fetchDevices(for: team, types: [.iphone, .ipad], session: session) { devices, error in
|
||||
do {
|
||||
let devices = try Result(devices, error).get()
|
||||
|
||||
if let device = devices.first(where: { $0.identifier == udid }) {
|
||||
completionHandler(.success(device))
|
||||
} else {
|
||||
ALTAppleAPI.shared.registerDevice(name: UIDevice.current.name, identifier: udid, type: .iphone, team: team, session: session) { device, error in
|
||||
completionHandler(Result(device, error))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cacheAppIDs(team _: ALTTeam, session _: ALTAppleAPISession, completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
let fetchAppIDsOperation = FetchAppIDsOperation(context: context)
|
||||
fetchAppIDsOperation.resultHandler = { result in
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
|
||||
completionHandler(.success(()))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
operationQueue.addOperation(fetchAppIDsOperation)
|
||||
}
|
||||
|
||||
func showInstructionsIfNecessary(completionHandler: @escaping (Bool) -> Void) {
|
||||
guard shouldShowInstructions else { return completionHandler(false) }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let instructionsViewController = self.storyboard.instantiateViewController(withIdentifier: "instructionsViewController") as! InstructionsViewController
|
||||
instructionsViewController.showsBottomButton = true
|
||||
instructionsViewController.completionHandler = {
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
if !self.present(instructionsViewController) {
|
||||
completionHandler(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showRefreshScreenIfNecessary(signer: ALTSigner, session _: ALTAppleAPISession, completionHandler: @escaping (Bool) -> Void) {
|
||||
guard let application = ALTApplication(fileURL: Bundle.main.bundleURL), let provisioningProfile = application.provisioningProfile else { return completionHandler(false) }
|
||||
|
||||
// If we're not using the same certificate used to install AltStore, warn user that they need to refresh.
|
||||
guard !provisioningProfile.certificates.contains(signer.certificate) else { return completionHandler(false) }
|
||||
|
||||
#if DEBUG
|
||||
completionHandler(false)
|
||||
#else
|
||||
DispatchQueue.main.async {
|
||||
let context = AuthenticatedOperationContext(context: self.context)
|
||||
context.operations.removeAllObjects() // Prevent deadlock due to endless waiting on previous operations to finish.
|
||||
|
||||
let refreshViewController = self.storyboard.instantiateViewController(withIdentifier: "refreshAltStoreViewController") as! RefreshAltStoreViewController
|
||||
refreshViewController.context = context
|
||||
refreshViewController.completionHandler = { _ in
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
if !self.present(refreshViewController) {
|
||||
completionHandler(false)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationOperation {
|
||||
@objc func textFieldTextDidChange(_ notification: Notification) {
|
||||
guard let textField = notification.object as? UITextField else { return }
|
||||
|
||||
submitCodeAction?.isEnabled = (textField.text ?? "").count == 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// BackgroundRefreshAppsOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/6/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import EmotionalDamage
|
||||
|
||||
enum RefreshError: LocalizedError {
|
||||
case noInstalledApps
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noInstalledApps: return NSLocalizedString("No active apps require refreshing.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CFNotificationName {
|
||||
static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString)
|
||||
static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString)
|
||||
|
||||
static func requestAppState(for appID: String) -> CFNotificationName {
|
||||
let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID
|
||||
return CFNotificationName(name as CFString)
|
||||
}
|
||||
|
||||
static func appIsRunning(for appID: String) -> CFNotificationName {
|
||||
let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID
|
||||
return CFNotificationName(name as CFString)
|
||||
}
|
||||
}
|
||||
|
||||
private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void = { _, observer, name, _, _ in
|
||||
guard let name = name, let observer = observer else { return }
|
||||
|
||||
let operation = unsafeBitCast(observer, to: BackgroundRefreshAppsOperation.self)
|
||||
operation.receivedApplicationState(notification: name)
|
||||
}
|
||||
|
||||
@objc(BackgroundRefreshAppsOperation)
|
||||
public final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<InstalledApp, Error>]> {
|
||||
public let installedApps: [InstalledApp]
|
||||
private let managedObjectContext: NSManagedObjectContext
|
||||
|
||||
public var presentsFinishedNotification: Bool = false
|
||||
|
||||
private let refreshIdentifier: String = UUID().uuidString
|
||||
private var runningApplications: Set<String> = []
|
||||
|
||||
public init(installedApps: [InstalledApp]) {
|
||||
self.installedApps = installedApps
|
||||
managedObjectContext = installedApps.compactMap { $0.managedObjectContext }.first ?? DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
public override func finish(_ result: Result<[String: Result<InstalledApp, Error>], Error>) {
|
||||
super.finish(result)
|
||||
|
||||
scheduleFinishedRefreshingNotification(for: result, delay: 0)
|
||||
|
||||
managedObjectContext.perform {
|
||||
self.stopListeningForRunningApps()
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if UIApplication.shared.applicationState == .background {}
|
||||
}
|
||||
}
|
||||
|
||||
public override func main() {
|
||||
super.main()
|
||||
|
||||
guard !installedApps.isEmpty else {
|
||||
finish(.failure(RefreshError.noInstalledApps))
|
||||
return
|
||||
}
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
|
||||
managedObjectContext.perform {
|
||||
print("Apps to refresh:", self.installedApps.map(\.bundleIdentifier))
|
||||
|
||||
self.startListeningForRunningApps()
|
||||
|
||||
// Wait for 3 seconds (2 now, 1 later in FindServerOperation) to:
|
||||
// a) give us time to discover AltServers
|
||||
// b) give other processes a chance to respond to requestAppState notification
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
self.managedObjectContext.perform {
|
||||
let filteredApps = self.installedApps.filter { !self.runningApplications.contains($0.bundleIdentifier) }
|
||||
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
|
||||
|
||||
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
|
||||
group.beginInstallationHandler = { installedApp in
|
||||
guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return }
|
||||
|
||||
// We're starting to install AltStore, which means the app is about to quit.
|
||||
// So, we schedule a "refresh successful" local notification to be displayed after a delay,
|
||||
// but if the app is still running, we cancel the notification.
|
||||
// Then, we schedule another notification and repeat the process.
|
||||
|
||||
// Also since AltServer has already received the app, it can finish installing even if we're no longer running in background.
|
||||
|
||||
if let error = group.context.error {
|
||||
self.scheduleFinishedRefreshingNotification(for: .failure(error))
|
||||
} else {
|
||||
var results = group.results
|
||||
results[installedApp.bundleIdentifier] = .success(installedApp)
|
||||
|
||||
self.scheduleFinishedRefreshingNotification(for: .success(results))
|
||||
}
|
||||
}
|
||||
group.completionHandler = { results in
|
||||
self.finish(.success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackgroundRefreshAppsOperation {
|
||||
func startListeningForRunningApps() {
|
||||
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
for installedApp in installedApps {
|
||||
let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier)
|
||||
CFNotificationCenterAddObserver(notificationCenter, observer, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately)
|
||||
|
||||
let requestAppStateNotification = CFNotificationName.requestAppState(for: installedApp.bundleIdentifier)
|
||||
CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true)
|
||||
}
|
||||
}
|
||||
|
||||
func stopListeningForRunningApps() {
|
||||
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
for installedApp in installedApps {
|
||||
let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier)
|
||||
CFNotificationCenterRemoveObserver(notificationCenter, observer, appIsRunningNotification, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func receivedApplicationState(notification: CFNotificationName) {
|
||||
let baseName = String(CFNotificationName.appIsRunning.rawValue)
|
||||
|
||||
let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "")
|
||||
runningApplications.insert(appID)
|
||||
}
|
||||
|
||||
func scheduleFinishedRefreshingNotification(for result: Result<[String: Result<InstalledApp, Error>], Error>, delay: TimeInterval = 5) {
|
||||
func scheduleFinishedRefreshingNotification() {
|
||||
cancelFinishedRefreshingNotification()
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
var shouldPresentAlert = false
|
||||
|
||||
do {
|
||||
let results = try result.get()
|
||||
shouldPresentAlert = false
|
||||
|
||||
for (_, result) in results {
|
||||
guard case let .failure(error) = result else { continue }
|
||||
throw error
|
||||
}
|
||||
|
||||
content.title = NSLocalizedString("Refreshed Apps", comment: "")
|
||||
content.body = NSLocalizedString("All apps have been refreshed.", comment: "")
|
||||
} catch RefreshError.noInstalledApps {
|
||||
shouldPresentAlert = false
|
||||
} catch {
|
||||
print("Failed to refresh apps in background.", error)
|
||||
|
||||
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
|
||||
content.body = error.localizedDescription
|
||||
|
||||
shouldPresentAlert = false
|
||||
}
|
||||
|
||||
if shouldPresentAlert {
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: refreshIdentifier, content: content, trigger: trigger)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
|
||||
if delay > 0 {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
|
||||
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
|
||||
// If app is still running at this point, we schedule another notification with same identifier.
|
||||
// This prevents the currently scheduled notification from displaying, and starts another countdown timer.
|
||||
// First though, make sure there _is_ still a pending request, otherwise it's been cancelled
|
||||
// and we should stop polling.
|
||||
guard requests.contains(where: { $0.identifier == self.refreshIdentifier }) else { return }
|
||||
|
||||
scheduleFinishedRefreshingNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if presentsFinishedNotification {
|
||||
scheduleFinishedRefreshingNotification()
|
||||
}
|
||||
|
||||
// Perform synchronously to ensure app doesn't quit before we've finishing saving to disk.
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
context.performAndWait {
|
||||
_ = RefreshAttempt(identifier: self.refreshIdentifier, result: result, context: context)
|
||||
|
||||
do { try context.save() } catch { print("Failed to save refresh attempt.", error) }
|
||||
}
|
||||
}
|
||||
|
||||
func cancelFinishedRefreshingNotification() {
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [refreshIdentifier])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// BackupAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/12/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
|
||||
extension BackupAppOperation {
|
||||
enum Action: String {
|
||||
case backup
|
||||
case restore
|
||||
}
|
||||
}
|
||||
|
||||
@objc(BackupAppOperation)
|
||||
class BackupAppOperation: ResultOperation<Void> {
|
||||
let action: Action
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
private var appName: String?
|
||||
private var timeoutTimer: Timer?
|
||||
|
||||
init(action: Action, context: InstallAppOperationContext) {
|
||||
self.action = action
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
do {
|
||||
if let error = self.context.error {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let installedApp = context.installedApp, let context = installedApp.managedObjectContext else { throw OperationError.invalidParameters }
|
||||
context.perform {
|
||||
do {
|
||||
let appName = installedApp.name
|
||||
self.appName = appName
|
||||
|
||||
guard let altstoreApp = InstalledApp.fetchAltStore(in: context) else { throw OperationError.appNotFound }
|
||||
let altstoreOpenURL = altstoreApp.openAppURL
|
||||
|
||||
var returnURLComponents = URLComponents(url: altstoreOpenURL, resolvingAgainstBaseURL: false)
|
||||
returnURLComponents?.host = "appBackupResponse"
|
||||
guard let returnURL = returnURLComponents?.url else { throw OperationError.openAppFailed(name: appName) }
|
||||
|
||||
var openURLComponents = URLComponents()
|
||||
openURLComponents.scheme = installedApp.openAppURL.scheme
|
||||
openURLComponents.host = self.action.rawValue
|
||||
openURLComponents.queryItems = [URLQueryItem(name: "returnURL", value: returnURL.absoluteString)]
|
||||
|
||||
guard let openURL = openURLComponents.url else { throw OperationError.openAppFailed(name: appName) }
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let currentTime = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
UIApplication.shared.open(openURL, options: [:]) { success in
|
||||
let elapsedTime = CFAbsoluteTimeGetCurrent() - currentTime
|
||||
|
||||
if success {
|
||||
self.registerObservers()
|
||||
} else if elapsedTime < 0.5 {
|
||||
// Failed too quickly for human to respond to alert, possibly still finalizing installation.
|
||||
// Try again in a couple seconds.
|
||||
|
||||
print("Failed too quickly, retrying after a few seconds...")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
UIApplication.shared.open(openURL, options: [:]) { success in
|
||||
if success {
|
||||
self.registerObservers()
|
||||
} else {
|
||||
self.finish(.failure(OperationError.openAppFailed(name: appName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.finish(.failure(OperationError.openAppFailed(name: appName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
override func finish(_ result: Result<Void, Error>) {
|
||||
let result = result.mapError { error -> Error in
|
||||
let appName = self.appName ?? self.context.bundleIdentifier
|
||||
|
||||
switch (error, self.action) {
|
||||
case let (error as NSError, _) where (self.context.error as NSError?) == error: fallthrough
|
||||
case (OperationError.cancelled, _):
|
||||
return error
|
||||
|
||||
case let (error as NSError, .backup):
|
||||
let localizedFailure = String(format: NSLocalizedString("Could not back up “%@”.", comment: ""), appName)
|
||||
return error.withLocalizedFailure(localizedFailure)
|
||||
|
||||
case let (error as NSError, .restore):
|
||||
let localizedFailure = String(format: NSLocalizedString("Could not restore “%@”.", comment: ""), appName)
|
||||
return error.withLocalizedFailure(localizedFailure)
|
||||
}
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success: progress.completedUnitCount += 1
|
||||
case .failure: break
|
||||
}
|
||||
|
||||
super.finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackupAppOperation {
|
||||
func registerObservers() {
|
||||
var applicationWillReturnObserver: NSObjectProtocol!
|
||||
applicationWillReturnObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self = self, !self.isFinished else { return }
|
||||
|
||||
self.timeoutTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
|
||||
// Final delay to ensure we don't prematurely return failure
|
||||
// in case timer expired while we were in background, but
|
||||
// are now returning to app with success response.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
guard let self = self, !self.isFinished else { return }
|
||||
self.finish(.failure(OperationError.timedOut))
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.removeObserver(applicationWillReturnObserver!)
|
||||
}
|
||||
|
||||
var backupResponseObserver: NSObjectProtocol!
|
||||
backupResponseObserver = NotificationCenter.default.addObserver(forName: SideStoreAppDelegate.appBackupDidFinish, object: nil, queue: nil) { [weak self] notification in
|
||||
self?.timeoutTimer?.invalidate()
|
||||
|
||||
let result = notification.userInfo?[SideStoreAppDelegate.appBackupResultKey] as? Result<Void, Error> ?? .failure(OperationError.unknownResult)
|
||||
self?.finish(result)
|
||||
|
||||
NotificationCenter.default.removeObserver(backupResponseObserver!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// DeactivateAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/4/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import minimuxer
|
||||
import MiniMuxerSwift
|
||||
import RoxasUIKit
|
||||
import SideKit
|
||||
|
||||
@objc(DeactivateAppOperation)
|
||||
final class DeactivateAppOperation: ResultOperation<InstalledApp> {
|
||||
let app: InstalledApp
|
||||
let context: OperationContext
|
||||
|
||||
init(app: InstalledApp, context: OperationContext) {
|
||||
self.app = app
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
let installedApp = context.object(with: self.app.objectID) as! InstalledApp
|
||||
let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier }
|
||||
let allIdentifiers = [installedApp.resignedBundleIdentifier] + appExtensionProfiles
|
||||
|
||||
for profile in allIdentifiers {
|
||||
do {
|
||||
let res = try remove_provisioning_profile(id: profile)
|
||||
if case let Uhoh.Bad(code) = res {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
}
|
||||
} catch let Uhoh.Bad(code) {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
} catch {
|
||||
self.finish(.failure(ALTServerError.unknownResponse))
|
||||
}
|
||||
}
|
||||
|
||||
self.progress.completedUnitCount += 1
|
||||
installedApp.isActive = false
|
||||
self.finish(.success(installedApp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// DownloadAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/10/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RoxasUIKit
|
||||
|
||||
import AltSign
|
||||
import SideKit
|
||||
import SideStoreCore
|
||||
import Shared
|
||||
|
||||
private extension DownloadAppOperation {
|
||||
struct DependencyError: ALTLocalizedError {
|
||||
let dependency: Dependency
|
||||
let error: Error
|
||||
|
||||
var failure: String? {
|
||||
String(format: NSLocalizedString("Could not download “%@”.", comment: ""), dependency.preferredFilename)
|
||||
}
|
||||
|
||||
var underlyingError: Error? {
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(DownloadAppOperation)
|
||||
final class DownloadAppOperation: ResultOperation<ALTApplication> {
|
||||
let app: AppProtocol
|
||||
let context: AppOperationContext
|
||||
|
||||
private let bundleIdentifier: String
|
||||
private var sourceURL: URL?
|
||||
private let destinationURL: URL
|
||||
|
||||
private let session = URLSession(configuration: .default)
|
||||
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
init(app: AppProtocol, destinationURL: URL, context: AppOperationContext) {
|
||||
self.app = app
|
||||
self.context = context
|
||||
|
||||
bundleIdentifier = app.bundleIdentifier
|
||||
sourceURL = app.url
|
||||
self.destinationURL = destinationURL
|
||||
|
||||
super.init()
|
||||
|
||||
// App = 3, Dependencies = 1
|
||||
progress.totalUnitCount = 4
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
print("Downloading App:", bundleIdentifier)
|
||||
|
||||
guard let sourceURL = sourceURL else { return finish(.failure(OperationError.appNotFound)) }
|
||||
|
||||
downloadApp(from: sourceURL) { result in
|
||||
do {
|
||||
let application = try result.get()
|
||||
|
||||
if self.context.bundleIdentifier == StoreApp.dolphinAppID, self.context.bundleIdentifier != application.bundleIdentifier {
|
||||
if var infoPlist = NSDictionary(contentsOf: application.bundle.infoPlistURL) as? [String: Any] {
|
||||
// Manually update the app's bundle identifier to match the one specified in the source.
|
||||
// This allows people who previously installed the app to still update and refresh normally.
|
||||
infoPlist[kCFBundleIdentifierKey as String] = StoreApp.dolphinAppID
|
||||
(infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true)
|
||||
}
|
||||
}
|
||||
|
||||
self.downloadDependencies(for: application) { result in
|
||||
do {
|
||||
_ = try result.get()
|
||||
|
||||
try FileManager.default.copyItem(at: application.fileURL, to: self.destinationURL, shouldReplace: true)
|
||||
|
||||
guard let copiedApplication = ALTApplication(fileURL: self.destinationURL) else { throw OperationError.invalidApp }
|
||||
self.finish(.success(copiedApplication))
|
||||
|
||||
self.progress.completedUnitCount += 1
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func finish(_ result: Result<ALTApplication, Error>) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: temporaryDirectory)
|
||||
} catch {
|
||||
print("Failed to remove DownloadAppOperation temporary directory: \(temporaryDirectory).", error)
|
||||
}
|
||||
|
||||
super.finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadAppOperation {
|
||||
func downloadApp(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void) {
|
||||
func finishOperation(_ result: Result<URL, Error>) {
|
||||
do {
|
||||
let fileURL = try result.get()
|
||||
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound }
|
||||
|
||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let appBundleURL: URL
|
||||
|
||||
if isDirectory.boolValue {
|
||||
// Directory, so assuming this is .app bundle.
|
||||
guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp }
|
||||
|
||||
appBundleURL = temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
|
||||
} else {
|
||||
// File, so assuming this is a .ipa file.
|
||||
appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
|
||||
}
|
||||
|
||||
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
|
||||
completionHandler(.success(application))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
if sourceURL.isFileURL {
|
||||
finishOperation(.success(sourceURL))
|
||||
|
||||
progress.completedUnitCount += 3
|
||||
} else {
|
||||
let downloadTask = session.downloadTask(with: sourceURL) { fileURL, response, error in
|
||||
do {
|
||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
||||
finishOperation(.success(fileURL))
|
||||
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
} catch {
|
||||
finishOperation(.failure(error))
|
||||
}
|
||||
}
|
||||
progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
|
||||
|
||||
downloadTask.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadAppOperation {
|
||||
struct AltStorePlist: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case dependencies = "ALTDependencies"
|
||||
}
|
||||
|
||||
var dependencies: [Dependency]
|
||||
}
|
||||
|
||||
struct Dependency: Decodable {
|
||||
var downloadURL: URL
|
||||
var path: String?
|
||||
|
||||
var preferredFilename: String {
|
||||
let preferredFilename = path.map { ($0 as NSString).lastPathComponent } ?? downloadURL.lastPathComponent
|
||||
return preferredFilename
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case downloadURL
|
||||
case path
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let urlString = try container.decode(String.self, forKey: .downloadURL)
|
||||
let path = try container.decodeIfPresent(String.self, forKey: .path)
|
||||
|
||||
guard let downloadURL = URL(string: urlString) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "downloadURL is not a valid URL.")
|
||||
}
|
||||
|
||||
self.downloadURL = downloadURL
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
func downloadDependencies(for application: ALTApplication, completionHandler: @escaping (Result<Set<URL>, Error>) -> Void) {
|
||||
guard FileManager.default.fileExists(atPath: application.bundle.altstorePlistURL.path) else {
|
||||
return completionHandler(.success([]))
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: application.bundle.altstorePlistURL)
|
||||
|
||||
let altstorePlist = try PropertyListDecoder().decode(AltStorePlist.self, from: data)
|
||||
|
||||
var dependencyURLs = Set<URL>()
|
||||
var dependencyError: DependencyError?
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
let progress = Progress(totalUnitCount: Int64(altstorePlist.dependencies.count), parent: self.progress, pendingUnitCount: 1)
|
||||
|
||||
for dependency in altstorePlist.dependencies {
|
||||
dispatchGroup.enter()
|
||||
|
||||
download(dependency, for: application, progress: progress) { result in
|
||||
switch result {
|
||||
case let .failure(error): dependencyError = error
|
||||
case let .success(fileURL): dependencyURLs.insert(fileURL)
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(qos: .userInitiated, queue: .global()) {
|
||||
if let dependencyError = dependencyError {
|
||||
completionHandler(.failure(dependencyError))
|
||||
} else {
|
||||
completionHandler(.success(dependencyURLs))
|
||||
}
|
||||
}
|
||||
} catch let error as DecodingError {
|
||||
let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not download dependencies for %@.", comment: ""), application.name))
|
||||
completionHandler(.failure(nsError))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result<URL, DependencyError>) -> Void) {
|
||||
let downloadTask = session.downloadTask(with: dependency.downloadURL) { fileURL, response, error in
|
||||
do {
|
||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
let path = dependency.path ?? dependency.preferredFilename
|
||||
let destinationURL = application.fileURL.appendingPathComponent(path)
|
||||
|
||||
let directoryURL = destinationURL.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: directoryURL.path) {
|
||||
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
|
||||
|
||||
completionHandler(.success(destinationURL))
|
||||
} catch {
|
||||
completionHandler(.failure(DependencyError(dependency: dependency, error: error)))
|
||||
}
|
||||
}
|
||||
progress.addChild(downloadTask.progress, withPendingUnitCount: 1)
|
||||
|
||||
downloadTask.resume()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// EnableJITOperation.swift
|
||||
// EnableJITOperation
|
||||
//
|
||||
// Created by Riley Testut on 9/1/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import minimuxer
|
||||
import MiniMuxerSwift
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
@available(iOS 14, *)
|
||||
protocol EnableJITContext {
|
||||
var installedApp: InstalledApp? { get }
|
||||
|
||||
var error: Error? { get }
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
final class EnableJITOperation<Context: EnableJITContext>: ResultOperation<Void> {
|
||||
let context: Context
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(context: Context) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let installedApp = context.installedApp else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
let v = minimuxer_to_operation(code: 1)
|
||||
|
||||
do {
|
||||
var x = try debug_app(app_id: installedApp.resignedBundleIdentifier)
|
||||
switch x {
|
||||
case .Good:
|
||||
self.finish(.success(()))
|
||||
case let .Bad(code):
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
}
|
||||
} catch let Uhoh.Bad(code) {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
} catch {
|
||||
self.finish(.failure(OperationError.unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// FetchAnisetteDataOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 1/7/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(FetchAnisetteDataOperation)
|
||||
final class FetchAnisetteDataOperation: ResultOperation<ALTAnisetteData> {
|
||||
let context: OperationContext
|
||||
|
||||
init(context: OperationContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
let url = AnisetteManager.currentURL
|
||||
DLOG("Anisette URL: %@", url.absoluteString)
|
||||
|
||||
let task = URLSession.shared.dataTask(with: url) { data, _, error in
|
||||
guard let data = data, error == nil else { return }
|
||||
|
||||
do {
|
||||
// make sure this JSON is in the format we expect
|
||||
// convert data to json
|
||||
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] {
|
||||
// try to read out a dictionary
|
||||
// for some reason serial number isn't needed but it doesn't work unless it has a value
|
||||
let formattedJSON: [String: String] = ["machineID": json["X-Apple-I-MD-M"]!, "oneTimePassword": json["X-Apple-I-MD"]!, "localUserID": json["X-Apple-I-MD-LU"]!, "routingInfo": json["X-Apple-I-MD-RINFO"]!, "deviceUniqueIdentifier": json["X-Mme-Device-Id"]!, "deviceDescription": json["X-MMe-Client-Info"]!, "date": json["X-Apple-I-Client-Time"]!, "locale": json["X-Apple-Locale"]!, "timeZone": json["X-Apple-I-TimeZone"]!, "deviceSerialNumber": "1"]
|
||||
|
||||
if let anisette = ALTAnisetteData(json: formattedJSON) {
|
||||
DLOG("Anisette used: %@", formattedJSON)
|
||||
self.finish(.success(anisette))
|
||||
}
|
||||
}
|
||||
} catch let error as NSError {
|
||||
print("Failed to load: \(error.localizedDescription)")
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// FetchAppIDsOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 1/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(FetchAppIDsOperation)
|
||||
final class FetchAppIDsOperation: ResultOperation<([AppID], NSManagedObjectContext)> {
|
||||
let context: AuthenticatedOperationContext
|
||||
let managedObjectContext: NSManagedObjectContext
|
||||
|
||||
init(context: AuthenticatedOperationContext, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) {
|
||||
self.context = context
|
||||
self.managedObjectContext = managedObjectContext
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let team = context.team,
|
||||
let session = context.session
|
||||
else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { appIDs, error in
|
||||
self.managedObjectContext.perform {
|
||||
do {
|
||||
let fetchedAppIDs = try Result(appIDs, error).get()
|
||||
|
||||
guard let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), team.identifier), in: self.managedObjectContext) else { throw OperationError.notAuthenticated }
|
||||
|
||||
let fetchedIdentifiers = fetchedAppIDs.map { $0.identifier }
|
||||
|
||||
let deletedAppIDsRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
|
||||
deletedAppIDsRequest.predicate = NSPredicate(format: "%K == %@ AND NOT (%K IN %@)",
|
||||
#keyPath(AppID.team), team,
|
||||
#keyPath(AppID.identifier), fetchedIdentifiers)
|
||||
|
||||
let deletedAppIDs = try self.managedObjectContext.fetch(deletedAppIDsRequest)
|
||||
deletedAppIDs.forEach { self.managedObjectContext.delete($0) }
|
||||
|
||||
let appIDs = fetchedAppIDs.map { AppID($0, team: team, context: self.managedObjectContext) }
|
||||
self.finish(.success((appIDs, self.managedObjectContext)))
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//
|
||||
// FetchProvisioningProfilesOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 2/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(FetchProvisioningProfilesOperation)
|
||||
final class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioningProfile]> {
|
||||
let context: AppOperationContext
|
||||
|
||||
var additionalEntitlements: [ALTEntitlement: Any]?
|
||||
|
||||
private let appGroupsLock = NSLock()
|
||||
|
||||
init(context: AppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
progress.totalUnitCount = 1
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let team = context.team,
|
||||
let session = context.session
|
||||
else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
guard let app = context.app else { return finish(.failure(OperationError.appNotFound)) }
|
||||
|
||||
progress.totalUnitCount = Int64(1 + app.appExtensions.count)
|
||||
|
||||
prepareProvisioningProfile(for: app, parentApp: nil, team: team, session: session) { result in
|
||||
do {
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
let profile = try result.get()
|
||||
|
||||
var profiles = [app.bundleIdentifier: profile]
|
||||
var error: Error?
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
for appExtension in app.appExtensions {
|
||||
dispatchGroup.enter()
|
||||
|
||||
self.prepareProvisioningProfile(for: appExtension, parentApp: app, team: team, session: session) { result in
|
||||
switch result {
|
||||
case let .failure(e): error = e
|
||||
case let .success(profile): profiles[appExtension.bundleIdentifier] = profile
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
|
||||
self.progress.completedUnitCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .global()) {
|
||||
if let error = error {
|
||||
self.finish(.failure(error))
|
||||
} else {
|
||||
self.finish(.success(profiles))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func process<T>(_ result: Result<T, Error>) -> T? {
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
finish(.failure(error))
|
||||
return nil
|
||||
|
||||
case let .success(value):
|
||||
guard !isCancelled else {
|
||||
finish(.failure(OperationError.cancelled))
|
||||
return nil
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FetchProvisioningProfilesOperation {
|
||||
func prepareProvisioningProfile(for app: ALTApplication, parentApp: ALTApplication?, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void) {
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
|
||||
let preferredBundleID: String?
|
||||
|
||||
// Check if we have already installed this app with this team before.
|
||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier)
|
||||
if let installedApp = InstalledApp.first(satisfying: predicate, in: context) {
|
||||
// Teams match if installedApp.team has same identifier as team,
|
||||
// or if installedApp.team is nil but resignedBundleIdentifier contains the team's identifier.
|
||||
let teamsMatch = installedApp.team?.identifier == team.identifier || (installedApp.team == nil && installedApp.resignedBundleIdentifier.contains(team.identifier))
|
||||
|
||||
// #if DEBUG
|
||||
//
|
||||
// if app.isAltStoreApp
|
||||
// {
|
||||
// // Use legacy bundle ID format for AltStore.
|
||||
// preferredBundleID = teamsMatch ? installedApp.resignedBundleIdentifier : nil
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// preferredBundleID = teamsMatch ? installedApp.resignedBundleIdentifier : nil
|
||||
// }
|
||||
//
|
||||
// #else
|
||||
|
||||
if teamsMatch {
|
||||
// This app is already installed with the same team, so use the same resigned bundle identifier as before.
|
||||
// This way, if we change the identifier format (again), AltStore will continue to use
|
||||
// the old bundle identifier to prevent it from installing as a new app.
|
||||
preferredBundleID = installedApp.resignedBundleIdentifier
|
||||
} else {
|
||||
preferredBundleID = nil
|
||||
}
|
||||
|
||||
// #endif
|
||||
} else {
|
||||
preferredBundleID = nil
|
||||
}
|
||||
|
||||
let bundleID: String
|
||||
|
||||
if let preferredBundleID = preferredBundleID {
|
||||
bundleID = preferredBundleID
|
||||
} else {
|
||||
// This app isn't already installed, so create the resigned bundle identifier ourselves.
|
||||
// Or, if the app _is_ installed but with a different team, we need to create a new
|
||||
// bundle identifier anyway to prevent collisions with the previous team.
|
||||
let parentBundleID = parentApp?.bundleIdentifier ?? app.bundleIdentifier
|
||||
let updatedParentBundleID: String
|
||||
|
||||
if app.isAltStoreApp {
|
||||
// Use legacy bundle ID format for AltStore (and its extensions).
|
||||
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
|
||||
} else {
|
||||
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
|
||||
}
|
||||
|
||||
bundleID = app.bundleIdentifier.replacingOccurrences(of: parentBundleID, with: updatedParentBundleID)
|
||||
}
|
||||
|
||||
let preferredName: String
|
||||
|
||||
if let parentApp = parentApp {
|
||||
preferredName = parentApp.name + " " + app.name
|
||||
} else {
|
||||
preferredName = app.name
|
||||
}
|
||||
|
||||
// Register
|
||||
self.registerAppID(for: app, name: preferredName, bundleIdentifier: bundleID, team: team, session: session) { result in
|
||||
switch result {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case let .success(appID):
|
||||
|
||||
// Update features
|
||||
self.updateFeatures(for: appID, app: app, team: team, session: session) { result in
|
||||
switch result {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case let .success(appID):
|
||||
|
||||
// Update app groups
|
||||
self.updateAppGroups(for: appID, app: app, team: team, session: session) { result in
|
||||
switch result {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case let .success(appID):
|
||||
|
||||
// Fetch Provisioning Profile
|
||||
self.fetchProvisioningProfile(for: appID, team: team, session: session) { result in
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerAppID(for application: ALTApplication, name: String, bundleIdentifier: String, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void) {
|
||||
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { appIDs, error in
|
||||
do {
|
||||
let appIDs = try Result(appIDs, error).get()
|
||||
|
||||
if let appID = appIDs.first(where: { $0.bundleIdentifier.lowercased() == bundleIdentifier.lowercased() }) {
|
||||
completionHandler(.success(appID))
|
||||
} else {
|
||||
let requiredAppIDs = 1 + application.appExtensions.count
|
||||
let availableAppIDs = max(0, Team.maximumFreeAppIDs - appIDs.count)
|
||||
|
||||
let sortedExpirationDates = appIDs.compactMap { $0.expirationDate }.sorted(by: { $0 < $1 })
|
||||
|
||||
if team.type == .free {
|
||||
if requiredAppIDs > availableAppIDs {
|
||||
if let expirationDate = sortedExpirationDates.first {
|
||||
throw OperationError.maximumAppIDLimitReached(application: application, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, nextExpirationDate: expirationDate)
|
||||
} else {
|
||||
throw ALTAppleAPIError(.maximumAppIDLimitReached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ALTAppleAPI.shared.addAppID(withName: name, bundleIdentifier: bundleIdentifier, team: team, session: session) { appID, error in
|
||||
do {
|
||||
do {
|
||||
let appID = try Result(appID, error).get()
|
||||
completionHandler(.success(appID))
|
||||
} catch ALTAppleAPIError.maximumAppIDLimitReached {
|
||||
if let expirationDate = sortedExpirationDates.first {
|
||||
throw OperationError.maximumAppIDLimitReached(application: application, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, nextExpirationDate: expirationDate)
|
||||
} else {
|
||||
throw ALTAppleAPIError(.maximumAppIDLimitReached)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void) {
|
||||
var entitlements = app.entitlements
|
||||
for (key, value) in additionalEntitlements ?? [:] {
|
||||
entitlements[key] = value
|
||||
}
|
||||
|
||||
let requiredFeatures = entitlements.compactMap { entitlement, value -> (ALTFeature, Any)? in
|
||||
guard let feature = ALTFeature(entitlement: entitlement) else { return nil }
|
||||
return (feature, value)
|
||||
}
|
||||
|
||||
var features = requiredFeatures.reduce(into: [ALTFeature: Any]()) { $0[$1.0] = $1.1 }
|
||||
|
||||
if let applicationGroups = entitlements[.appGroups] as? [String], !applicationGroups.isEmpty {
|
||||
// App uses app groups, so assign `true` to enable the feature.
|
||||
features[.appGroups] = true
|
||||
} else {
|
||||
// App has no app groups, so assign `false` to disable the feature.
|
||||
features[.appGroups] = false
|
||||
}
|
||||
|
||||
var updateFeatures = false
|
||||
|
||||
// Determine whether the required features are already enabled for the AppID.
|
||||
for (feature, value) in features {
|
||||
if let appIDValue = appID.features[feature] as AnyObject?, (value as AnyObject).isEqual(appIDValue) {
|
||||
// AppID already has this feature enabled and the values are the same.
|
||||
continue
|
||||
} else if appID.features[feature] == nil, let shouldEnableFeature = value as? Bool, !shouldEnableFeature {
|
||||
// AppID doesn't already have this feature enabled, but we want it disabled anyway.
|
||||
continue
|
||||
} else {
|
||||
// AppID either doesn't have this feature enabled or the value has changed,
|
||||
// so we need to update it to reflect new values.
|
||||
updateFeatures = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if updateFeatures {
|
||||
let appID = appID.copy() as! ALTAppID
|
||||
appID.features = features
|
||||
|
||||
ALTAppleAPI.shared.update(appID, team: team, session: session) { appID, error in
|
||||
completionHandler(Result(appID, error))
|
||||
}
|
||||
} else {
|
||||
completionHandler(.success(appID))
|
||||
}
|
||||
}
|
||||
|
||||
func updateAppGroups(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void) {
|
||||
var entitlements = app.entitlements
|
||||
for (key, value) in additionalEntitlements ?? [:] {
|
||||
entitlements[key] = value
|
||||
}
|
||||
|
||||
guard var applicationGroups = entitlements[.appGroups] as? [String], !applicationGroups.isEmpty else {
|
||||
// Assigning an App ID to an empty app group array fails,
|
||||
// so just do nothing if there are no app groups.
|
||||
return completionHandler(.success(appID))
|
||||
}
|
||||
|
||||
if app.isAltStoreApp {
|
||||
// Potentially updating app groups for this specific AltStore.
|
||||
// Find the (unique) AltStore app group, then replace it
|
||||
// with the correct "base" app group ID.
|
||||
// Otherwise, we may append a duplicate team identifier to the end.
|
||||
if let index = applicationGroups.firstIndex(where: { $0.contains(Bundle.baseAltStoreAppGroupID) }) {
|
||||
applicationGroups[index] = Bundle.baseAltStoreAppGroupID
|
||||
} else {
|
||||
applicationGroups.append(Bundle.baseAltStoreAppGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch onto global queue to prevent appGroupsLock deadlock.
|
||||
DispatchQueue.global().async {
|
||||
// Ensure we're not concurrently fetching and updating app groups,
|
||||
// which can lead to race conditions such as adding an app group twice.
|
||||
self.appGroupsLock.lock()
|
||||
|
||||
func finish(_ result: Result<ALTAppID, Error>) {
|
||||
self.appGroupsLock.unlock()
|
||||
completionHandler(result)
|
||||
}
|
||||
|
||||
ALTAppleAPI.shared.fetchAppGroups(for: team, session: session) { groups, error in
|
||||
switch Result(groups, error) {
|
||||
case let .failure(error): finish(.failure(error))
|
||||
case let .success(fetchedGroups):
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
var groups = [ALTAppGroup]()
|
||||
var errors = [Error]()
|
||||
|
||||
for groupIdentifier in applicationGroups {
|
||||
let adjustedGroupIdentifier = groupIdentifier + "." + team.identifier
|
||||
|
||||
if let group = fetchedGroups.first(where: { $0.groupIdentifier == adjustedGroupIdentifier }) {
|
||||
groups.append(group)
|
||||
} else {
|
||||
dispatchGroup.enter()
|
||||
|
||||
// Not all characters are allowed in group names, so we replace periods with spaces (like Apple does).
|
||||
let name = "AltStore " + groupIdentifier.replacingOccurrences(of: ".", with: " ")
|
||||
|
||||
ALTAppleAPI.shared.addAppGroup(withName: name, groupIdentifier: adjustedGroupIdentifier, team: team, session: session) { group, error in
|
||||
switch Result(group, error) {
|
||||
case let .success(group): groups.append(group)
|
||||
case let .failure(error): errors.append(error)
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .global()) {
|
||||
if let error = errors.first {
|
||||
finish(.failure(error))
|
||||
} else {
|
||||
ALTAppleAPI.shared.assign(appID, to: Array(groups), team: team, session: session) { success, error in
|
||||
let result = Result(success, error)
|
||||
finish(result.map { _ in appID })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void) {
|
||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { profile, error in
|
||||
switch Result(profile, error) {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case let .success(profile):
|
||||
|
||||
// Delete existing profile
|
||||
ALTAppleAPI.shared.delete(profile, for: team, session: session) { success, error in
|
||||
switch Result(success, error) {
|
||||
case let .failure(error): completionHandler(.failure(error))
|
||||
case .success:
|
||||
|
||||
// Fetch new provisiong profile
|
||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { profile, error in
|
||||
completionHandler(Result(profile, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// FetchSourceOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/30/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(FetchSourceOperation)
|
||||
final class FetchSourceOperation: ResultOperation<Source> {
|
||||
let sourceURL: URL
|
||||
let managedObjectContext: NSManagedObjectContext
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
private lazy var dateFormatter: ISO8601DateFormatter = {
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
init(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) {
|
||||
self.sourceURL = sourceURL
|
||||
self.managedObjectContext = managedObjectContext
|
||||
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.urlCache = nil
|
||||
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
let dataTask = session.dataTask(with: sourceURL) { data, response, error in
|
||||
|
||||
let childContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(withParent: self.managedObjectContext)
|
||||
childContext.mergePolicy = NSOverwriteMergePolicy
|
||||
childContext.perform {
|
||||
do {
|
||||
let (data, _) = try Result((data, response), error).get()
|
||||
|
||||
let decoder = SideStoreCore.JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder -> Date in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let text = try container.decode(String.self)
|
||||
|
||||
// Full ISO8601 Format.
|
||||
self.dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withTimeZone]
|
||||
if let date = self.dateFormatter.date(from: text) {
|
||||
return date
|
||||
}
|
||||
|
||||
// Just date portion of ISO8601.
|
||||
self.dateFormatter.formatOptions = [.withFullDate]
|
||||
if let date = self.dateFormatter.date(from: text) {
|
||||
return date
|
||||
}
|
||||
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.")
|
||||
}
|
||||
|
||||
decoder.managedObjectContext = childContext
|
||||
decoder.sourceURL = self.sourceURL
|
||||
|
||||
let source = try decoder.decode(Source.self, from: data)
|
||||
let identifier = source.identifier
|
||||
|
||||
try childContext.save()
|
||||
|
||||
self.managedObjectContext.perform {
|
||||
if let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), identifier), in: self.managedObjectContext) {
|
||||
self.finish(.success(source))
|
||||
} else {
|
||||
self.finish(.failure(OperationError.noSources))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.managedObjectContext.perform {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress.addChild(dataTask.progress, withPendingUnitCount: 1)
|
||||
|
||||
dataTask.resume()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// FetchTrustedSourcesOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 4/13/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private extension URL {
|
||||
#if STAGING
|
||||
static let trustedSources = URL(string: "https://raw.githubusercontent.com/SideStore/SideStore/develop/trustedapps.json")!
|
||||
#else
|
||||
static let trustedSources = URL(string: "https://raw.githubusercontent.com/SideStore/SideStore/develop/trustedapps.json")!
|
||||
#endif
|
||||
}
|
||||
|
||||
public extension FetchTrustedSourcesOperation {
|
||||
public struct TrustedSource: Decodable {
|
||||
public var identifier: String
|
||||
public var sourceURL: URL?
|
||||
}
|
||||
|
||||
private struct Response: Decodable {
|
||||
var version: Int
|
||||
var sources: [FetchTrustedSourcesOperation.TrustedSource]
|
||||
}
|
||||
}
|
||||
|
||||
public final class FetchTrustedSourcesOperation: ResultOperation<[FetchTrustedSourcesOperation.TrustedSource]> {
|
||||
public override func main() {
|
||||
super.main()
|
||||
|
||||
let dataTask = URLSession.shared.dataTask(with: .trustedSources) { data, response, error in
|
||||
do {
|
||||
if let response = response as? HTTPURLResponse {
|
||||
guard response.statusCode != 404 else {
|
||||
self.finish(.failure(URLError(.fileDoesNotExist, userInfo: [NSURLErrorKey: URL.trustedSources])))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let data = data else { throw error! }
|
||||
|
||||
let response = try Foundation.JSONDecoder().decode(Response.self, from: data)
|
||||
self.finish(.success(response.sources))
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
dataTask.resume()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// InstallAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
import MiniMuxerSwift
|
||||
import minimuxer
|
||||
|
||||
@objc(InstallAppOperation)
|
||||
final class InstallAppOperation: ResultOperation<InstalledApp> {
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
private var didCleanUp = false
|
||||
|
||||
init(context: InstallAppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
progress.totalUnitCount = 100
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let certificate = context.certificate,
|
||||
let resignedApp = context.resignedApp
|
||||
else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
backgroundContext.perform {
|
||||
/* App */
|
||||
let installedApp: InstalledApp
|
||||
|
||||
// Fetch + update rather than insert + resolve merge conflicts to prevent potential context-level conflicts.
|
||||
if let app = InstalledApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), self.context.bundleIdentifier), in: backgroundContext) {
|
||||
installedApp = app
|
||||
} else {
|
||||
installedApp = InstalledApp(resignedApp: resignedApp, originalBundleIdentifier: self.context.bundleIdentifier, certificateSerialNumber: certificate.serialNumber, context: backgroundContext)
|
||||
}
|
||||
|
||||
installedApp.update(resignedApp: resignedApp, certificateSerialNumber: certificate.serialNumber)
|
||||
installedApp.needsResign = false
|
||||
|
||||
if let team = DatabaseManager.shared.activeTeam(in: backgroundContext) {
|
||||
installedApp.team = team
|
||||
}
|
||||
|
||||
/* App Extensions */
|
||||
var installedExtensions = Set<InstalledExtension>()
|
||||
|
||||
if
|
||||
let bundle = Bundle(url: resignedApp.fileURL),
|
||||
let directory = bundle.builtInPlugInsURL,
|
||||
let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants])
|
||||
{
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard let appExtensionBundle = Bundle(url: fileURL) else { continue }
|
||||
guard let appExtension = ALTApplication(fileURL: appExtensionBundle.bundleURL) else { continue }
|
||||
|
||||
let parentBundleID = self.context.bundleIdentifier
|
||||
let resignedParentBundleID = resignedApp.bundleIdentifier
|
||||
|
||||
let resignedBundleID = appExtension.bundleIdentifier
|
||||
let originalBundleID = resignedBundleID.replacingOccurrences(of: resignedParentBundleID, with: parentBundleID)
|
||||
|
||||
print("`parentBundleID`: \(parentBundleID)")
|
||||
print("`resignedParentBundleID`: \(resignedParentBundleID)")
|
||||
print("`resignedBundleID`: \(resignedBundleID)")
|
||||
print("`originalBundleID`: \(originalBundleID)")
|
||||
|
||||
let installedExtension: InstalledExtension
|
||||
|
||||
if let appExtension = installedApp.appExtensions.first(where: { $0.bundleIdentifier == originalBundleID }) {
|
||||
installedExtension = appExtension
|
||||
} else {
|
||||
installedExtension = InstalledExtension(resignedAppExtension: appExtension, originalBundleIdentifier: originalBundleID, context: backgroundContext)
|
||||
}
|
||||
|
||||
installedExtension.update(resignedAppExtension: appExtension)
|
||||
|
||||
installedExtensions.insert(installedExtension)
|
||||
}
|
||||
}
|
||||
|
||||
installedApp.appExtensions = installedExtensions
|
||||
|
||||
self.context.beginInstallationHandler?(installedApp)
|
||||
|
||||
// Temporary directory and resigned .ipa no longer needed, so delete them now to ensure AltStore doesn't quit before we get the chance to.
|
||||
self.cleanUp()
|
||||
|
||||
var activeProfiles: Set<String>?
|
||||
if let sideloadedAppsLimit = UserDefaults.standard.activeAppsLimit {
|
||||
// When installing these new profiles, AltServer will remove all non-active profiles to ensure we remain under limit.
|
||||
|
||||
let fetchRequest = InstalledApp.activeAppsFetchRequest()
|
||||
fetchRequest.includesPendingChanges = false
|
||||
|
||||
var activeApps = InstalledApp.fetch(fetchRequest, in: backgroundContext)
|
||||
if !activeApps.contains(installedApp) {
|
||||
let activeAppsCount = activeApps.map { $0.requiredActiveSlots }.reduce(0, +)
|
||||
|
||||
let availableActiveApps = max(sideloadedAppsLimit - activeAppsCount, 0)
|
||||
if installedApp.requiredActiveSlots <= availableActiveApps {
|
||||
// This app has not been explicitly activated, but there are enough slots available,
|
||||
// so implicitly activate it.
|
||||
installedApp.isActive = true
|
||||
activeApps.append(installedApp)
|
||||
} else {
|
||||
installedApp.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
activeProfiles = Set(activeApps.flatMap { installedApp -> [String] in
|
||||
let appExtensionProfiles = installedApp.appExtensions.map { $0.resignedBundleIdentifier }
|
||||
return [installedApp.resignedBundleIdentifier] + appExtensionProfiles
|
||||
})
|
||||
}
|
||||
|
||||
let ns_bundle = NSString(string: installedApp.bundleIdentifier)
|
||||
let ns_bundle_ptr = UnsafeMutablePointer<CChar>(mutating: ns_bundle.utf8String)
|
||||
|
||||
let res = minimuxer_install_ipa(ns_bundle_ptr)
|
||||
if res == 0 {
|
||||
installedApp.refreshedDate = Date()
|
||||
self.finish(.success(installedApp))
|
||||
|
||||
} else {
|
||||
self.finish(.failure(minimuxer_to_operation(code: res)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func finish(_ result: Result<InstalledApp, Error>) {
|
||||
cleanUp()
|
||||
|
||||
// Only remove refreshed IPA when finished.
|
||||
if let app = context.app {
|
||||
let fileURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
} catch {
|
||||
print("Failed to remove refreshed .ipa:", error)
|
||||
}
|
||||
}
|
||||
|
||||
super.finish(result)
|
||||
}
|
||||
}
|
||||
|
||||
private extension InstallAppOperation {
|
||||
func cleanUp() {
|
||||
guard !didCleanUp else { return }
|
||||
didCleanUp = true
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: context.temporaryDirectory)
|
||||
} catch {
|
||||
print("Failed to remove temporary directory.", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// Operation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/7/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RoxasUIKit
|
||||
|
||||
public class ResultOperation<ResultType>: Operation {
|
||||
var resultHandler: ((Result<ResultType, Error>) -> Void)?
|
||||
|
||||
@available(*, unavailable)
|
||||
public override func finish() {
|
||||
super.finish()
|
||||
}
|
||||
|
||||
func finish(_ result: Result<ResultType, Error>) {
|
||||
guard !isFinished else { return }
|
||||
|
||||
if isCancelled {
|
||||
resultHandler?(.failure(OperationError.cancelled))
|
||||
} else {
|
||||
resultHandler?(result)
|
||||
}
|
||||
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
|
||||
public class Operation: RSTOperation, ProgressReporting {
|
||||
public let progress = Progress.discreteProgress(totalUnitCount: 1)
|
||||
|
||||
private var backgroundTaskID: UIBackgroundTaskIdentifier?
|
||||
|
||||
public override var isAsynchronous: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
progress.cancellationHandler = { [weak self] in self?.cancel() }
|
||||
}
|
||||
|
||||
public override func cancel() {
|
||||
super.cancel()
|
||||
|
||||
if !progress.isCancelled {
|
||||
progress.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
public override func main() {
|
||||
super.main()
|
||||
|
||||
let name = "com.altstore." + NSStringFromClass(type(of: self))
|
||||
backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||
guard let backgroundTask = self?.backgroundTaskID else { return }
|
||||
|
||||
self?.cancel()
|
||||
|
||||
UIApplication.shared.endBackgroundTask(backgroundTask)
|
||||
self?.backgroundTaskID = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
public override func finish() {
|
||||
guard !isFinished else { return }
|
||||
|
||||
super.finish()
|
||||
|
||||
if let backgroundTaskID = backgroundTaskID {
|
||||
UIApplication.shared.endBackgroundTask(backgroundTaskID)
|
||||
self.backgroundTaskID = .invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// Contexts.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/20/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
|
||||
public class OperationContext {
|
||||
var error: Error?
|
||||
|
||||
var presentingViewController: UIViewController?
|
||||
|
||||
let operations: NSHashTable<Foundation.Operation>
|
||||
|
||||
public init(error: Error? = nil, operations: [Foundation.Operation] = []) {
|
||||
self.error = error
|
||||
|
||||
self.operations = NSHashTable<Foundation.Operation>.weakObjects()
|
||||
for operation in operations {
|
||||
self.operations.add(operation)
|
||||
}
|
||||
}
|
||||
|
||||
public convenience init(context: OperationContext) {
|
||||
self.init(error: context.error, operations: context.operations.allObjects)
|
||||
}
|
||||
}
|
||||
|
||||
public final class AuthenticatedOperationContext: OperationContext {
|
||||
var session: ALTAppleAPISession?
|
||||
|
||||
var team: ALTTeam?
|
||||
var certificate: ALTCertificate?
|
||||
|
||||
weak var authenticationOperation: AuthenticationOperation?
|
||||
|
||||
public convenience init(context: AuthenticatedOperationContext) {
|
||||
self.init(error: context.error, operations: context.operations.allObjects)
|
||||
|
||||
session = context.session
|
||||
team = context.team
|
||||
certificate = context.certificate
|
||||
authenticationOperation = context.authenticationOperation
|
||||
}
|
||||
}
|
||||
|
||||
@dynamicMemberLookup
|
||||
class AppOperationContext {
|
||||
let bundleIdentifier: String
|
||||
let authenticatedContext: AuthenticatedOperationContext
|
||||
|
||||
var app: ALTApplication?
|
||||
var provisioningProfiles: [String: ALTProvisioningProfile]?
|
||||
|
||||
var isFinished = false
|
||||
|
||||
var error: Error? {
|
||||
get {
|
||||
_error ?? self.authenticatedContext.error
|
||||
}
|
||||
set {
|
||||
_error = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var _error: Error?
|
||||
|
||||
init(bundleIdentifier: String, authenticatedContext: AuthenticatedOperationContext) {
|
||||
self.bundleIdentifier = bundleIdentifier
|
||||
self.authenticatedContext = authenticatedContext
|
||||
}
|
||||
|
||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<AuthenticatedOperationContext, T>) -> T {
|
||||
self.authenticatedContext[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
|
||||
class InstallAppOperationContext: AppOperationContext {
|
||||
lazy var temporaryDirectory: URL = {
|
||||
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
do { try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) } catch { self.error = error }
|
||||
|
||||
return temporaryDirectory
|
||||
}()
|
||||
|
||||
var resignedApp: ALTApplication?
|
||||
var installedApp: InstalledApp? {
|
||||
didSet {
|
||||
installedAppContext = installedApp?.managedObjectContext
|
||||
}
|
||||
}
|
||||
|
||||
private var installedAppContext: NSManagedObjectContext?
|
||||
|
||||
var beginInstallationHandler: ((InstalledApp) -> Void)?
|
||||
|
||||
var alternateIconURL: URL?
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// OperationError.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/7/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AltSign
|
||||
import Foundation
|
||||
|
||||
enum OperationError: LocalizedError {
|
||||
static let domain = OperationError.unknown._domain
|
||||
|
||||
case unknown
|
||||
case unknownResult
|
||||
case cancelled
|
||||
case timedOut
|
||||
|
||||
case notAuthenticated
|
||||
case appNotFound
|
||||
|
||||
case unknownUDID
|
||||
|
||||
case invalidApp
|
||||
case invalidParameters
|
||||
|
||||
case maximumAppIDLimitReached(application: ALTApplication, requiredAppIDs: Int, availableAppIDs: Int, nextExpirationDate: Date)
|
||||
|
||||
case noSources
|
||||
|
||||
case openAppFailed(name: String)
|
||||
case missingAppGroup
|
||||
|
||||
case noDevice
|
||||
case createService(name: String)
|
||||
case getFromDevice(name: String)
|
||||
case setArgument(name: String)
|
||||
case afc
|
||||
case install
|
||||
case uninstall
|
||||
case lookupApps
|
||||
case detach
|
||||
case functionArguments
|
||||
case profileInstall
|
||||
case noConnection
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .unknown: return NSLocalizedString("An unknown error occured.", comment: "")
|
||||
case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "")
|
||||
case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "")
|
||||
case .timedOut: return NSLocalizedString("The operation timed out.", comment: "")
|
||||
case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "")
|
||||
case .appNotFound: return NSLocalizedString("App not found.", comment: "")
|
||||
case .unknownUDID: return NSLocalizedString("Unknown device UDID.", comment: "")
|
||||
case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "")
|
||||
case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "")
|
||||
case .noSources: return NSLocalizedString("There are no SideStore sources.", comment: "")
|
||||
case let .openAppFailed(name): return String(format: NSLocalizedString("SideStore was denied permission to launch %@.", comment: ""), name)
|
||||
case .missingAppGroup: return NSLocalizedString("SideStore's shared app group could not be found.", comment: "")
|
||||
case .maximumAppIDLimitReached: return NSLocalizedString("Cannot register more than 10 App IDs.", comment: "")
|
||||
case .noDevice: return NSLocalizedString("Cannot fetch the device from the muxer", comment: "")
|
||||
case let .createService(name): return String(format: NSLocalizedString("Cannot start a %@ server on the device.", comment: ""), name)
|
||||
case let .getFromDevice(name): return String(format: NSLocalizedString("Cannot fetch %@ from the device.", comment: ""), name)
|
||||
case let .setArgument(name): return String(format: NSLocalizedString("Cannot set %@ on the device.", comment: ""), name)
|
||||
case .afc: return NSLocalizedString("AFC was unable to manage files on the device", comment: "")
|
||||
case .install: return NSLocalizedString("Unable to install the app from the staging directory", comment: "")
|
||||
case .uninstall: return NSLocalizedString("Unable to uninstall the app", comment: "")
|
||||
case .lookupApps: return NSLocalizedString("Unable to fetch apps from the device", comment: "")
|
||||
case .detach: return NSLocalizedString("Unable to detach from the app's process", comment: "")
|
||||
case .functionArguments: return NSLocalizedString("A function was passed invalid arguments", comment: "")
|
||||
case .profileInstall: return NSLocalizedString("Unable to manage profiles on the device", comment: "")
|
||||
case .noConnection: return NSLocalizedString("Unable to connect to the device, make sure Wireguard is enabled and you're connected to WiFi", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case let .maximumAppIDLimitReached(application, requiredAppIDs, availableAppIDs, date):
|
||||
let baseMessage = NSLocalizedString("Delete sideloaded apps to free up App ID slots.", comment: "")
|
||||
let message: String
|
||||
|
||||
if requiredAppIDs > 1 {
|
||||
let availableText: String
|
||||
|
||||
switch availableAppIDs {
|
||||
case 0: availableText = NSLocalizedString("none are available", comment: "")
|
||||
case 1: availableText = NSLocalizedString("only 1 is available", comment: "")
|
||||
default: availableText = String(format: NSLocalizedString("only %@ are available", comment: ""), NSNumber(value: availableAppIDs))
|
||||
}
|
||||
|
||||
let prefixMessage = String(format: NSLocalizedString("%@ requires %@ App IDs, but %@.", comment: ""), application.name, NSNumber(value: requiredAppIDs), availableText)
|
||||
message = prefixMessage + " " + baseMessage
|
||||
} else {
|
||||
let dateComponents = Calendar.current.dateComponents([.day, .hour, .minute], from: Date(), to: date)
|
||||
|
||||
let dateComponentsFormatter = DateComponentsFormatter()
|
||||
dateComponentsFormatter.maximumUnitCount = 1
|
||||
dateComponentsFormatter.unitsStyle = .full
|
||||
|
||||
let remainingTime = dateComponentsFormatter.string(from: dateComponents)!
|
||||
|
||||
let remainingTimeMessage = String(format: NSLocalizedString("You can register another App ID in %@.", comment: ""), remainingTime)
|
||||
message = baseMessage + " " + remainingTimeMessage
|
||||
}
|
||||
|
||||
return message
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minimuxer_to_operation(code: Int32) -> OperationError {
|
||||
switch code {
|
||||
case 1:
|
||||
return OperationError.noDevice
|
||||
case 2:
|
||||
return OperationError.createService(name: "debug")
|
||||
case 3:
|
||||
return OperationError.createService(name: "instproxy")
|
||||
case 4:
|
||||
return OperationError.getFromDevice(name: "installed apps")
|
||||
case 5:
|
||||
return OperationError.getFromDevice(name: "path to the app")
|
||||
case 6:
|
||||
return OperationError.getFromDevice(name: "bundle path")
|
||||
case 7:
|
||||
return OperationError.setArgument(name: "max packet")
|
||||
case 8:
|
||||
return OperationError.setArgument(name: "working directory")
|
||||
case 9:
|
||||
return OperationError.setArgument(name: "argv")
|
||||
case 10:
|
||||
return OperationError.getFromDevice(name: "launch success")
|
||||
case 11:
|
||||
return OperationError.detach
|
||||
case 12:
|
||||
return OperationError.functionArguments
|
||||
case 13:
|
||||
return OperationError.createService(name: "AFC")
|
||||
case 14:
|
||||
return OperationError.afc
|
||||
case 15:
|
||||
return OperationError.install
|
||||
case 16:
|
||||
return OperationError.uninstall
|
||||
case 17:
|
||||
return OperationError.createService(name: "misagent")
|
||||
case 18:
|
||||
return OperationError.profileInstall
|
||||
case 19:
|
||||
return OperationError.profileInstall
|
||||
case 20:
|
||||
return OperationError.noConnection
|
||||
default:
|
||||
return OperationError.unknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//
|
||||
// PatchAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/13/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AppleArchive
|
||||
import Combine
|
||||
import System
|
||||
import UIKit
|
||||
import SidePatcher
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@available(iOS 14, *)
|
||||
protocol PatchAppContext {
|
||||
var bundleIdentifier: String { get }
|
||||
var temporaryDirectory: URL { get }
|
||||
|
||||
var resignedApp: ALTApplication? { get }
|
||||
var error: Error? { get }
|
||||
}
|
||||
|
||||
enum PatchAppError: LocalizedError {
|
||||
case unsupportedOperatingSystemVersion(OperatingSystemVersion)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unsupportedOperatingSystemVersion(osVersion):
|
||||
var osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion)"
|
||||
if osVersion.patchVersion != 0 {
|
||||
osVersionString += ".\(osVersion.patchVersion)"
|
||||
}
|
||||
|
||||
let errorDescription = String(format: NSLocalizedString("The OTA download URL for iOS %@ could not be determined.", comment: ""), osVersionString)
|
||||
return errorDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OTAUpdate {
|
||||
var url: URL
|
||||
var archivePath: String
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
public final class PatchAppOperation: ResultOperation<Void> {
|
||||
let context: PatchAppContext
|
||||
|
||||
var progressHandler: ((Progress, String) -> Void)?
|
||||
|
||||
private let appPatcher = SideAppPatcher()
|
||||
private lazy var patchDirectory: URL = self.context.temporaryDirectory.appendingPathComponent("Patch", isDirectory: true)
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(context: PatchAppContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
progress.totalUnitCount = 100
|
||||
}
|
||||
|
||||
public override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let resignedApp = context.resignedApp else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
progressHandler?(progress, NSLocalizedString("Downloading iOS firmware...", comment: ""))
|
||||
|
||||
cancellable = fetchOTAUpdate()
|
||||
.flatMap { self.downloadArchive(from: $0) }
|
||||
.flatMap { self.extractSpotlightFromArchive(at: $0) }
|
||||
.flatMap { self.patch(resignedApp, withBinaryAt: $0) }
|
||||
.tryMap { try FileManager.default.zipAppBundle(at: $0) }
|
||||
.tryMap { fileURL in
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL)
|
||||
|
||||
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
case let .failure(error): self.finish(.failure(error))
|
||||
case .finished: self.finish(.success(()))
|
||||
}
|
||||
} receiveValue: { _ in }
|
||||
}
|
||||
|
||||
public override func cancel() {
|
||||
super.cancel()
|
||||
|
||||
cancellable?.cancel()
|
||||
cancellable = nil
|
||||
}
|
||||
}
|
||||
|
||||
private let ALTFragmentZipCallback: @convention(c) (UInt32) -> Void = { percentageComplete in
|
||||
guard let progress = Progress.current() else { return }
|
||||
|
||||
if percentageComplete == 100, progress.completedUnitCount == 0 {
|
||||
// Ignore first percentageComplete, which is always 100.
|
||||
return
|
||||
}
|
||||
|
||||
progress.completedUnitCount = Int64(percentageComplete)
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
private extension PatchAppOperation {
|
||||
func fetchOTAUpdate() -> AnyPublisher<OTAUpdate, Error> {
|
||||
Just(()).tryMap {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
switch (osVersion.majorVersion, osVersion.minorVersion) {
|
||||
case (14, 3):
|
||||
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2020WinterFCS/patches/001-87330/99E29969-F6B6-422A-B946-70DE2E2D73BE/com_apple_MobileAsset_SoftwareUpdate/67f9e42f5e57a20e0a87eaf81b69dd2a61311d3f.zip")!,
|
||||
archivePath: "AssetData/payloadv2/payload.042")
|
||||
|
||||
case (14, 4):
|
||||
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2021WinterFCS/patches/001-98606/43AF99A1-F286-43B1-A101-F9F856EA395A/com_apple_MobileAsset_SoftwareUpdate/c4985c32c344beb7b49c61919b4e39d1fd336c90.zip")!,
|
||||
archivePath: "AssetData/payloadv2/payload.042")
|
||||
|
||||
case (14, 5):
|
||||
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2021SpringFCS/patches/061-84483/AB525139-066E-46F8-8E85-DCE802C03BA8/com_apple_MobileAsset_SoftwareUpdate/788573ae93113881db04269acedeecabbaa643e3.zip")!,
|
||||
archivePath: "AssetData/payloadv2/payload.043")
|
||||
|
||||
default: throw PatchAppError.unsupportedOperatingSystemVersion(osVersion)
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func downloadArchive(from update: OTAUpdate) -> AnyPublisher<URL, Error> {
|
||||
Just(()).tryMap {
|
||||
#if targetEnvironment(simulator)
|
||||
throw PatchAppError.unsupportedOperatingSystemVersion(ProcessInfo.processInfo.operatingSystemVersion)
|
||||
#else
|
||||
|
||||
try FileManager.default.createDirectory(at: self.patchDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let archiveURL = self.patchDirectory.appendingPathComponent("ota.archive")
|
||||
try archiveURL.withUnsafeFileSystemRepresentation { archivePath in
|
||||
guard let fz = fragmentzip_open((update.url.absoluteString as NSString).utf8String!) else {
|
||||
throw URLError(.cannotConnectToHost, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The connection failed because a connection cannot be made to the host.", comment: ""),
|
||||
NSURLErrorKey: update.url])
|
||||
}
|
||||
defer { fragmentzip_close(fz) }
|
||||
|
||||
self.progress.becomeCurrent(withPendingUnitCount: 100)
|
||||
defer { self.progress.resignCurrent() }
|
||||
|
||||
guard fragmentzip_download_file(fz, update.archivePath, archivePath!, ALTFragmentZipCallback) == 0 else {
|
||||
throw URLError(.networkConnectionLost, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The connection failed because the network connection was lost.", comment: ""),
|
||||
NSURLErrorKey: update.url])
|
||||
}
|
||||
}
|
||||
|
||||
print("Downloaded OTA archive.")
|
||||
return archiveURL
|
||||
|
||||
#endif
|
||||
}
|
||||
.mapError { ($0 as NSError).withLocalizedFailure(NSLocalizedString("Could not download OTA archive.", comment: "")) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func extractSpotlightFromArchive(at archiveURL: URL) -> AnyPublisher<URL, Error> {
|
||||
Just(()).tryMap {
|
||||
#if targetEnvironment(simulator)
|
||||
throw PatchAppError.unsupportedOperatingSystemVersion(ProcessInfo.processInfo.operatingSystemVersion)
|
||||
#else
|
||||
|
||||
let spotlightPath = "Applications/Spotlight.app/Spotlight"
|
||||
let spotlightFileURL = self.patchDirectory.appendingPathComponent(spotlightPath)
|
||||
|
||||
guard let readFileStream = ArchiveByteStream.fileStream(path: FilePath(archiveURL.path), mode: .readOnly, options: [], permissions: FilePermissions(rawValue: 0o644)),
|
||||
let decompressStream = ArchiveByteStream.decompressionStream(readingFrom: readFileStream),
|
||||
let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressStream),
|
||||
let readStream = ArchiveStream.extractStream(extractingTo: FilePath(self.patchDirectory.path))
|
||||
else { throw CocoaError(.fileReadCorruptFile, userInfo: [NSURLErrorKey: archiveURL]) }
|
||||
|
||||
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: readStream) { _, filePath, _ in
|
||||
guard filePath == FilePath(spotlightPath) else { return .skip }
|
||||
return .ok
|
||||
}
|
||||
|
||||
print("Extracted Spotlight from OTA archive.")
|
||||
return spotlightFileURL
|
||||
|
||||
#endif
|
||||
}
|
||||
.mapError { ($0 as NSError).withLocalizedFailure(NSLocalizedString("Could not extract Spotlight from OTA archive.", comment: "")) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func patch(_ app: ALTApplication, withBinaryAt patchFileURL: URL) -> AnyPublisher<URL, Error> {
|
||||
Just(()).tryMap {
|
||||
// executableURL may be nil, so use infoDictionary instead to determine executable name.
|
||||
// guard let appName = app.bundle.executableURL?.lastPathComponent else { throw OperationError.invalidApp }
|
||||
guard let appName = app.bundle.infoDictionary?[kCFBundleExecutableKey as String] as? String else { throw OperationError.invalidApp }
|
||||
|
||||
let temporaryAppURL = self.patchDirectory.appendingPathComponent("Patched.app", isDirectory: true)
|
||||
try FileManager.default.copyItem(at: app.fileURL, to: temporaryAppURL)
|
||||
|
||||
let appBinaryURL = temporaryAppURL.appendingPathComponent(appName, isDirectory: false)
|
||||
try self.appPatcher.patchAppBinary(at: appBinaryURL, withBinaryAt: patchFileURL)
|
||||
|
||||
print("Patched \(app.name).")
|
||||
return temporaryAppURL
|
||||
}
|
||||
.mapError { ($0 as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), app.name)) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
//
|
||||
// PatchViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/20/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension PatchViewController {
|
||||
enum Step {
|
||||
case confirm
|
||||
case install
|
||||
case openApp
|
||||
case patchApp
|
||||
case reboot
|
||||
case refresh
|
||||
case finish
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
public final class PatchViewController: UIViewController {
|
||||
var patchApp: AnyApp?
|
||||
var installedApp: InstalledApp?
|
||||
|
||||
var completionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
private let context = AuthenticatedOperationContext()
|
||||
|
||||
private var currentStep: Step = .confirm {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonHandler: (() -> Void)?
|
||||
private var resignedApp: ALTApplication?
|
||||
|
||||
private lazy var temporaryDirectory: URL = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
private var didEnterBackgroundObservation: NSObjectProtocol?
|
||||
private weak var cancellableProgress: Progress?
|
||||
|
||||
@IBOutlet private var placeholderView: RSTPlaceholderView!
|
||||
@IBOutlet private var taskDescriptionLabel: UILabel!
|
||||
@IBOutlet private var pillButton: PillButton!
|
||||
@IBOutlet private var cancelBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private var cancelButton: UIButton!
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
isModalInPresentation = true
|
||||
|
||||
placeholderView.stackView.spacing = 20
|
||||
placeholderView.textLabel.textColor = .white
|
||||
|
||||
placeholderView.detailTextLabel.textAlignment = .left
|
||||
placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
self?.startProcess()
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
print("Failed to create temporary directory:", error)
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if installedApp != nil {
|
||||
refreshApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private extension PatchViewController {
|
||||
func update() {
|
||||
cancelButton.alpha = 0.0
|
||||
|
||||
switch currentStep {
|
||||
case .confirm:
|
||||
guard let app = patchApp else { break }
|
||||
|
||||
if UIDevice.current.isUntetheredJailbreakRequired {
|
||||
placeholderView.textLabel.text = NSLocalizedString("Jailbreak Requires Untethering", comment: "")
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak is untethered, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
|
||||
} else {
|
||||
placeholderView.textLabel.text = NSLocalizedString("Jailbreak Supports Untethering", comment: "")
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak has an untethered version, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
|
||||
}
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Install Untethered Jailbreak", comment: ""), for: .normal)
|
||||
|
||||
cancelButton.alpha = 1.0
|
||||
|
||||
case .install:
|
||||
guard let app = patchApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Installing %@ placeholder…", comment: ""), app.name)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("A placeholder app needs to be installed in order to prepare your device for untethering.\n\nThis may take a few moments.", comment: "")
|
||||
|
||||
case .openApp:
|
||||
placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
|
||||
|
||||
case .patchApp:
|
||||
guard let app = patchApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Patching %@ placeholder…", comment: ""), app.name)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("This will take a few moments. Please do not turn off the screen or leave the app until patching is complete.", comment: "")
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Patch Placeholder", comment: ""), for: .normal)
|
||||
|
||||
case .reboot:
|
||||
placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
|
||||
|
||||
pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
|
||||
|
||||
case .refresh:
|
||||
guard let installedApp = installedApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Finish installing %@?", comment: ""), installedApp.name)
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("In order to finish jailbreaking this device, you need to install %@ then follow the instructions in the app.", comment: ""), installedApp.name)
|
||||
|
||||
pillButton.setTitle(String(format: NSLocalizedString("Install %@", comment: ""), installedApp.name), for: .normal)
|
||||
|
||||
case .finish:
|
||||
guard let installedApp = installedApp else { break }
|
||||
|
||||
placeholderView.textLabel.text = String(format: NSLocalizedString("Finish in %@", comment: ""), installedApp.name)
|
||||
placeholderView.detailTextLabel.text = String(format: NSLocalizedString("Follow the instructions in %@ to finish jailbreaking this device.", comment: ""), installedApp.name)
|
||||
|
||||
pillButton.setTitle(String(format: NSLocalizedString("Open %@", comment: ""), installedApp.name), for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func present(_ error: Error, title: String) {
|
||||
DispatchQueue.main.async {
|
||||
let nsError = error as NSError
|
||||
|
||||
let alertController = UIAlertController(title: nsError.localizedFailure ?? title, message: error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
|
||||
self.setProgress(nil, description: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func setProgress(_ progress: Progress?, description: String?) {
|
||||
DispatchQueue.main.async {
|
||||
self.pillButton.progress = progress
|
||||
self.taskDescriptionLabel.text = description ?? " " // Use non-empty string to prevent label resizing itself.
|
||||
}
|
||||
}
|
||||
|
||||
func finish(with result: Result<Void, Error>) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: temporaryDirectory)
|
||||
} catch {
|
||||
print("Failed to remove temporary directory:", error)
|
||||
}
|
||||
|
||||
if let observation = didEnterBackgroundObservation {
|
||||
NotificationCenter.default.removeObserver(observation)
|
||||
}
|
||||
|
||||
completionHandler?(result)
|
||||
completionHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private extension PatchViewController {
|
||||
@IBAction func performButtonAction() {
|
||||
buttonHandler?()
|
||||
}
|
||||
|
||||
@IBAction func cancel() {
|
||||
finish(with: .success(()))
|
||||
|
||||
cancellableProgress?.cancel()
|
||||
}
|
||||
|
||||
@IBAction func installRegularJailbreak() {
|
||||
guard let app = patchApp else { return }
|
||||
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
if UIDevice.current.isUntetheredJailbreakRequired {
|
||||
title = NSLocalizedString("Untethering Required", comment: "")
|
||||
message = String(format: NSLocalizedString("%@ can not jailbreak this device unless you untether it first. Are you sure you want to install without untethering?", comment: ""), app.name)
|
||||
} else {
|
||||
title = NSLocalizedString("Untethering Recommended", comment: "")
|
||||
message = String(format: NSLocalizedString("Untethering this jailbreak will prevent %@ from expiring, even after 7 days or rebooting the device. Are you sure you want to install without untethering?", comment: ""), app.name)
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Install Without Untethering", comment: ""), style: .default) { _ in
|
||||
self.finish(with: .failure(OperationError.cancelled))
|
||||
})
|
||||
alertController.addAction(.cancel)
|
||||
present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
private extension PatchViewController {
|
||||
func startProcess() {
|
||||
guard let patchApp = patchApp else { return }
|
||||
|
||||
currentStep = .install
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: patchApp) {
|
||||
// Cancel pending jailbreak app installation so we can start a new one.
|
||||
progress.cancel()
|
||||
}
|
||||
|
||||
let appURL = InstalledApp.fileURL(for: patchApp)
|
||||
let cachedAppURL = temporaryDirectory.appendingPathComponent("Cached.app")
|
||||
|
||||
do {
|
||||
// Make copy of original app, so we can replace the cached patch app with it later.
|
||||
try FileManager.default.copyItem(at: appURL, to: cachedAppURL, shouldReplace: true)
|
||||
} catch {
|
||||
present(error, title: NSLocalizedString("Could not back up jailbreak app.", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
var unzippingError: Error?
|
||||
let refreshGroup = AppManager.shared.install(patchApp, presentingViewController: self, context: context) { result in
|
||||
do {
|
||||
_ = try result.get()
|
||||
|
||||
if let unzippingError = unzippingError {
|
||||
throw unzippingError
|
||||
}
|
||||
|
||||
// Replace cached patch app with original app so we can resume installing it post-reboot.
|
||||
try FileManager.default.copyItem(at: cachedAppURL, to: appURL, shouldReplace: true)
|
||||
|
||||
self.openApp()
|
||||
} catch {
|
||||
self.present(error, title: String(format: NSLocalizedString("Could not install %@ placeholder.", comment: ""), patchApp.name))
|
||||
}
|
||||
}
|
||||
refreshGroup.beginInstallationHandler = { installedApp in
|
||||
do {
|
||||
// Replace patch app name with correct name.
|
||||
installedApp.name = patchApp.name
|
||||
|
||||
let ipaURL = installedApp.refreshedIPAURL
|
||||
let resignedAppURL = try FileManager.default.unzipAppBundle(at: ipaURL, toDirectory: self.temporaryDirectory)
|
||||
|
||||
self.resignedApp = ALTApplication(fileURL: resignedAppURL)
|
||||
} catch {
|
||||
print("Error unzipping app bundle:", error)
|
||||
unzippingError = error
|
||||
}
|
||||
}
|
||||
setProgress(refreshGroup.progress, description: nil)
|
||||
|
||||
cancellableProgress = refreshGroup.progress
|
||||
}
|
||||
|
||||
func openApp() {
|
||||
guard let patchApp = patchApp else { return }
|
||||
|
||||
setProgress(nil, description: nil)
|
||||
currentStep = .openApp
|
||||
|
||||
// This observation is willEnterForeground because patching starts immediately upon return.
|
||||
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { _ in
|
||||
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
|
||||
self.patchApplication()
|
||||
}
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
|
||||
let openURL = InstalledApp.openAppURL(for: patchApp)
|
||||
UIApplication.shared.open(openURL) { success in
|
||||
guard !success else { return }
|
||||
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func patchApplication() {
|
||||
guard let resignedApp = resignedApp else { return }
|
||||
|
||||
currentStep = .patchApp
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
self?.patchApplication()
|
||||
}
|
||||
|
||||
let patchAppOperation = AppManager.shared.patch(resignedApp: resignedApp, presentingViewController: self, context: context) { result in
|
||||
switch result {
|
||||
case let .failure(error): self.present(error, title: String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), resignedApp.name))
|
||||
case .success: self.rebootDevice()
|
||||
}
|
||||
}
|
||||
patchAppOperation.progressHandler = { progress, description in
|
||||
self.setProgress(progress, description: description)
|
||||
}
|
||||
cancellableProgress = patchAppOperation.progress
|
||||
}
|
||||
|
||||
func rebootDevice() {
|
||||
guard let patchApp = patchApp else { return }
|
||||
|
||||
setProgress(nil, description: nil)
|
||||
currentStep = .reboot
|
||||
|
||||
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
|
||||
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
|
||||
|
||||
var patchedApps = UserDefaults.standard.patchedApps ?? []
|
||||
if !patchedApps.contains(patchApp.bundleIdentifier) {
|
||||
patchedApps.append(patchApp.bundleIdentifier)
|
||||
UserDefaults.standard.patchedApps = patchedApps
|
||||
}
|
||||
|
||||
self.finish(with: .success(()))
|
||||
}
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
|
||||
let openURL = InstalledApp.openAppURL(for: patchApp)
|
||||
UIApplication.shared.open(openURL) { success in
|
||||
guard !success else { return }
|
||||
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func refreshApp() {
|
||||
guard let installedApp = installedApp else { return }
|
||||
|
||||
currentStep = .refresh
|
||||
|
||||
buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
let tempApp = context.object(with: installedApp.objectID) as! InstalledApp
|
||||
tempApp.needsResign = true
|
||||
|
||||
let errorTitle = String(format: NSLocalizedString("Could not install %@.", comment: ""), tempApp.name)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
// Refreshing ensures we don't attempt to patch the app again,
|
||||
// since that is only checked when installing a new app.
|
||||
let refreshGroup = AppManager.shared.refresh([installedApp], presentingViewController: self, group: nil)
|
||||
refreshGroup.completionHandler = { [weak refreshGroup, weak self] results in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let (bundleIdentifier, result) = results.first else { throw refreshGroup?.context.error ?? OperationError.unknown }
|
||||
_ = try result.get()
|
||||
|
||||
if var patchedApps = UserDefaults.standard.patchedApps, let index = patchedApps.firstIndex(of: bundleIdentifier) {
|
||||
patchedApps.remove(at: index)
|
||||
UserDefaults.standard.patchedApps = patchedApps
|
||||
}
|
||||
|
||||
self.finish()
|
||||
} catch {
|
||||
self.present(error, title: errorTitle)
|
||||
}
|
||||
}
|
||||
self.setProgress(refreshGroup.progress, description: String(format: NSLocalizedString("Installing %@...", comment: ""), installedApp.name))
|
||||
}
|
||||
} catch {
|
||||
self.present(error, title: errorTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
guard let installedApp = installedApp else { return }
|
||||
|
||||
setProgress(nil, description: nil)
|
||||
currentStep = .finish
|
||||
|
||||
didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
|
||||
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
|
||||
self.finish(with: .success(()))
|
||||
}
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
let appName = installedApp.name
|
||||
let openURL = installedApp.openAppURL
|
||||
|
||||
self.buttonHandler = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
|
||||
UIApplication.shared.open(openURL) { success in
|
||||
guard !success else { return }
|
||||
self.present(OperationError.openAppFailed(name: appName), title: String(format: NSLocalizedString("Could not open %@.", comment: ""), appName))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// RefreshAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 2/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import minimuxer
|
||||
import MiniMuxerSwift
|
||||
import RoxasUIKit
|
||||
|
||||
@objc(RefreshAppOperation)
|
||||
final class RefreshAppOperation: ResultOperation<InstalledApp> {
|
||||
let context: AppOperationContext
|
||||
|
||||
// Strong reference to managedObjectContext to keep it alive until we're finished.
|
||||
let managedObjectContext: NSManagedObjectContext
|
||||
|
||||
init(context: AppOperationContext) {
|
||||
self.context = context
|
||||
managedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
do {
|
||||
if let error = context.error {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let profiles = context.provisioningProfiles else { throw OperationError.invalidParameters }
|
||||
|
||||
guard let app = context.app else { throw OperationError.appNotFound }
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { _ in
|
||||
print("Sending refresh app request...")
|
||||
|
||||
for p in profiles {
|
||||
do {
|
||||
let x = try install_provisioning_profile(plist: p.value.data)
|
||||
if case let .Bad(code) = x {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
}
|
||||
} catch let Uhoh.Bad(code) {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
} catch {
|
||||
self.finish(.failure(OperationError.unknown))
|
||||
}
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier)
|
||||
self.managedObjectContext.perform {
|
||||
guard let installedApp = InstalledApp.first(satisfying: predicate, in: self.managedObjectContext) else {
|
||||
return
|
||||
}
|
||||
installedApp.update(provisioningProfile: p.value)
|
||||
for installedExtension in installedApp.appExtensions {
|
||||
guard let provisioningProfile = profiles[installedExtension.bundleIdentifier] else { continue }
|
||||
installedExtension.update(provisioningProfile: provisioningProfile)
|
||||
}
|
||||
self.finish(.success(installedApp))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// RefreshGroup.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/20/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
|
||||
public final class RefreshGroup: NSObject {
|
||||
let context: AuthenticatedOperationContext
|
||||
let progress = Progress.discreteProgress(totalUnitCount: 0)
|
||||
|
||||
var completionHandler: (([String: Result<InstalledApp, Error>]) -> Void)?
|
||||
var beginInstallationHandler: ((InstalledApp) -> Void)?
|
||||
|
||||
private(set) var results = [String: Result<InstalledApp, Error>]()
|
||||
|
||||
// Keep strong references to managed object contexts
|
||||
// so they don't die out from under us.
|
||||
private(set) var _contexts = Set<NSManagedObjectContext>()
|
||||
|
||||
private var isFinished = false
|
||||
|
||||
private let dispatchGroup = DispatchGroup()
|
||||
private var operations: [Foundation.Operation] = []
|
||||
|
||||
public init(context: AuthenticatedOperationContext = AuthenticatedOperationContext()) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Used to keep track of which operations belong to this group.
|
||||
/// This does _not_ add them to any operation queue.
|
||||
public func add(_ operations: [Foundation.Operation]) {
|
||||
for operation in operations {
|
||||
dispatchGroup.enter()
|
||||
|
||||
operation.completionBlock = { [weak self] in
|
||||
self?.dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
if self.operations.isEmpty && !operations.isEmpty {
|
||||
dispatchGroup.notify(queue: .global()) { [weak self] in
|
||||
self?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
self.operations.append(contentsOf: operations)
|
||||
}
|
||||
|
||||
public func set(_ result: Result<InstalledApp, Error>, forAppWithBundleIdentifier bundleIdentifier: String) {
|
||||
results[bundleIdentifier] = result
|
||||
|
||||
switch result {
|
||||
case .failure: break
|
||||
case let .success(installedApp):
|
||||
guard let context = installedApp.managedObjectContext else { break }
|
||||
_contexts.insert(context)
|
||||
}
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
operations.forEach { $0.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private extension RefreshGroup {
|
||||
func finish() {
|
||||
guard !isFinished else { return }
|
||||
isFinished = true
|
||||
|
||||
completionHandler?(results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// RemoveAppBackupOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/13/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc(RemoveAppBackupOperation)
|
||||
final class RemoveAppBackupOperation: ResultOperation<Void> {
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
private let coordinator = NSFileCoordinator()
|
||||
private let coordinatorQueue = OperationQueue()
|
||||
|
||||
init(context: InstallAppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
coordinatorQueue.name = "AltStore - RemoveAppBackupOperation Queue"
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let installedApp = context.installedApp else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
installedApp.managedObjectContext?.perform {
|
||||
guard let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return self.finish(.failure(OperationError.missingAppGroup)) }
|
||||
|
||||
let intent = NSFileAccessIntent.writingIntent(with: backupDirectoryURL, options: [.forDeleting])
|
||||
self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { error in
|
||||
do {
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: intent.url)
|
||||
|
||||
self.finish(.success(()))
|
||||
} catch let error as CocoaError where error.code == CocoaError.Code.fileNoSuchFile {
|
||||
#if DEBUG
|
||||
|
||||
// When debugging, it's expected that app groups don't match, so ignore.
|
||||
self.finish(.success(()))
|
||||
|
||||
#else
|
||||
|
||||
print("Failed to remove app backup directory:", error)
|
||||
self.finish(.failure(error))
|
||||
|
||||
#endif
|
||||
} catch {
|
||||
print("Failed to remove app backup directory:", error)
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// RemoveAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/12/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import SideStoreCore
|
||||
import minimuxer
|
||||
import MiniMuxerSwift
|
||||
import Shared
|
||||
import SideKit
|
||||
|
||||
@objc(RemoveAppOperation)
|
||||
final class RemoveAppOperation: ResultOperation<InstalledApp> {
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
init(context: InstallAppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let installedApp = context.installedApp else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
let resignedBundleIdentifier = installedApp.resignedBundleIdentifier
|
||||
|
||||
do {
|
||||
let res = try remove_app(app_id: resignedBundleIdentifier)
|
||||
if case let Uhoh.Bad(code) = res {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
}
|
||||
} catch let Uhoh.Bad(code) {
|
||||
self.finish(.failure(minimuxer_to_operation(code: code)))
|
||||
} catch {
|
||||
self.finish(.failure(ALTServerError.appDeletionFailed))
|
||||
}
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
let installedApp = context.object(with: installedApp.objectID) as! InstalledApp
|
||||
installedApp.isActive = false
|
||||
self.finish(.success(installedApp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//
|
||||
// ResignAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/7/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RoxasUIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
|
||||
|
||||
@objc(ResignAppOperation)
|
||||
final class ResignAppOperation: ResultOperation<ALTApplication> {
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
init(context: InstallAppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
progress.totalUnitCount = 3
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let app = context.app,
|
||||
let profiles = context.provisioningProfiles,
|
||||
let team = context.team,
|
||||
let certificate = context.certificate
|
||||
else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
// Prepare app bundle
|
||||
let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2)
|
||||
progress.addChild(prepareAppProgress, withPendingUnitCount: 3)
|
||||
|
||||
let prepareAppBundleProgress = prepareAppBundle(for: app, profiles: profiles) { result in
|
||||
guard let appBundleURL = self.process(result) else { return }
|
||||
|
||||
print("Resigning App:", self.context.bundleIdentifier)
|
||||
|
||||
// Resign app bundle
|
||||
let resignProgress = self.resignAppBundle(at: appBundleURL, team: team, certificate: certificate, profiles: Array(profiles.values)) { result in
|
||||
guard let resignedURL = self.process(result) else { return }
|
||||
|
||||
// Finish
|
||||
do {
|
||||
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
try FileManager.default.copyItem(at: resignedURL, to: destinationURL, shouldReplace: true)
|
||||
|
||||
// Use appBundleURL since we need an app bundle, not .ipa.
|
||||
guard let resignedApplication = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
|
||||
self.finish(.success(resignedApplication))
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
prepareAppProgress.addChild(resignProgress, withPendingUnitCount: 1)
|
||||
}
|
||||
prepareAppProgress.addChild(prepareAppBundleProgress, withPendingUnitCount: 1)
|
||||
}
|
||||
|
||||
func process<T>(_ result: Result<T, Error>) -> T? {
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
finish(.failure(error))
|
||||
return nil
|
||||
|
||||
case let .success(value):
|
||||
guard !isCancelled else {
|
||||
finish(.failure(OperationError.cancelled))
|
||||
return nil
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ResignAppOperation {
|
||||
func prepareAppBundle(for app: ALTApplication, profiles: [String: ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress {
|
||||
let progress = Progress.discreteProgress(totalUnitCount: 1)
|
||||
|
||||
let bundleIdentifier = app.bundleIdentifier
|
||||
let openURL = InstalledApp.openAppURL(for: app)
|
||||
|
||||
let fileURL = app.fileURL
|
||||
|
||||
func prepare(_ bundle: Bundle, additionalInfoDictionaryValues: [String: Any] = [:]) throws {
|
||||
guard let identifier = bundle.bundleIdentifier else { throw ALTError(.missingAppBundle) }
|
||||
guard let profile = profiles[identifier] else { throw ALTError(.missingProvisioningProfile) }
|
||||
guard var infoDictionary = bundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
|
||||
|
||||
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
|
||||
infoDictionary[Bundle.Info.altBundleID] = identifier
|
||||
infoDictionary[Bundle.Info.devicePairingString] = Bundle.main.object(forInfoDictionaryKey: "ALTPairingFile") as? String
|
||||
|
||||
for (key, value) in additionalInfoDictionaryValues {
|
||||
infoDictionary[key] = value
|
||||
}
|
||||
|
||||
if let appGroups = profile.entitlements[.appGroups] as? [String] {
|
||||
infoDictionary[Bundle.Info.appGroups] = appGroups
|
||||
|
||||
// To keep file providers working, remap the NSExtensionFileProviderDocumentGroup, if there is one.
|
||||
if var extensionInfo = infoDictionary["NSExtension"] as? [String: Any],
|
||||
let appGroup = extensionInfo["NSExtensionFileProviderDocumentGroup"] as? String,
|
||||
let localAppGroup = appGroups.filter({ $0.contains(appGroup) }).min(by: { $0.count < $1.count }) {
|
||||
extensionInfo["NSExtensionFileProviderDocumentGroup"] = localAppGroup
|
||||
infoDictionary["NSExtension"] = extensionInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Add app-specific exported UTI so we can check later if this app (extension) is installed or not.
|
||||
let installedAppUTI = ["UTTypeConformsTo": [],
|
||||
"UTTypeDescription": "AltStore Installed App",
|
||||
"UTTypeIconFiles": [],
|
||||
"UTTypeIdentifier": InstalledApp.installedAppUTI(forBundleIdentifier: profile.bundleIdentifier),
|
||||
"UTTypeTagSpecification": [:]] as [String: Any]
|
||||
|
||||
var exportedUTIs = infoDictionary[Bundle.Info.exportedUTIs] as? [[String: Any]] ?? []
|
||||
exportedUTIs.append(installedAppUTI)
|
||||
infoDictionary[Bundle.Info.exportedUTIs] = exportedUTIs
|
||||
|
||||
try (infoDictionary as NSDictionary).write(to: bundle.infoPlistURL)
|
||||
}
|
||||
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let appBundleURL = self.context.temporaryDirectory.appendingPathComponent("App.app")
|
||||
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
|
||||
|
||||
// Become current so we can observe progress from unzipAppBundle().
|
||||
progress.becomeCurrent(withPendingUnitCount: 1)
|
||||
|
||||
guard let appBundle = Bundle(url: appBundleURL) else { throw ALTError(.missingAppBundle) }
|
||||
guard let infoDictionary = appBundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
|
||||
|
||||
var allURLSchemes = infoDictionary[Bundle.Info.urlTypes] as? [[String: Any]] ?? []
|
||||
|
||||
let altstoreURLScheme = ["CFBundleTypeRole": "Editor",
|
||||
"CFBundleURLName": bundleIdentifier,
|
||||
"CFBundleURLSchemes": [openURL.scheme!]] as [String: Any]
|
||||
allURLSchemes.append(altstoreURLScheme)
|
||||
|
||||
var additionalValues: [String: Any] = [Bundle.Info.urlTypes: allURLSchemes]
|
||||
|
||||
if app.isAltStoreApp {
|
||||
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { throw OperationError.unknownUDID }
|
||||
guard let pairingFileString = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) as? String else { throw OperationError.unknownUDID }
|
||||
additionalValues[Bundle.Info.devicePairingString] = pairingFileString
|
||||
additionalValues[Bundle.Info.deviceID] = udid
|
||||
additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
|
||||
|
||||
if
|
||||
let data = Keychain.shared.signingCertificate,
|
||||
let signingCertificate = ALTCertificate(p12Data: data, password: nil),
|
||||
let encryptingPassword = Keychain.shared.signingCertificatePassword
|
||||
{
|
||||
additionalValues[Bundle.Info.certificateID] = signingCertificate.serialNumber
|
||||
|
||||
let encryptedData = signingCertificate.encryptedP12Data(withPassword: encryptingPassword)
|
||||
try encryptedData?.write(to: appBundle.certificateURL, options: .atomic)
|
||||
} else {
|
||||
// The embedded certificate + certificate identifier are already in app bundle, no need to update them.
|
||||
}
|
||||
} else if infoDictionary.keys.contains(Bundle.Info.deviceID), let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String {
|
||||
// There is an ALTDeviceID entry, so assume the app is using AltKit and replace it with the device's UDID.
|
||||
additionalValues[Bundle.Info.deviceID] = udid
|
||||
additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
|
||||
}
|
||||
|
||||
let iconScale = Int(UIScreen.main.scale)
|
||||
|
||||
if let alternateIconURL = self.context.alternateIconURL,
|
||||
case let data = try Data(contentsOf: alternateIconURL),
|
||||
let image = UIImage(data: data),
|
||||
let icon = image.resizing(toFill: CGSize(width: 60 * iconScale, height: 60 * iconScale)),
|
||||
let iconData = icon.pngData() {
|
||||
let iconName = "AltIcon"
|
||||
let iconURL = appBundleURL.appendingPathComponent(iconName + "@\(iconScale)x.png")
|
||||
try iconData.write(to: iconURL, options: .atomic)
|
||||
|
||||
let iconDictionary = ["CFBundlePrimaryIcon": ["CFBundleIconFiles": [iconName]]]
|
||||
additionalValues["CFBundleIcons"] = iconDictionary
|
||||
}
|
||||
|
||||
// Prepare app
|
||||
try prepare(appBundle, additionalInfoDictionaryValues: additionalValues)
|
||||
|
||||
if let directory = appBundle.builtInPlugInsURL, let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants]) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard let appExtension = Bundle(url: fileURL) else { throw ALTError(.missingAppBundle) }
|
||||
try prepare(appExtension)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(.success(appBundleURL))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
return progress
|
||||
}
|
||||
|
||||
func resignAppBundle(at fileURL: URL, team: ALTTeam, certificate: ALTCertificate, profiles: [ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress {
|
||||
let signer = ALTSigner(team: team, certificate: certificate)
|
||||
let progress = signer.signApp(at: fileURL, provisioningProfiles: profiles) { success, error in
|
||||
do {
|
||||
try Result(success, error).get()
|
||||
|
||||
let ipaURL = try FileManager.default.zipAppBundle(at: fileURL)
|
||||
completionHandler(.success(ipaURL))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
return progress
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// SendAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/7/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
import SideStoreCore
|
||||
import Shared
|
||||
import SideKit
|
||||
import MiniMuxerSwift
|
||||
|
||||
@objc(SendAppOperation)
|
||||
final class SendAppOperation: ResultOperation<Void> {
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
private let dispatchQueue = DispatchQueue(label: "com.sidestore.SendAppOperation")
|
||||
|
||||
init(context: InstallAppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
|
||||
progress.totalUnitCount = 1
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
if let error = context.error {
|
||||
finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let resignedApp = context.resignedApp else { return finish(.failure(OperationError.invalidParameters)) }
|
||||
|
||||
// self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa.
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: context.bundleIdentifier, url: resignedApp.fileURL)
|
||||
let fileURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
|
||||
print("AFC App `fileURL`: \(fileURL.absoluteString)")
|
||||
|
||||
let ns_bundle = NSString(string: app.bundleIdentifier)
|
||||
let ns_bundle_ptr = UnsafeMutablePointer<CChar>(mutating: ns_bundle.utf8String)
|
||||
|
||||
if let data = NSData(contentsOf: fileURL) {
|
||||
let pls = UnsafeMutablePointer<UInt8>.allocate(capacity: data.length)
|
||||
for (index, data) in data.enumerated() {
|
||||
pls[index] = data
|
||||
}
|
||||
let res = minimuxer_yeet_app_afc(ns_bundle_ptr, pls, UInt(data.length))
|
||||
if res == 0 {
|
||||
print("minimuxer_yeet_app_afc `res` == \(res)")
|
||||
progress.completedUnitCount += 1
|
||||
finish(.success(()))
|
||||
} else {
|
||||
finish(.failure(minimuxer_to_operation(code: res)))
|
||||
}
|
||||
|
||||
} else {
|
||||
finish(.failure(ALTServerError.unknownResponse))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// UpdatePatronsOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 4/11/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
private extension URL {
|
||||
#if STAGING
|
||||
static let patreonInfo = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/altstore/patreon.json")!
|
||||
#else
|
||||
static let patreonInfo = URL(string: "https://cdn.altstore.io/file/altstore/altstore/patreon.json")!
|
||||
#endif
|
||||
}
|
||||
|
||||
extension UpdatePatronsOperation {
|
||||
private struct Response: Decodable {
|
||||
var version: Int
|
||||
var accessToken: String
|
||||
var refreshID: String
|
||||
}
|
||||
}
|
||||
|
||||
final class UpdatePatronsOperation: ResultOperation<Void> {
|
||||
let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
let dataTask = URLSession.shared.dataTask(with: .patreonInfo) { data, response, error in
|
||||
do {
|
||||
if let response = response as? HTTPURLResponse {
|
||||
guard response.statusCode != 404 else {
|
||||
self.finish(.failure(URLError(.fileDoesNotExist, userInfo: [NSURLErrorKey: URL.patreonInfo])))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let data = data else { throw error! }
|
||||
|
||||
let response = try SideStoreCore.JSONDecoder().decode(Response.self, from: data)
|
||||
Keychain.shared.patreonCreatorAccessToken = response.accessToken
|
||||
|
||||
let previousRefreshID = UserDefaults.shared.patronsRefreshID
|
||||
guard response.refreshID != previousRefreshID else {
|
||||
self.finish(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
PatreonAPI.shared.fetchPatrons { result in
|
||||
self.context.perform {
|
||||
do {
|
||||
let patrons = try result.get()
|
||||
let managedPatrons = patrons.map { ManagedPatron(patron: $0, context: self.context) }
|
||||
|
||||
let patronIDs = Set(managedPatrons.map { $0.identifier })
|
||||
let nonFriendZonePredicate = NSPredicate(format: "NOT (%K IN %@)", #keyPath(ManagedPatron.identifier), patronIDs)
|
||||
|
||||
let nonFriendZonePatrons = ManagedPatron.all(satisfying: nonFriendZonePredicate, in: self.context)
|
||||
for managedPatron in nonFriendZonePatrons {
|
||||
self.context.delete(managedPatron)
|
||||
}
|
||||
|
||||
try self.context.save()
|
||||
|
||||
UserDefaults.shared.patronsRefreshID = response.refreshID
|
||||
|
||||
self.finish(.success(()))
|
||||
|
||||
print("Updated Friend Zone Patrons!")
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
dataTask.resume()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// VerifyAppOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/2/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
import RoxasUIKit
|
||||
import SideKit
|
||||
import Shared
|
||||
|
||||
enum VerificationError: LocalizedError {
|
||||
case privateEntitlements(ALTApplication, entitlements: [String: Any])
|
||||
case mismatchedBundleIdentifiers(ALTApplication, sourceBundleID: String)
|
||||
case iOSVersionNotSupported(ALTApplication)
|
||||
|
||||
var app: ALTApplication {
|
||||
switch self {
|
||||
case let .privateEntitlements(app, _): return app
|
||||
case let .mismatchedBundleIdentifiers(app, _): return app
|
||||
case let .iOSVersionNotSupported(app): return app
|
||||
}
|
||||
}
|
||||
|
||||
var failure: String? {
|
||||
String(format: NSLocalizedString("“%@” could not be installed.", comment: ""), app.name)
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case let .privateEntitlements(app, _):
|
||||
return String(format: NSLocalizedString("“%@” requires private permissions.", comment: ""), app.name)
|
||||
|
||||
case let .mismatchedBundleIdentifiers(app, sourceBundleID):
|
||||
return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), app.bundleIdentifier, sourceBundleID)
|
||||
|
||||
case let .iOSVersionNotSupported(app):
|
||||
let name = app.name
|
||||
|
||||
var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)"
|
||||
if app.minimumiOSVersion.patchVersion > 0 {
|
||||
version += ".\(app.minimumiOSVersion.patchVersion)"
|
||||
}
|
||||
|
||||
let localizedDescription = String(format: NSLocalizedString("%@ requires %@.", comment: ""), name, version)
|
||||
return localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(VerifyAppOperation)
|
||||
final class VerifyAppOperation: ResultOperation<Void> {
|
||||
let context: AppOperationContext
|
||||
var verificationHandler: ((VerificationError) -> Bool)?
|
||||
|
||||
init(context: AppOperationContext) {
|
||||
self.context = context
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func main() {
|
||||
super.main()
|
||||
|
||||
do {
|
||||
if let error = context.error {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let app = context.app else { throw OperationError.invalidParameters }
|
||||
|
||||
guard app.bundleIdentifier == context.bundleIdentifier else {
|
||||
throw VerificationError.mismatchedBundleIdentifiers(app, sourceBundleID: context.bundleIdentifier)
|
||||
}
|
||||
|
||||
guard ProcessInfo.processInfo.isOperatingSystemAtLeast(app.minimumiOSVersion) else {
|
||||
throw VerificationError.iOSVersionNotSupported(app)
|
||||
}
|
||||
|
||||
if #available(iOS 13.5, *) {
|
||||
// No psychic paper, so we can ignore private entitlements
|
||||
app.hasPrivateEntitlements = false
|
||||
} else {
|
||||
// Make sure this goes last, since once user responds to alert we don't do any more app verification.
|
||||
if let commentStart = app.entitlementsString.range(of: "<!---><!-->"), let commentEnd = app.entitlementsString.range(of: "<!-- -->") {
|
||||
// Psychic Paper private entitlements.
|
||||
|
||||
let entitlementsStart = app.entitlementsString.index(after: commentStart.upperBound)
|
||||
let rawEntitlements = String(app.entitlementsString[entitlementsStart ..< commentEnd.lowerBound])
|
||||
|
||||
let plistTemplate = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
%@
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
let entitlementsPlist = String(format: plistTemplate, rawEntitlements)
|
||||
let entitlements = try PropertyListSerialization.propertyList(from: entitlementsPlist.data(using: .utf8)!, options: [], format: nil) as! [String: Any]
|
||||
|
||||
app.hasPrivateEntitlements = true
|
||||
let error = VerificationError.privateEntitlements(app, entitlements: entitlements)
|
||||
process(error) { result in
|
||||
self.finish(result.mapError { $0 as Error })
|
||||
}
|
||||
|
||||
return
|
||||
} else {
|
||||
app.hasPrivateEntitlements = false
|
||||
}
|
||||
}
|
||||
|
||||
finish(.success(()))
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VerifyAppOperation {
|
||||
func process(_ error: VerificationError, completion: @escaping (Result<Void, VerificationError>) -> Void) {
|
||||
guard let presentingViewController = context.presentingViewController else { return completion(.failure(error)) }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
switch error {
|
||||
case let .privateEntitlements(_, entitlements):
|
||||
let permissions = entitlements.keys.sorted().joined(separator: "\n")
|
||||
let message = String(format: NSLocalizedString("""
|
||||
You must allow access to these private permissions before continuing:
|
||||
|
||||
%@
|
||||
|
||||
Private permissions allow apps to do more than normally allowed by iOS, including potentially accessing sensitive private data. Make sure to only install apps from sources you trust.
|
||||
""", comment: ""), permissions)
|
||||
|
||||
let alertController = UIAlertController(title: error.failureReason ?? error.localizedDescription, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Allow Access", comment: ""), style: .destructive) { _ in
|
||||
completion(.success(()))
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Deny Access", comment: ""), style: .default, handler: { _ in
|
||||
completion(.failure(error))
|
||||
}))
|
||||
presentingViewController.present(alertController, animated: true, completion: nil)
|
||||
|
||||
case .mismatchedBundleIdentifiers: return completion(.failure(error))
|
||||
case .iOSVersionNotSupported: return completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user