From 8dc108030da5cff509f5bf849bed0e5556676f5d Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 26 Feb 2021 16:47:33 -0600 Subject: [PATCH] Downloads app dependencies listed in AltStore.plist Allows apps to download additional dependencies before installation, such as plug-ins. --- .../Operations/DownloadAppOperation.swift | 252 ++++++++++++++++-- Dependencies/AltSign | 2 +- Shared/Extensions/Bundle+AltStore.swift | 20 +- 3 files changed, 238 insertions(+), 36 deletions(-) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 3bd5f832..ae70ae8f 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -12,6 +12,23 @@ import Roxas import AltStoreCore import AltSign +private extension DownloadAppOperation +{ + struct DependencyError: ALTLocalizedError + { + let dependency: Dependency + let error: Error + + var failure: String? { + return String(format: NSLocalizedString("Could not download “%@”.", comment: ""), self.dependency.preferredFilename) + } + + var underlyingError: Error? { + return self.error + } + } +} + @objc(DownloadAppOperation) class DownloadAppOperation: ResultOperation { @@ -23,6 +40,7 @@ class DownloadAppOperation: ResultOperation private let destinationURL: URL private let session = URLSession(configuration: .default) + private let temporaryDirectory = FileManager.default.uniqueTemporaryURL() init(app: AppProtocol, destinationURL: URL, context: AppOperationContext) { @@ -35,7 +53,8 @@ class DownloadAppOperation: ResultOperation super.init() - self.progress.totalUnitCount = 1 + // App = 3, Dependencies = 1 + self.progress.totalUnitCount = 4 } override func main() @@ -50,6 +69,66 @@ class DownloadAppOperation: ResultOperation print("Downloading App:", self.bundleIdentifier) + self.downloadApp(from: self.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) + { + 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 downloadApp(from sourceURL: URL, completionHandler: @escaping (Result) -> Void) + { func finishOperation(_ result: Result) { do @@ -59,9 +138,7 @@ class DownloadAppOperation: ResultOperation var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound } - let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) - defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true, attributes: nil) let appBundleURL: URL @@ -70,62 +147,179 @@ class DownloadAppOperation: ResultOperation // Directory, so assuming this is .app bundle. guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp } - appBundleURL = fileURL + 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: temporaryDirectory) + appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: self.temporaryDirectory) } guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp } - - try FileManager.default.copyItem(at: appBundleURL, to: self.destinationURL, shouldReplace: true) - - if self.context.bundleIdentifier == StoreApp.dolphinAppID, self.context.bundleIdentifier != application.bundleIdentifier - { - let infoPlistURL = self.destinationURL.appendingPathComponent("Info.plist") - - if var infoPlist = NSDictionary(contentsOf: 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: infoPlistURL, atomically: true) - } - } - - guard let copiedApplication = ALTApplication(fileURL: self.destinationURL) else { throw OperationError.invalidApp } - self.finish(.success(copiedApplication)) + completionHandler(.success(application)) } catch { - self.finish(.failure(error)) + completionHandler(.failure(error)) } } if self.sourceURL.isFileURL { - finishOperation(.success(self.sourceURL)) + finishOperation(.success(sourceURL)) - self.progress.completedUnitCount += 1 + self.progress.completedUnitCount += 3 } else { - let downloadTask = self.session.downloadTask(with: self.sourceURL) { (fileURL, response, error) in + let downloadTask = self.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)) } } - self.progress.addChild(downloadTask.progress, withPendingUnitCount: 1) + 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: DependencyError? + + 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("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) -> 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 + { + completionHandler(.failure(DependencyError(dependency: dependency, error: error))) + } + } + progress.addChild(downloadTask.progress, withPendingUnitCount: 1) + + downloadTask.resume() + } +} diff --git a/Dependencies/AltSign b/Dependencies/AltSign index 74c08fc9..451588f5 160000 --- a/Dependencies/AltSign +++ b/Dependencies/AltSign @@ -1 +1 @@ -Subproject commit 74c08fc9dd3801a87c3716688d6ac0acabefea79 +Subproject commit 451588f56f8581162e70deff070a7bc743d657db diff --git a/Shared/Extensions/Bundle+AltStore.swift b/Shared/Extensions/Bundle+AltStore.swift index c028b6bb..a56b45fc 100644 --- a/Shared/Extensions/Bundle+AltStore.swift +++ b/Shared/Extensions/Bundle+AltStore.swift @@ -25,23 +25,31 @@ public extension Bundle public extension Bundle { - static var baseAltStoreAppGroupID = "group.com.rileytestut.AltStore" - var infoPlistURL: URL { let infoPlistURL = self.bundleURL.appendingPathComponent("Info.plist") return infoPlistURL } var provisioningProfileURL: URL { - let infoPlistURL = self.bundleURL.appendingPathComponent("embedded.mobileprovision") - return infoPlistURL + let provisioningProfileURL = self.bundleURL.appendingPathComponent("embedded.mobileprovision") + return provisioningProfileURL } var certificateURL: URL { - let infoPlistURL = self.bundleURL.appendingPathComponent("ALTCertificate.p12") - return infoPlistURL + let certificateURL = self.bundleURL.appendingPathComponent("ALTCertificate.p12") + return certificateURL } + var altstorePlistURL: URL { + let altstorePlistURL = self.bundleURL.appendingPathComponent("AltStore.plist") + return altstorePlistURL + } +} + +public extension Bundle +{ + static var baseAltStoreAppGroupID = "group.com.rileytestut.AltStore" + var appGroups: [String] { return self.infoDictionary?[Bundle.Info.appGroups] as? [String] ?? [] }