From 5b20ce13bce85b71a1b1c72a21a9a09dd003080c Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 15 Nov 2022 17:43:38 -0600 Subject: [PATCH] Verifies min/max OS version before downloading app + asks user to download older app version if necessary --- .../Operations/DownloadAppOperation.swift | 104 ++++++++++++++---- AltStore/Operations/VerifyAppOperation.swift | 61 +++++++--- 2 files changed, 126 insertions(+), 39 deletions(-) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 9866442b..20623519 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -20,7 +20,6 @@ class DownloadAppOperation: ResultOperation private let appName: String private let bundleIdentifier: String - private var sourceURL: URL? private let destinationURL: URL private let session = URLSession(configuration: .default) @@ -33,7 +32,6 @@ class DownloadAppOperation: ResultOperation self.appName = app.name self.bundleIdentifier = app.bundleIdentifier - self.sourceURL = app.url self.destinationURL = destinationURL super.init() @@ -54,9 +52,88 @@ class DownloadAppOperation: ResultOperation print("Downloading App:", self.bundleIdentifier) - guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) } + // Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors. + self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName) - self.downloadApp(from: sourceURL) { result in + guard let storeApp = self.app as? StoreApp else { + return self.download(self.app) + } + + // Verify storeApp + 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 = $app.url else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) } + + self.downloadIPA(from: sourceURL) { result in do { let application = try result.get() @@ -97,24 +174,7 @@ class DownloadAppOperation: ResultOperation } } - 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 downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result) -> Void) { func finishOperation(_ result: Result) { diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index 59ccd73b..81990ab9 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -25,7 +25,10 @@ extension VerificationError static func privateEntitlements(_ entitlements: [String: Any], app: ALTApplication) -> VerificationError { VerificationError(code: .privateEntitlements, app: app, entitlements: entitlements) } static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError { VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID) } - static func iOSVersionNotSupported(app: ALTApplication) -> VerificationError { VerificationError(code: .iOSVersionNotSupported, app: app) } + + static func iOSVersionNotSupported(app: AppProtocol, osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, requiredOSVersion: OperatingSystemVersion?) -> VerificationError { + VerificationError(code: .iOSVersionNotSupported, app: app, deviceOSVersion: osVersion, requiredOSVersion: requiredOSVersion) + } } struct VerificationError: ALTLocalizedError @@ -35,22 +38,44 @@ struct VerificationError: ALTLocalizedError var errorTitle: String? var errorFailure: String? - var app: ALTApplication? + @Managed var app: AppProtocol? var entitlements: [String: Any]? var sourceBundleID: String? + var deviceOSVersion: OperatingSystemVersion? + var requiredOSVersion: OperatingSystemVersion? + var errorDescription: String? { + switch self.code + { + case .iOSVersionNotSupported: + guard let deviceOSVersion else { return nil } + + var failureReason = self.errorFailureReason + if self.app == nil + { + // failureReason does not start with app name, so make first letter lowercase. + let firstLetter = failureReason.prefix(1).lowercased() + failureReason = firstLetter + failureReason.dropFirst() + } + + let localizedDescription = String(format: NSLocalizedString("This device is running iOS %@, but %@", comment: ""), deviceOSVersion.stringValue, failureReason) + return localizedDescription + + default: return nil + } + } var errorFailureReason: String { switch self.code { case .privateEntitlements: - let appName = (self.app?.name as String?).map { String(format: NSLocalizedString("“%@”", comment: ""), $0) } ?? NSLocalizedString("The app", comment: "") + let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") return String(format: NSLocalizedString("%@ requires private permissions.", comment: ""), appName) case .mismatchedBundleIdentifiers: - if let app = self.app, let bundleID = self.sourceBundleID + if let appBundleID = self.$app.bundleIdentifier, let bundleID = self.sourceBundleID { - return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), app.bundleIdentifier, bundleID) + return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), appBundleID, bundleID) } else { @@ -58,22 +83,25 @@ struct VerificationError: ALTLocalizedError } case .iOSVersionNotSupported: - if let app = self.app + let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") + let deviceOSVersion = self.deviceOSVersion ?? ProcessInfo.processInfo.operatingSystemVersion + + guard let requiredOSVersion else { + return String(format: NSLocalizedString("%@ does not support iOS %@.", comment: ""), appName, deviceOSVersion.stringValue) + } + + if deviceOSVersion > requiredOSVersion { - var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)" - if app.minimumiOSVersion.patchVersion > 0 - { - version += ".\(app.minimumiOSVersion.patchVersion)" - } + // Device OS version is higher than maximum supported OS version. - let failureReason = String(format: NSLocalizedString("%@ requires %@.", comment: ""), app.name, version) + let failureReason = String(format: NSLocalizedString("%@ requires iOS %@ or earlier.", comment: ""), appName, requiredOSVersion.stringValue) return failureReason } else { - let version = ProcessInfo.processInfo.operatingSystemVersion.stringValue + // Device OS version is lower than minimum supported OS version. - let failureReason = String(format: NSLocalizedString("This app does not support iOS %@.", comment: ""), version) + let failureReason = String(format: NSLocalizedString("%@ requires iOS %@ or later.", comment: ""), appName, requiredOSVersion.stringValue) return failureReason } } @@ -114,7 +142,7 @@ class VerifyAppOperation: ResultOperation } guard ProcessInfo.processInfo.isOperatingSystemAtLeast(app.minimumiOSVersion) else { - throw VerificationError.iOSVersionNotSupported(app: app) + throw VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: app.minimumiOSVersion) } if #available(iOS 13.5, *) @@ -196,8 +224,7 @@ private extension VerifyAppOperation })) presentingViewController.present(alertController, animated: true, completion: nil) - case .mismatchedBundleIdentifiers: return completion(.failure(error)) - case .iOSVersionNotSupported: return completion(.failure(error)) + case .mismatchedBundleIdentifiers, .iOSVersionNotSupported: return completion(.failure(error)) } } }