From fa160124d2044f27a163196e6876465837f2c9ec Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Mon, 12 Sep 2022 17:05:55 -0700 Subject: [PATCH] =?UTF-8?q?Supports=20new=20=E2=80=9Cversions=E2=80=9D=20k?= =?UTF-8?q?ey=20in=20source=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows sources to list multiple versions of an app. Preserves backwards compatibility by assigning legacy version values when assigning AppVersions. --- .../App Detail/AppContentViewController.swift | 19 ++- AltStore/App Detail/AppViewController.swift | 6 +- AltStore/AppDelegate.swift | 4 +- AltStore/Browse/BrowseViewController.swift | 2 +- AltStore/My Apps/MyAppsViewController.swift | 8 +- AltStore/News/NewsViewController.swift | 2 +- AltStoreCore/Model/AppVersion.swift | 62 ++++++--- AltStoreCore/Model/DatabaseManager.swift | 2 +- AltStoreCore/Model/InstalledApp.swift | 2 +- AltStoreCore/Model/MergePolicy.swift | 16 +++ AltStoreCore/Model/StoreApp.swift | 127 +++++++++++++++--- 11 files changed, 198 insertions(+), 52 deletions(-) diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index 6b7d556b..87d502f4 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -80,10 +80,21 @@ class AppContentViewController: UITableViewController self.subtitleLabel.text = self.app.subtitle self.descriptionTextView.text = self.app.localizedDescription - self.versionDescriptionTextView.text = self.app.versionDescription - self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), self.app.version) - self.versionDateLabel.text = Date().relativeDateString(since: self.app.versionDate, dateFormatter: self.dateFormatter) - self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: Int64(self.app.size)) + + if let version = self.app.latestVersion + { + self.versionDescriptionTextView.text = version.localizedDescription + self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version) + self.versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: self.dateFormatter) + self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size) + } + else + { + self.versionDescriptionTextView.text = nil + self.versionLabel.text = nil + self.versionDateLabel.text = nil + self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0) + } self.descriptionTextView.maximumNumberOfLines = 5 self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index 810a07f4..ccc6b4eb 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -384,10 +384,10 @@ private extension AppViewController button.progress = progress } - if Date() < self.app.versionDate + if let versionDate = self.app.latestVersion?.date, versionDate > Date() { - self.bannerView.button.countdownDate = self.app.versionDate - self.navigationBarDownloadButton.countdownDate = self.app.versionDate + self.bannerView.button.countdownDate = versionDate + self.navigationBarDownloadButton.countdownDate = versionDate } else { diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index d6589e6d..bd498aed 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -380,11 +380,11 @@ private extension AppDelegate for update in updates { guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue } - guard let storeApp = update.storeApp else { continue } + guard let storeApp = update.storeApp, let version = storeApp.version 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, storeApp.version) + content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, version) content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index aa15adcf..ec1bb75d 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -113,7 +113,7 @@ private extension BrowseViewController let progress = AppManager.shared.installationProgress(for: app) cell.bannerView.button.progress = progress - if Date() < app.versionDate + if let versionDate = app.latestVersion?.date, versionDate > Date() { cell.bannerView.button.countdownDate = app.versionDate } diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 19d993f0..03d65be0 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -186,7 +186,7 @@ private extension MyAppsViewController func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = InstalledApp.updatesFetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.versionDate, ascending: true), + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.latestVersion?.date, ascending: true), NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)] fetchRequest.returnsObjectsAsFaults = false @@ -195,7 +195,7 @@ private extension MyAppsViewController dataSource.cellIdentifierHandler = { _ in "UpdateCell" } dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in guard let self = self else { return } - guard let app = installedApp.storeApp else { return } + guard let app = installedApp.storeApp, let latestVersion = app.latestVersion else { return } let cell = cell as! UpdateCollectionViewCell cell.layoutMargins.left = self.view.layoutMargins.left @@ -209,7 +209,7 @@ private extension MyAppsViewController cell.bannerView.configure(for: app) - let versionDate = Date().relativeDateString(since: app.versionDate, dateFormatter: self.dateFormatter) + let versionDate = Date().relativeDateString(since: latestVersion.date, dateFormatter: self.dateFormatter) cell.bannerView.subtitleLabel.text = versionDate let appName: String @@ -223,7 +223,7 @@ private extension MyAppsViewController appName = app.name } - cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, app.version, versionDate) + cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestVersion.version, versionDate) cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered) diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index ab8bf209..0cf77a9c 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -391,7 +391,7 @@ extension NewsViewController let progress = AppManager.shared.installationProgress(for: storeApp) footerView.bannerView.button.progress = progress - if Date() < storeApp.versionDate + if let versionDate = storeApp.latestVersion?.date, versionDate > Date() { footerView.bannerView.button.countdownDate = storeApp.versionDate } diff --git a/AltStoreCore/Model/AppVersion.swift b/AltStoreCore/Model/AppVersion.swift index fda513ca..2bbde4a6 100644 --- a/AltStoreCore/Model/AppVersion.swift +++ b/AltStoreCore/Model/AppVersion.swift @@ -47,7 +47,6 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable super.init(entity: entity, insertInto: context) } - private enum CodingKeys: String, CodingKey { case version @@ -55,31 +54,34 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable case localizedDescription case downloadURL case size - case _minOSVersion - case _maxOSVersion - case appBundleID - case sourceID } public required init(from decoder: Decoder) throws { guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") } - super.init(entity: NewsItem.entity(), insertInto: context) + super.init(entity: AppVersion.entity(), insertInto: context) - let container = try decoder.container(keyedBy: CodingKeys.self) - self.version = try container.decode(String.self, forKey: .version) - self.date = try container.decode(Date.self, forKey: .date) - - self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription) - self.downloadURL = try container.decode(URL.self, forKey: .downloadURL) - self.size = try container.decode(Int64.self, forKey: .size) - - self._minOSVersion = try container.decodeIfPresent(String.self, forKey: ._minOSVersion) - self._maxOSVersion = try container.decodeIfPresent(String.self, forKey: ._maxOSVersion) - - self.appBundleID = try container.decode(String.self, forKey: .appBundleID) - self.sourceID = try container.decodeIfPresent(String.self, forKey: .sourceID) + do + { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.version = try container.decode(String.self, forKey: .version) + self.date = try container.decode(Date.self, forKey: .date) + self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription) + + self.downloadURL = try container.decode(URL.self, forKey: .downloadURL) + self.size = try container.decode(Int64.self, forKey: .size) + } + catch + { + if let context = self.managedObjectContext + { + context.delete(self) + } + + throw error + } } } @@ -89,4 +91,26 @@ public extension AppVersion { return NSFetchRequest(entityName: "AppVersion") } + + class func makeAppVersion( + version: String, + date: Date, + localizedDescription: String? = nil, + downloadURL: URL, + size: Int64, + appBundleID: String, + sourceID: String? = nil, + in context: NSManagedObjectContext) -> AppVersion + { + let appVersion = AppVersion(context: context) + appVersion.version = version + appVersion.date = date + appVersion.localizedDescription = localizedDescription + appVersion.downloadURL = downloadURL + appVersion.size = size + appVersion.appBundleID = appBundleID + appVersion.sourceID = sourceID + + return appVersion + } } diff --git a/AltStoreCore/Model/DatabaseManager.swift b/AltStoreCore/Model/DatabaseManager.swift index 72d617d7..9d517039 100644 --- a/AltStoreCore/Model/DatabaseManager.swift +++ b/AltStoreCore/Model/DatabaseManager.swift @@ -211,7 +211,7 @@ private extension DatabaseManager else { storeApp = StoreApp.makeAltStoreApp(in: context) - storeApp.version = localApp.version + storeApp.latestVersion?.version = localApp.version storeApp.source = altStoreSource } diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index f2ea3003..06645a13 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -146,7 +146,7 @@ public extension InstalledApp { let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest fetchRequest.predicate = NSPredicate(format: "%K == YES AND %K != nil AND %K != %K", - #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.version)) + #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.latestVersion.version)) return fetchRequest } diff --git a/AltStoreCore/Model/MergePolicy.swift b/AltStoreCore/Model/MergePolicy.swift index 5401c117..fbe1f818 100644 --- a/AltStoreCore/Model/MergePolicy.swift +++ b/AltStoreCore/Model/MergePolicy.swift @@ -31,6 +31,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy { permission.managedObjectContext?.delete(permission) } + + // Delete previous versions (different than below). + for case let appVersion as AppVersion in previousApp._versions where appVersion.app == nil + { + appVersion.managedObjectContext?.delete(appVersion) + } } default: @@ -55,6 +61,16 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy permission.managedObjectContext?.delete(permission) } + if let contextApp = conflict.conflictingObjects.first as? StoreApp + { + let contextVersions = Set(contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version }) + for case let appVersion as AppVersion in databaseObject._versions where !contextVersions.contains(appVersion.version) + { + print("[ALTLog] Deleting cached app version: \(appVersion.appBundleID + "_" + appVersion.version), not in:", contextApp.versions.map { $0.appBundleID + "_" + $0.version }) + appVersion.managedObjectContext?.delete(appVersion) + } + } + case let databaseObject as Source: guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break } diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 97a07ed3..5d17e33d 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -103,22 +103,41 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable @NSManaged public private(set) var developerName: String @NSManaged public private(set) var localizedDescription: String - @NSManaged public private(set) var size: Int32 + @NSManaged @objc(size) private var _size: Int32 @NSManaged public private(set) var iconURL: URL @NSManaged public private(set) var screenshotURLs: [URL] - @NSManaged public var version: String - @NSManaged public private(set) var versionDate: Date - @NSManaged public private(set) var versionDescription: String? + @NSManaged @objc(version) private var _version: String + @NSManaged @objc(versionDate) private var _versionDate: Date + @NSManaged @objc(versionDescription) private var _versionDescription: String? - @NSManaged public private(set) var downloadURL: URL + @NSManaged @objc(downloadURL) private var _downloadURL: URL @NSManaged public private(set) var platformURLs: PlatformURLs? @NSManaged public private(set) var tintColor: UIColor? @NSManaged public private(set) var isBeta: Bool - @NSManaged public var sourceIdentifier: String? + @objc public internal(set) var sourceIdentifier: String? { + get { + self.willAccessValue(forKey: #keyPath(sourceIdentifier)) + defer { self.didAccessValue(forKey: #keyPath(sourceIdentifier)) } + + let sourceIdentifier = self.primitiveSourceIdentifier + return sourceIdentifier + } + set { + self.willChangeValue(forKey: #keyPath(sourceIdentifier)) + self.primitiveSourceIdentifier = newValue + self.didChangeValue(forKey: #keyPath(sourceIdentifier)) + + for version in self.versions + { + version.sourceID = newValue + } + } + } + @NSManaged private var primitiveSourceIdentifier: String? @NSManaged public var sortIndex: Int32 @@ -152,6 +171,31 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable return self._versions.array as! [AppVersion] } + @nonobjc public var size: Int64? { + guard let version = self.latestVersion else { return nil } + return version.size + } + + @nonobjc public var version: String? { + guard let version = self.latestVersion else { return nil } + return version.version + } + + @nonobjc public var versionDescription: String? { + guard let version = self.latestVersion else { return nil } + return version.localizedDescription + } + + @nonobjc public var versionDate: Date? { + guard let version = self.latestVersion else { return nil } + return version.date + } + + @nonobjc public var downloadURL: URL? { + guard let version = self.latestVersion else { return nil } + return version.downloadURL + } + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { super.init(entity: entity, insertInto: context) @@ -175,6 +219,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable case permissions case size case isBeta = "beta" + case versions } public required init(from decoder: Decoder) throws @@ -194,10 +239,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) - self.version = try container.decode(String.self, forKey: .version) - self.versionDate = try container.decode(Date.self, forKey: .versionDate) - self.versionDescription = try container.decodeIfPresent(String.self, forKey: .versionDescription) - self.iconURL = try container.decode(URL.self, forKey: .iconURL) self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? [] @@ -207,14 +248,14 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable self.platformURLs = platformURLs // Backwards compatibility, use the fiirst (iOS will be first since sorted that way) if let first = platformURLs.sorted().first { - self.downloadURL = first.downloadURL + self._downloadURL = first.downloadURL } else { throw DecodingError.dataCorruptedError(forKey: .platformURLs, in: container, debugDescription: "platformURLs has no entries") } } else if let downloadURL = downloadURL { - self.downloadURL = downloadURL + self._downloadURL = downloadURL } else { throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "E downloadURL:String or downloadURLs:[[Platform:URL]] key required.") } @@ -228,11 +269,40 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable self.tintColor = tintColor } - self.size = try container.decode(Int32.self, forKey: .size) self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? [] self._permissions = NSOrderedSet(array: permissions) + + if let versions = try container.decodeIfPresent([AppVersion].self, forKey: .versions) + { + //TODO: Throw error if there isn't at least one version. + + for version in versions + { + version.appBundleID = self.bundleIdentifier + } + + self.setVersions(versions) + } + else + { + let version = try container.decode(String.self, forKey: .version) + let versionDate = try container.decode(Date.self, forKey: .versionDate) + let versionDescription = try container.decodeIfPresent(String.self, forKey: .versionDescription) + + let downloadURL = try container.decode(URL.self, forKey: .downloadURL) + let size = try container.decode(Int32.self, forKey: .size) + + let appVersion = AppVersion.makeAppVersion(version: version, + date: versionDate, + localizedDescription: versionDescription, + downloadURL: downloadURL, + size: Int64(size), + appBundleID: self.bundleIdentifier, + in: context) + self.setVersions([appVersion]) + } } catch { @@ -246,6 +316,24 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable } } +private extension StoreApp +{ + func setVersions(_ versions: [AppVersion]) + { + guard let latestVersion = versions.first else { preconditionFailure("StoreApp must have at least one AppVersion.") } + + self.latestVersion = latestVersion + self._versions = NSOrderedSet(array: versions) + + // Preserve backwards compatibility by assigning legacy property values. + self._version = latestVersion.version + self._versionDate = latestVersion.date + self._versionDescription = latestVersion.localizedDescription + self._downloadURL = latestVersion.downloadURL + self._size = Int32(latestVersion.size) + } +} + public extension StoreApp { @nonobjc class func fetchRequest() -> NSFetchRequest @@ -262,9 +350,16 @@ public extension StoreApp app.localizedDescription = "SideStore is an alternative App Store." app.iconURL = URL(string: "https://user-images.githubusercontent.com/705880/63392210-540c5980-c37b-11e9-968c-8742fc68ab2e.png")! app.screenshotURLs = [] - app.version = "1.0" - app.versionDate = Date() - app.downloadURL = URL(string: "http://rileytestut.com")! + app.sourceIdentifier = Source.altStoreIdentifier + + let appVersion = AppVersion.makeAppVersion(version: "1.0", + date: Date(), + downloadURL: URL(string: "http://rileytestut.com")!, + size: 0, + appBundleID: app.bundleIdentifier, + sourceID: Source.altStoreIdentifier, + in: context) + app.setVersions([appVersion]) #if BETA app.isBeta = true