mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
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.
406 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|