Supports app versions with explicit build versions

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).
This commit is contained in:
Riley Testut
2023-05-18 14:51:26 -05:00
committed by Magesh K
parent efce9a8579
commit f7640e35d1
13 changed files with 130 additions and 46 deletions

View File

@@ -42,6 +42,7 @@
</entity>
<entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
<attribute name="appBundleID" attributeType="String"/>
<attribute name="buildVersion" attributeType="String"/>
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="downloadURL" attributeType="URI"/>
<attribute name="localizedDescription" optional="YES" attributeType="String"/>
@@ -57,11 +58,13 @@
<uniquenessConstraint>
<constraint value="appBundleID"/>
<constraint value="version"/>
<constraint value="buildVersion"/>
<constraint value="sourceID"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="InstalledApp" representedClassName="InstalledApp" syncable="YES">
<attribute name="buildVersion" attributeType="String"/>
<attribute name="bundleIdentifier" attributeType="String"/>
<attribute name="certificateSerialNumber" optional="YES" attributeType="String"/>
<attribute name="expirationDate" attributeType="Date" usesScalarValueType="NO"/>

View File

@@ -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<AppVersion>
@@ -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

View File

@@ -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

View File

@@ -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<UIImage?, Error>) -> Void)
func loadIcon(completion: @escaping (Result<UIImage?, Error>) -> 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<InstalledApp>
{
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<InstalledApp>
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
}

View File

@@ -181,7 +181,7 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
return
}
var sortedVersionsByGlobalAppID = [String: NSOrderedSet]()
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
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)
}

View File

@@ -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<StoreApp>(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,