Files
SideStore/AltStoreCore/Model/InstalledApp.swift
Riley Testut 7f9ee81150 Refactors app version comparison logic to always include buildVersion
Before, whether or not the source included the buildVersion affected the comparison. If present, the buildVersion was used in comparison, if not, only the version itself was used for comparsion.

This meant it was impossible to update from a version with a buildVersion to the same version without one (e.g. going from betas to final releases). Now we _always_ consider the buildVersion in the comparsion, so an earlier entry in versions array without buildVersion can be considered “newer” even if versions match.
2024-12-26 21:15:29 +05:30

406 lines
16 KiB
Swift

//
// InstalledApp.swift
// AltStore
//
// Created by Riley Testut on 5/20/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import CoreData
import AltSign
import SemanticVersion
extension InstalledApp
{
public static var freeAccountActiveAppsLimit: Int {
if UserDefaults.standard.ignoreActiveAppsLimit
{
// MacDirtyCow exploit allows users to remove 3-app limit, so return 10 to match App ID limit per-week.
// Don't return nil because that implies there is no limit, which isn't quite true due to App ID limit.
return 10
}
else
{
// Free developer accounts are limited to only 3 active sideloaded apps at a time as of iOS 13.3.1.
return 3
}
}
}
public protocol InstalledAppProtocol: Fetchable
{
var name: String { get }
var bundleIdentifier: String { get }
var resignedBundleIdentifier: String { get }
var version: String { get }
var refreshedDate: Date { get }
var expirationDate: Date { get }
var installedDate: Date { get }
}
@objc(InstalledApp)
public class InstalledApp: NSManagedObject, InstalledAppProtocol
{
/* Properties */
@NSManaged public var name: String
@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
@NSManaged public var installedDate: Date
@NSManaged public var isActive: Bool
@NSManaged public var needsResign: Bool
@NSManaged public var hasAlternateIcon: Bool
@NSManaged public var certificateSerialNumber: String?
@NSManaged public var storeBuildVersion: String?
/* Transient */
@NSManaged public var isRefreshing: Bool
/* Relationships */
@NSManaged public var storeApp: StoreApp?
@NSManaged public var team: Team?
@NSManaged public var appExtensions: Set<InstalledExtension>
@NSManaged public private(set) var loggedErrors: NSSet /* Set<LoggedError> */ // Use NSSet to avoid eagerly fetching values.
public var isSideloaded: Bool {
return self.storeApp == nil
}
@objc public var hasUpdate: Bool {
if self.storeApp == nil { return false }
if self.storeApp!.latestSupportedVersion == nil { return false }
let currentVersion = SemanticVersion(self.version)
let latestVersion = SemanticVersion(self.storeApp!.latestSupportedVersion!.version)
if currentVersion == nil || latestVersion == nil {
// One of the versions is not valid SemVer, fall back to comparing the version strings by character
return self.version < self.storeApp!.latestSupportedVersion!.version
}
return currentVersion! < latestVersion!
}
public var appIDCount: Int {
return 1 + self.appExtensions.count
}
public var requiredActiveSlots: Int {
let requiredActiveSlots = UserDefaults.standard.activeAppLimitIncludesExtensions ? self.appIDCount : 1
return requiredActiveSlots
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
public init(resignedApp: ALTApplication, originalBundleIdentifier: String, certificateSerialNumber: String?, storeBuildVersion: String?, context: NSManagedObjectContext)
{
super.init(entity: InstalledApp.entity(), insertInto: context)
self.bundleIdentifier = originalBundleIdentifier
print("InstalledApp `self.bundleIdentifier`: \(self.bundleIdentifier)")
self.refreshedDate = Date()
self.installedDate = Date()
self.expirationDate = self.refreshedDate.addingTimeInterval(60 * 60 * 24 * 7) // Rough estimate until we get real values from provisioning profile.
// In practice this update() is redundant because we always call update() again after init from callers,
// but better to have an init that is guaranteed to successfully initialize an object
// than one that has a hidden assumption a second method will be called.
self.update(resignedApp: resignedApp, certificateSerialNumber: certificateSerialNumber, storeBuildVersion: storeBuildVersion)
}
}
public extension InstalledApp
{
var localizedVersion: String {
guard let storeBuildVersion else { return self.version }
let localizedVersion = "\(self.version) (\(storeBuildVersion))"
return localizedVersion
}
func update(resignedApp: ALTApplication, certificateSerialNumber: String?, storeBuildVersion: String?)
{
self.name = resignedApp.name
self.resignedBundleIdentifier = resignedApp.bundleIdentifier
self.version = resignedApp.version
self.buildVersion = resignedApp.buildVersion
self.storeBuildVersion = storeBuildVersion
self.certificateSerialNumber = certificateSerialNumber
if let provisioningProfile = resignedApp.provisioningProfile
{
self.update(provisioningProfile: provisioningProfile)
}
}
func update(provisioningProfile: ALTProvisioningProfile)
{
self.refreshedDate = provisioningProfile.creationDate
self.expirationDate = provisioningProfile.expirationDate
}
func loadIcon(completion: @escaping (Result<UIImage?, Error>) -> Void)
{
let hasAlternateIcon = self.hasAlternateIcon
let alternateIconURL = self.alternateIconURL
let fileURL = self.fileURL
DispatchQueue.global().async {
do
{
if hasAlternateIcon,
case let data = try Data(contentsOf: alternateIconURL),
let icon = UIImage(data: data)
{
return completion(.success(icon))
}
let application = ALTApplication(fileURL: fileURL)
completion(.success(application?.icon))
}
catch
{
completion(.failure(error))
}
}
}
func matches(_ appVersion: AppVersion) -> Bool
{
let matchesAppVersion = (self.version == appVersion.version && self.storeBuildVersion == appVersion.buildVersion)
return matchesAppVersion
}
}
public extension InstalledApp
{
@nonobjc class func fetchRequest() -> NSFetchRequest<InstalledApp>
{
return NSFetchRequest<InstalledApp>(entityName: "InstalledApp")
}
class func supportedUpdatesFetchRequest() -> NSFetchRequest<InstalledApp>
{
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
let predicateFormat = [
// isActive && storeApp != nil && latestSupportedVersion != nil
"%K == YES AND %K != nil AND %K != nil",
"AND",
// latestSupportedVersion.version != installedApp.version || latestSupportedVersion.buildVersion != installedApp.storeBuildVersion
//
// We have to also check !(latestSupportedVersion.buildVersion == '' && installedApp.storeBuildVersion == nil)
// because latestSupportedVersion.buildVersion stores an empty string for nil, while installedApp.storeBuildVersion uses NULL.
"(%K != %K OR (%K != %K AND NOT (%K == '' AND %K == nil)))",
].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.storeBuildVersion),
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion))
return fetchRequest
}
class func activeAppsFetchRequest() -> NSFetchRequest<InstalledApp>
{
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
fetchRequest.predicate = NSPredicate(format: "%K == YES", #keyPath(InstalledApp.isActive))
print("Active Apps Fetch Request: \(String(describing: fetchRequest.predicate))")
return fetchRequest
}
class func fetchAltStore(in context: NSManagedObjectContext) -> InstalledApp?
{
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
print("Fetch 'AltStore' Predicate: \(String(describing: predicate))")
let altStore = InstalledApp.first(satisfying: predicate, in: context)
return altStore
}
class func fetchActiveApps(in context: NSManagedObjectContext) -> [InstalledApp]
{
let activeApps = InstalledApp.fetch(InstalledApp.activeAppsFetchRequest(), in: context)
return activeApps
}
class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp]
{
let predicate = NSPredicate(format: "%K == YES AND %K != %@", #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
print("Fetch Apps for Refreshing All 'AltStore' predicate: \(String(describing: predicate))")
// if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
// {
// // No additional predicate
// }
// else
// {
// predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
// NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
// }
var installedApps = InstalledApp.all(satisfying: predicate,
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
in: context)
if let altStoreApp = InstalledApp.fetchAltStore(in: context)
{
// Refresh AltStore last since it causes app to quit.
installedApps.append(altStoreApp)
}
return installedApps
}
class func fetchAppsForBackgroundRefresh(in context: NSManagedObjectContext) -> [InstalledApp]
{
// Date 6 hours before now.
let date = Date().addingTimeInterval(-1 * 6 * 60 * 60)
let predicate = NSPredicate(format: "(%K == YES) AND (%K < %@) AND (%K != %@)",
#keyPath(InstalledApp.isActive),
#keyPath(InstalledApp.refreshedDate), date as NSDate,
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
print("Active Apps For Background Refresh 'AltStore' predicate: \(String(describing: predicate))")
// if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
// {
// // No additional predicate
// }
// else
// {
// predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
// NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
// }
var installedApps = InstalledApp.all(satisfying: predicate,
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
in: context)
if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date
{
// Refresh AltStore last since it may cause app to quit.
installedApps.append(altStoreApp)
}
return installedApps
}
}
public extension InstalledApp
{
var openAppURL: URL {
let openAppURL = URL(string: "altstore-" + self.bundleIdentifier + "://")!
return openAppURL
}
class func openAppURL(for app: AppProtocol) -> URL
{
let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")!
return openAppURL
}
}
public extension InstalledApp
{
class var appsDirectoryURL: URL {
let baseDirectory = FileManager.default.altstoreSharedDirectory ?? FileManager.default.applicationSupportDirectory
let appsDirectoryURL = baseDirectory.appendingPathComponent("Apps")
do { try FileManager.default.createDirectory(at: appsDirectoryURL, withIntermediateDirectories: true, attributes: nil) }
catch { print("Creating App Directory Error: \(error)") }
return appsDirectoryURL
}
class var legacyAppsDirectoryURL: URL {
let baseDirectory = FileManager.default.applicationSupportDirectory
let appsDirectoryURL = baseDirectory.appendingPathComponent("Apps")
return appsDirectoryURL
}
class func fileURL(for app: AppProtocol) -> URL
{
let appURL = self.directoryURL(for: app).appendingPathComponent("App.app")
return appURL
}
class func refreshedIPAURL(for app: AppProtocol) -> URL
{
let ipaURL = self.directoryURL(for: app).appendingPathComponent("Refreshed.ipa")
print("`ipaURL`: \(ipaURL.absoluteString)")
return ipaURL
}
class func directoryURL(for app: AppProtocol) -> URL
{
let directoryURL = InstalledApp.appsDirectoryURL.appendingPathComponent(app.bundleIdentifier)
do { try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) }
catch { print(error) }
return directoryURL
}
class func installedAppUTI(forBundleIdentifier bundleIdentifier: String) -> String
{
let installedAppUTI = "io.altstore.Installed." + bundleIdentifier
return installedAppUTI
}
class func installedBackupAppUTI(forBundleIdentifier bundleIdentifier: String) -> String
{
let installedBackupAppUTI = InstalledApp.installedAppUTI(forBundleIdentifier: bundleIdentifier) + ".backup"
return installedBackupAppUTI
}
class func alternateIconURL(for app: AppProtocol) -> URL
{
let installedBackupAppUTI = self.directoryURL(for: app).appendingPathComponent("AltIcon.png")
return installedBackupAppUTI
}
var directoryURL: URL {
return InstalledApp.directoryURL(for: self)
}
var fileURL: URL {
return InstalledApp.fileURL(for: self)
}
var refreshedIPAURL: URL {
return InstalledApp.refreshedIPAURL(for: self)
}
var installedAppUTI: String {
return InstalledApp.installedAppUTI(forBundleIdentifier: self.resignedBundleIdentifier)
}
var installedBackupAppUTI: String {
return InstalledApp.installedBackupAppUTI(forBundleIdentifier: self.resignedBundleIdentifier)
}
var alternateIconURL: URL {
return InstalledApp.alternateIconURL(for: self)
}
}