From 983355d3564c2e11d59cac18d309df01e4da4ed4 Mon Sep 17 00:00:00 2001 From: nythepegasus Date: Thu, 9 May 2024 01:38:45 -0400 Subject: [PATCH] Verifies min/max OS version before downloading app + asks user to download older app version if necessary --- AltStore.xcodeproj/project.pbxproj | 4 + .../Operations/DownloadAppOperation.swift | 105 ++++++++++++------ AltStore/Operations/VerifyAppOperation.swift | 57 ++++++---- .../Extensions/String+SideStore.swift | 14 +++ 4 files changed, 127 insertions(+), 53 deletions(-) create mode 100644 AltStoreCore/Extensions/String+SideStore.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index f29f2b41..314bd875 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 03F06CD52942C27E001C4D68 /* Bundle+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1E314122A05D4C00370A3C /* Bundle+AltStore.swift */; }; 0E05025A2BEC83C500879B5C /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0502592BEC83C500879B5C /* OperatingSystemVersion+Comparable.swift */; }; + 0E05025C2BEC947000879B5C /* String+SideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E05025B2BEC947000879B5C /* String+SideStore.swift */; }; 0E1A1F912AE36A9700364CAD /* bytearray.c in Sources */ = {isa = PBXBuildFile; fileRef = 0E1A1F902AE36A9600364CAD /* bytearray.c */; }; 0E764E172ADFF5740043DD4E /* AltBackup.ipa in Resources */ = {isa = PBXBuildFile; fileRef = 0E764E162ADFF5740043DD4E /* AltBackup.ipa */; }; 0EA1665B2ADFE0D2003015C1 /* out-limd.c in Sources */ = {isa = PBXBuildFile; fileRef = 0EA166472ADFE0D1003015C1 /* out-limd.c */; }; @@ -506,6 +507,7 @@ /* Begin PBXFileReference section */ 0E0502592BEC83C500879B5C /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; + 0E05025B2BEC947000879B5C /* String+SideStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SideStore.swift"; sourceTree = ""; }; 0E1A1F902AE36A9600364CAD /* bytearray.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = bytearray.c; path = src/bytearray.c; sourceTree = ""; }; 0E764E162ADFF5740043DD4E /* AltBackup.ipa */ = {isa = PBXFileReference; lastKnownFileType = file; name = AltBackup.ipa; path = AltStore/Resources/AltBackup.ipa; sourceTree = SOURCE_ROOT; }; 0EA166412ADFE0D1003015C1 /* jplist.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = jplist.c; path = Dependencies/libplist/src/jplist.c; sourceTree = SOURCE_ROOT; }; @@ -1419,6 +1421,7 @@ BF66EEE42501AED0007EE018 /* UserDefaults+AltStore.swift */, BF6A531F246DC1B0004F59C8 /* FileManager+SharedDirectories.swift */, 0E0502592BEC83C500879B5C /* OperatingSystemVersion+Comparable.swift */, + 0E05025B2BEC947000879B5C /* String+SideStore.swift */, ); path = Extensions; sourceTree = ""; @@ -2424,6 +2427,7 @@ BF66EE972501AEBC007EE018 /* ALTAppPermission.m in Sources */, BFAECC552501B0A400528F27 /* Connection.swift in Sources */, BF66EEDA2501AECA007EE018 /* RefreshAttempt.swift in Sources */, + 0E05025C2BEC947000879B5C /* String+SideStore.swift in Sources */, 0EE7FDCB2BE8D12B00D1E390 /* ALTLocalizedError.swift in Sources */, BF66EEA92501AEC5007EE018 /* Tier.swift in Sources */, BF66EEDB2501AECA007EE018 /* StoreApp.swift in Sources */, diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 8295873c..5655307d 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -17,46 +17,104 @@ final class DownloadAppOperation: ResultOperation { let app: AppProtocol let context: AppOperationContext - + private let appName: String 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 - + self.appName = app.name self.bundleIdentifier = app.bundleIdentifier self.sourceURL = app.url 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) - - guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) } - self.downloadApp(from: sourceURL) { result in + 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 = $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 +155,7 @@ final 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 9b262973..fa9789db 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -30,7 +30,7 @@ extension VerificationError VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID) } - static func iOSVersionNotSupported(app: ALTApplication) -> VerificationError { + static func iOSVersionNotSupported(app: ALTApplication, osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, requiredOSVersion: OperatingSystemVersion?) -> VerificationError { VerificationError(code: .iOSVersionNotSupported, app: app) } } @@ -41,38 +41,54 @@ 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 { + let firstLetter = failureReason.prefix(1).lowercased() + failureReason = firstLetter + failureReason.dropFirst() + } + + return String(formatted: "This device is running iOS %@, but %@", deviceOSVersion.stringValue, failureReason) + 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("Unknown app", comment: "") - return String(format: NSLocalizedString("“%@” requires private permissions.", comment: ""), appName) + let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") + return String(formatted: "“%@” requires private permissions.", appName) case .mismatchedBundleIdentifiers: - if let app, let sourceBundleID { - return String(format: NSLocalizedString("The bundle ID '%@' does not match the one specified by the source ('%@').", comment: ""), app.bundleIdentifier, sourceBundleID) + if let appBundleID = self.$app.bundleIdentifier, let bundleID = self.sourceBundleID { + return String(formatted: "The bundle ID '%@' does not match the one specified by the source ('%@').", appBundleID, bundleID) } else { return NSLocalizedString("The bundle ID does not match the one specified by the source.", comment: "") } case .iOSVersionNotSupported: - var failureReason: String! - if let app { - var version = "iOS \(app.minimumiOSVersion.majorVersion).\(app.minimumiOSVersion.minorVersion)" - if app.minimumiOSVersion.patchVersion > 0 { - version += ".\(app.minimumiOSVersion.patchVersion)" - } - failureReason = String(format: NSLocalizedString("%@ requires %@.", comment: ""), app.name, version) - } else { - let version = ProcessInfo.processInfo.operatingSystemVersionString - failureReason = String(format: NSLocalizedString("This app does not support iOS %@.", comment: ""), version) + let appName = self.$app.name ?? NSLocalizedString("The app", comment: "") + let deviceOSVersion = self.deviceOSVersion ?? ProcessInfo.processInfo.operatingSystemVersion + + guard let requiredOSVersion else { + return String(formatted: "%@ does not support iOS %@.", appName, deviceOSVersion.stringValue) + } + if deviceOSVersion > requiredOSVersion { + return String(formatted: "%@ requires iOS %@ or earlier", appName, requiredOSVersion.stringValue) + } else { + return String(formatted: "%@ requires iOS %@ or later", appName, requiredOSVersion.stringValue) } - return failureReason } } } @@ -108,7 +124,7 @@ final 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, *) @@ -190,8 +206,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)) } } } diff --git a/AltStoreCore/Extensions/String+SideStore.swift b/AltStoreCore/Extensions/String+SideStore.swift new file mode 100644 index 00000000..62566a80 --- /dev/null +++ b/AltStoreCore/Extensions/String+SideStore.swift @@ -0,0 +1,14 @@ +// +// String+SideStore.swift +// AltStoreCore +// +// Created by nythepegasus on 5/9/24. +// + +import Foundation + +public extension String { + init(formatted: String, comment: String? = nil, _ args: String...) { + self.init(format: NSLocalizedString(formatted, comment: comment ?? ""), args) + } +}