// // DownloadAppOperation.swift // AltStore // // Created by Riley Testut on 6/10/19. // Copyright © 2019 Riley Testut. All rights reserved. // import Foundation import Roxas import AltStoreCore import AltSign @objc(DownloadAppOperation) final class DownloadAppOperation: ResultOperation { let app: AppProtocol let context: AppOperationContext private let appName: String private let bundleIdentifier: String 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 self.appName = app.name self.bundleIdentifier = app.bundleIdentifier self.destinationURL = destinationURL super.init() // App = 3, Dependencies = 1 self.progress.totalUnitCount = 4 } override func main() { super.main() if let error = self.context.error { self.finish(.failure(error)) return } print("Downloading App:", self.bundleIdentifier) self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName) guard let storeApp = self.app as? StoreApp else { return self.download(self.app) } storeApp.managedObjectContext?.perform { do { let latestVersion = try self.verify(storeApp) self.download(latestVersion) } catch let error as VerificationError where error.code == .iOSVersionNotSupported { guard let presentingViewController = self.context.presentingViewController, let latestSupportedVersion = storeApp.latestSupportedVersion, case let version = latestSupportedVersion.version, version != storeApp.installedApp?.version else { return self.finish(.failure(error)) } let title = NSLocalizedString("Unsupported iOS Version", comment: "") let message = error.localizedDescription + "\n\n" + NSLocalizedString("Would you like to download the last version compatible with this device instead?", comment: "") DispatchQueue.main.async { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in self.finish(.failure(OperationError.cancelled)) }) alertController.addAction(UIAlertAction(title: String(format: NSLocalizedString("Download %@ %@", comment: ""), self.appName, version), style: .default) { _ in self.download(latestSupportedVersion) }) presentingViewController.present(alertController, animated: true) } } catch { self.finish(.failure(error)) } } } override func finish(_ result: Result) { do { try FileManager.default.removeItem(at: self.temporaryDirectory) } catch { print("Failed to remove DownloadAppOperation temporary directory: \(self.temporaryDirectory).", error) } super.finish(result) } } private extension DownloadAppOperation { func verify(_ storeApp: StoreApp) throws -> AppVersion { guard let version = storeApp.latestAvailableVersion else { let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName) throw OperationError.unknown(failureReason: failureReason) } if let minOSVersion = version.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) { throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: minOSVersion) } else if let maxOSVersion = version.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion { throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: maxOSVersion) } return version } func download(@Managed _ app: AppProtocol) { guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) } self.downloadIPA(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)) } } } func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result) -> Void) { func finishOperation(_ result: Result) { do { let fileURL = try result.get() var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) } try FileManager.default.createDirectory(at: self.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 = self.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: self.temporaryDirectory) } guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp } completionHandler(.success(application)) } catch { completionHandler(.failure(error)) } } if sourceURL.isFileURL { finishOperation(.success(sourceURL)) self.progress.completedUnitCount += 3 } else { let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in do { if let response = response as? HTTPURLResponse { guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: sourceURL]) } } let (fileURL, _) = try Result((fileURL, response), error).get() finishOperation(.success(fileURL)) try? FileManager.default.removeItem(at: fileURL) } catch { finishOperation(.failure(error)) } } self.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 = self.path.map { ($0 as NSString).lastPathComponent } ?? self.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, 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() var dependencyError: Error? let dispatchGroup = DispatchGroup() let progress = Progress(totalUnitCount: Int64(altstorePlist.dependencies.count), parent: self.progress, pendingUnitCount: 1) for dependency in altstorePlist.dependencies { dispatchGroup.enter() self.download(dependency, for: application, progress: progress) { result in switch result { case .failure(let error): dependencyError = error case .success(let 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("The dependencies for %@ could not be determined.", comment: ""), application.name)) completionHandler(.failure(nsError)) } catch { completionHandler(.failure(error)) } } func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result) -> Void) { let downloadTask = self.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 let error as NSError { let localizedFailure = String(format: NSLocalizedString("The dependency “%@” could not be downloaded.", comment: ""), dependency.preferredFilename) completionHandler(.failure(error.withLocalizedFailure(localizedFailure))) } } progress.addChild(downloadTask.progress, withPendingUnitCount: 1) downloadTask.resume() } }