diff --git a/AltStore/Analytics/AnalyticsManager.swift b/AltStore/Analytics/AnalyticsManager.swift index 8c88647f..2eebe247 100644 --- a/AltStore/Analytics/AnalyticsManager.swift +++ b/AltStore/Analytics/AnalyticsManager.swift @@ -24,6 +24,7 @@ extension AnalyticsManager case bundleIdentifier case developerName case version + case buildVersion case size case tintColor case sourceIdentifier @@ -59,6 +60,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 ce277f68..5ad7f7bd 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -84,7 +84,7 @@ final 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 7cca5eb1..ddf19ec9 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 35589978..c78a3585 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -341,7 +341,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 @@ -365,13 +367,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 ce2497a1..468014fb 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -248,7 +248,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) @@ -1049,7 +1049,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 f85eb755..5fce379e 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -64,22 +64,27 @@ final class DownloadAppOperation: ResultOperation 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)) + } + catch let error as VerificationError where error.code == .iOSVersionNotSupported + { + 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 ef54b472..20be4ac7 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 d8b1c9d7..ddd4eece 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 b63d727a..26c18c14 100644 --- a/AltStoreCore/Model/DatabaseManager.swift +++ b/AltStoreCore/Model/DatabaseManager.swift @@ -229,8 +229,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 } @@ -278,7 +277,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 26d3871a..e612aea9 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -49,6 +49,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 @@ -118,29 +119,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 @@ -165,6 +175,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 @@ -186,13 +209,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 8a629f89..3114d40e 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -316,6 +316,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, @@ -407,7 +408,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 = "SideStore" @@ -418,7 +419,8 @@ public extension StoreApp app.screenshotURLs = [] app.sourceIdentifier = Source.altStoreIdentifier - let appVersion = AppVersion.makeAppVersion(version: "0.3.0", + let appVersion = AppVersion.makeAppVersion(version: version, + buildVersion: buildVersion, date: Date(), downloadURL: URL(string: "http://rileytestut.com")!, size: 0,