From e97e48ba133b115a6adb5ac24b8e65947b0a60ee Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 18 May 2023 14:51:26 -0500 Subject: [PATCH] Supports app versions with explicit build versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AltStore will now consider an update available if either: * The source’s marketing version doesn’t match installed app’s version * The source declares a build version AND it doesn’t match the install app’s build version The installed app matches an app version if both maketing versions match, and the build versions match (if provided by the source). --- AltStore/Analytics/AnalyticsManager.swift | 2 + .../App Detail/AppContentViewController.swift | 2 +- AltStore/App Detail/AppViewController.swift | 4 +- AltStore/AppDelegate.swift | 16 ++++-- AltStore/My Apps/MyAppsViewController.swift | 4 +- .../Operations/DownloadAppOperation.swift | 11 ++-- .../Operations/FetchSourceOperation.swift | 4 +- .../AltStore 12.xcdatamodel/contents | 3 ++ AltStoreCore/Model/AppVersion.swift | 35 ++++++++++++- AltStoreCore/Model/DatabaseManager.swift | 5 +- AltStoreCore/Model/InstalledApp.swift | 50 +++++++++++++++---- AltStoreCore/Model/MergePolicy.swift | 24 ++++----- AltStoreCore/Model/StoreApp.swift | 6 ++- Dependencies/AltSign | 2 +- 14 files changed, 126 insertions(+), 42 deletions(-) diff --git a/AltStore/Analytics/AnalyticsManager.swift b/AltStore/Analytics/AnalyticsManager.swift index eafcb5dd..273bf7ce 100644 --- a/AltStore/Analytics/AnalyticsManager.swift +++ b/AltStore/Analytics/AnalyticsManager.swift @@ -30,6 +30,7 @@ extension AnalyticsManager case bundleIdentifier case developerName case version + case buildVersion case size case tintColor case sourceIdentifier @@ -65,6 +66,7 @@ extension AnalyticsManager .bundleIdentifier: app.bundleIdentifier, .developerName: app.storeApp?.developerName, .version: app.version, + .buildVersion: app.buildVersion, .size: appBundleSize?.description, .tintColor: app.storeApp?.tintColor?.hexString, .sourceIdentifier: app.storeApp?.sourceIdentifier, diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index ec47940d..c28afcd8 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -84,7 +84,7 @@ class AppContentViewController: UITableViewController if let version = self.app.latestAvailableVersion { self.versionDescriptionTextView.text = version.localizedDescription - self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version) + self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion) self.versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: self.dateFormatter) self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size) } diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index 7e1a15c2..1ea05cea 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -346,7 +346,7 @@ private extension AppViewController if let installedApp = self.app.installedApp { - if let latestVersion = self.app.latestAvailableVersion, installedApp.version != latestVersion.version + if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion) { button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal) } @@ -500,7 +500,7 @@ extension AppViewController { if let installedApp = self.app.installedApp { - if let latestVersion = self.app.latestAvailableVersion, installedApp.version != latestVersion.version + if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion) { self.updateApp(installedApp) } diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index e718b7ac..671c9679 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -343,7 +343,9 @@ private extension AppDelegate let previousUpdatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest() as! NSFetchRequest previousUpdatesFetchRequest.includesPendingChanges = false previousUpdatesFetchRequest.resultType = .dictionaryResultType - previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier), #keyPath(InstalledApp.storeApp.latestSupportedVersion.version)] + previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier), + #keyPath(InstalledApp.storeApp.latestSupportedVersion.version), + #keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)] let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest previousNewsItemsFetchRequest.includesPendingChanges = false @@ -367,13 +369,19 @@ private extension AppDelegate if let previousUpdate = previousUpdates.first(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) { - // An update for this app was already available, so check whether the version # is different. - guard let version = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion.version)], version != latestSupportedVersion.version else { continue } + // An update for this app was already available, so check whether the version or build version is different. + guard let previousVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion.version)] else { continue } + + // previousUpdate might not contain buildVersion, but if it does then map empty string to nil to match AppVersion. + let previousBuildVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)].map { $0.isEmpty ? nil : "" } + + // Only show notification if previous latestSupportedVersion does not _exactly_ match current latestSupportedVersion. + guard previousVersion != latestSupportedVersion.version || previousBuildVersion != latestSupportedVersion.buildVersion else { continue } } let content = UNMutableNotificationContent() content.title = NSLocalizedString("New Update Available", comment: "") - content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, latestSupportedVersion.version) + content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, latestSupportedVersion.localizedVersion) content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 03ef2d89..4eeafe11 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -240,7 +240,7 @@ private extension MyAppsViewController appName = app.name } - cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestSupportedVersion.version, versionDate) + cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestSupportedVersion.localizedVersion, versionDate) cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered) @@ -1073,7 +1073,7 @@ private extension MyAppsViewController var title = storeApp.name if let appVersion = storeApp.latestAvailableVersion { - title += " " + appVersion.version + title += " " + appVersion.localizedVersion var osVersion: String? = nil if let minOSVersion = appVersion.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index d8f6656c..d5c05a19 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -70,19 +70,24 @@ class DownloadAppOperation: ResultOperation } 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 + guard let presentingViewController = self.context.presentingViewController, let latestSupportedVersion = storeApp.latestSupportedVersion else { return self.finish(.failure(error)) } + if let installedApp = storeApp.installedApp + { + guard !installedApp.matches(latestSupportedVersion) 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: "") + let localizedVersion = latestSupportedVersion.localizedVersion 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 + alertController.addAction(UIAlertAction(title: String(format: NSLocalizedString("Download %@ %@", comment: ""), self.appName, localizedVersion), style: .default) { _ in self.download(latestSupportedVersion) }) presentingViewController.present(alertController, animated: true) diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index fbf3d7fb..3013398b 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -162,8 +162,8 @@ private extension FetchSourceOperation var versions = Set() for version in app.versions { - guard !versions.contains(version.version) else { throw SourceError.duplicateVersion(version.version, for: app, source: source) } - versions.insert(version.version) + guard !versions.contains(version.versionID) else { throw SourceError.duplicateVersion(version.localizedVersion, for: app, source: source) } + versions.insert(version.versionID) } for permission in app.permissions diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents index 580a76da..37c2632f 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 12.xcdatamodel/contents @@ -42,6 +42,7 @@ + @@ -57,11 +58,13 @@ + + diff --git a/AltStoreCore/Model/AppVersion.swift b/AltStoreCore/Model/AppVersion.swift index 2a8375ac..181a581f 100644 --- a/AltStoreCore/Model/AppVersion.swift +++ b/AltStoreCore/Model/AppVersion.swift @@ -13,9 +13,17 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable { /* Properties */ @NSManaged public var version: String + + // NULL does not work as expected with SQL Unique Constraints (because NULL != NULL), + // so we store non-optional value and provide public accessor with optional return type. + @nonobjc public var buildVersion: String? { + get { _buildVersion.isEmpty ? nil : _buildVersion } + set { _buildVersion = newValue ?? "" } + } + @NSManaged @objc(buildVersion) public private(set) var _buildVersion: String + @NSManaged public var date: Date @NSManaged public var localizedDescription: String? - @NSManaged public var downloadURL: URL @NSManaged public var size: Int64 @NSManaged public var sha256: String? @@ -51,6 +59,7 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable private enum CodingKeys: String, CodingKey { case version + case buildVersion case date case localizedDescription case downloadURL @@ -71,6 +80,8 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable let container = try decoder.container(keyedBy: CodingKeys.self) self.version = try container.decode(String.self, forKey: .version) + self.buildVersion = try container.decodeIfPresent(String.self, forKey: .buildVersion) + self.date = try container.decode(Date.self, forKey: .date) self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription) @@ -94,6 +105,26 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable } } +public extension AppVersion +{ + var localizedVersion: String { + guard let buildVersion else { return self.version } + + let localizedVersion = "\(self.version) (\(buildVersion))" + return localizedVersion + } + + var versionID: String { + // Use `nil` as fallback to prevent collisions between versions with builds and versions without. + // 1.5 (4) -> "1.5|4" + // 1.5.4 (no build) -> "1.5.4|nil" + let buildVersion = self.buildVersion ?? "nil" + + let versionID = "\(self.version)|\(buildVersion)" + return versionID + } +} + public extension AppVersion { @nonobjc class func fetchRequest() -> NSFetchRequest @@ -103,6 +134,7 @@ public extension AppVersion class func makeAppVersion( version: String, + buildVersion: String?, date: Date, localizedDescription: String? = nil, downloadURL: URL, @@ -113,6 +145,7 @@ public extension AppVersion { let appVersion = AppVersion(context: context) appVersion.version = version + appVersion.buildVersion = buildVersion appVersion.date = date appVersion.localizedDescription = localizedDescription appVersion.downloadURL = downloadURL diff --git a/AltStoreCore/Model/DatabaseManager.swift b/AltStoreCore/Model/DatabaseManager.swift index 6d9763b6..bc953f95 100644 --- a/AltStoreCore/Model/DatabaseManager.swift +++ b/AltStoreCore/Model/DatabaseManager.swift @@ -231,8 +231,7 @@ private extension DatabaseManager } else { - storeApp = StoreApp.makeAltStoreApp(in: context) - storeApp.latestSupportedVersion?.version = localApp.version + storeApp = StoreApp.makeAltStoreApp(version: localApp.version, buildVersion: localApp.buildVersion, in: context) storeApp.source = altStoreSource } @@ -280,7 +279,7 @@ private extension DatabaseManager #if DEBUG let replaceCachedApp = true #else - let replaceCachedApp = !FileManager.default.fileExists(atPath: fileURL.path) || installedApp.version != localApp.version + let replaceCachedApp = !FileManager.default.fileExists(atPath: fileURL.path) || installedApp.version != localApp.version || installedApp.buildVersion != localApp.buildVersion #endif if replaceCachedApp diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 9511e15c..14b3ba48 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -48,6 +48,7 @@ public class InstalledApp: NSManagedObject, InstalledAppProtocol @NSManaged public var bundleIdentifier: String @NSManaged public var resignedBundleIdentifier: String @NSManaged public var version: String + @NSManaged public var buildVersion: String @NSManaged public var refreshedDate: Date @NSManaged public var expirationDate: Date @@ -100,29 +101,38 @@ public class InstalledApp: NSManagedObject, InstalledAppProtocol self.update(resignedApp: resignedApp, certificateSerialNumber: certificateSerialNumber) } +} + +public extension InstalledApp +{ + var localizedVersion: String { + let localizedVersion = "\(self.version) (\(self.buildVersion))" + return localizedVersion + } - public func update(resignedApp: ALTApplication, certificateSerialNumber: String?) + func update(resignedApp: ALTApplication, certificateSerialNumber: String?) { self.name = resignedApp.name self.resignedBundleIdentifier = resignedApp.bundleIdentifier self.version = resignedApp.version + self.buildVersion = resignedApp.buildVersion self.certificateSerialNumber = certificateSerialNumber - + if let provisioningProfile = resignedApp.provisioningProfile { self.update(provisioningProfile: provisioningProfile) } } - public func update(provisioningProfile: ALTProvisioningProfile) + func update(provisioningProfile: ALTProvisioningProfile) { self.refreshedDate = provisioningProfile.creationDate self.expirationDate = provisioningProfile.expirationDate } - public func loadIcon(completion: @escaping (Result) -> Void) + func loadIcon(completion: @escaping (Result) -> Void) { let hasAlternateIcon = self.hasAlternateIcon let alternateIconURL = self.alternateIconURL @@ -147,6 +157,19 @@ public class InstalledApp: NSManagedObject, InstalledAppProtocol } } } + + func matches(_ appVersion: AppVersion) -> Bool + { + // Versions MUST match exactly. + guard self.version == appVersion.version else { return false } + + // If buildVersion is nil, return true because versions match. + guard let buildVersion = appVersion.buildVersion else { return true } + + // If buildVersion != nil, compare buildVersions and return result. + let matchesAppVersion = (self.buildVersion == buildVersion) + return matchesAppVersion + } } public extension InstalledApp @@ -168,13 +191,22 @@ public extension InstalledApp class func supportedUpdatesFetchRequest() -> NSFetchRequest { - let predicate = NSPredicate(format: "%K != nil AND %K != %K", - #keyPath(InstalledApp.storeApp.latestSupportedVersion), - #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.latestSupportedVersion.version)) + let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest - let fetchRequest = InstalledApp.updatesFetchRequest() - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate, predicate].compactMap { $0 }) + let predicateFormat = [ + // isActive && storeApp != nil && latestSupportedVersion != nil + "%K == YES AND %K != nil AND %K != nil", + + "AND", + + // (latestSupportedVersion.version != installedApp.version || (latestSupportedVersion.buildVersion != nil && latestSupportedVersion.buildVersion != installedApp.buildVersion)) + "(%K != %K OR (%K != '' AND %K != %K))", + ].joined(separator: " ") + fetchRequest.predicate = NSPredicate(format: predicateFormat, + #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.latestSupportedVersion), + #keyPath(InstalledApp.storeApp.latestSupportedVersion.version), #keyPath(InstalledApp.version), + #keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.buildVersion)) return fetchRequest } diff --git a/AltStoreCore/Model/MergePolicy.swift b/AltStoreCore/Model/MergePolicy.swift index f4b0b327..59687f22 100644 --- a/AltStoreCore/Model/MergePolicy.swift +++ b/AltStoreCore/Model/MergePolicy.swift @@ -181,7 +181,7 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy return } - var sortedVersionsByGlobalAppID = [String: NSOrderedSet]() + var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]() var permissionsByGlobalAppID = [String: Set]() var featuredAppIDsBySourceID = [String: [String]]() @@ -202,8 +202,8 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy } // Versions - let contextVersions = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version }) - for case let databaseVersion as AppVersion in databaseObject._versions where !contextVersions.contains(databaseVersion.version) + let contextVersionIDs = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.versionID }) + for case let databaseVersion as AppVersion in databaseObject._versions where !contextVersionIDs.contains(databaseVersion.versionID) { // Version # does NOT exist in context, so delete existing databaseVersion. databaseVersion.managedObjectContext?.delete(databaseVersion) @@ -212,7 +212,7 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy if let globallyUniqueID = contextApp.globallyUniqueID { permissionsByGlobalAppID[globallyUniqueID] = contextPermissions - sortedVersionsByGlobalAppID[globallyUniqueID] = contextVersions + sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs } case let databaseObject as Source: @@ -271,21 +271,21 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy } // App versions - if let sortedAppVersions = sortedVersionsByGlobalAppID[globallyUniqueID], - let sortedAppVersionsArray = sortedAppVersions.array as? [String], - case let databaseVersions = databaseObject.versions.map({ $0.version }), - databaseVersions != sortedAppVersionsArray + if let sortedAppVersionIDs = sortedVersionIDsByGlobalAppID[globallyUniqueID], + let sortedAppVersionsIDsArray = sortedAppVersionIDs.array as? [String], + case let databaseVersionIDs = databaseObject.versions.map({ $0.versionID }), + databaseVersionIDs != sortedAppVersionsIDsArray { // databaseObject.versions post-merge doesn't match contextApp.versions pre-merge, so attempt to fix by re-sorting. let fixedAppVersions = databaseObject.versions.sorted { (versionA, versionB) in - let indexA = sortedAppVersions.index(of: versionA.version) - let indexB = sortedAppVersions.index(of: versionB.version) + let indexA = sortedAppVersionIDs.index(of: versionA.versionID) + let indexB = sortedAppVersionIDs.index(of: versionB.versionID) return indexA < indexB } - let appVersionValues = fixedAppVersions.map { $0.version } - guard appVersionValues == sortedAppVersionsArray else { + let appVersionIDs = fixedAppVersions.map { $0.versionID } + guard appVersionIDs == sortedAppVersionsIDsArray else { // fixedAppVersions still doesn't match source's versions, so throw MergeError. throw MergeError.incorrectVersionOrder(for: databaseObject) } diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 505bd66e..df12043a 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -184,6 +184,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable let size = try container.decode(Int32.self, forKey: .size) let appVersion = AppVersion.makeAppVersion(version: version, + buildVersion: nil, date: versionDate, localizedDescription: versionDescription, downloadURL: downloadURL, @@ -275,7 +276,7 @@ public extension StoreApp return NSFetchRequest(entityName: "StoreApp") } - class func makeAltStoreApp(in context: NSManagedObjectContext) -> StoreApp + class func makeAltStoreApp(version: String, buildVersion: String?, in context: NSManagedObjectContext) -> StoreApp { let app = StoreApp(context: context) app.name = "AltStore" @@ -286,7 +287,8 @@ public extension StoreApp app.screenshotURLs = [] app.sourceIdentifier = Source.altStoreIdentifier - let appVersion = AppVersion.makeAppVersion(version: "1.0", + let appVersion = AppVersion.makeAppVersion(version: version, + buildVersion: buildVersion, date: Date(), downloadURL: URL(string: "http://rileytestut.com")!, size: 0, diff --git a/Dependencies/AltSign b/Dependencies/AltSign index 73e2ad8b..45a8dead 160000 --- a/Dependencies/AltSign +++ b/Dependencies/AltSign @@ -1 +1 @@ -Subproject commit 73e2ad8be1a7acc1a39830f9c239092890dbf6ea +Subproject commit 45a8dead440e5fb004b6d329fc30218f7b14004b