mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-12 00:03:27 +01:00
Merge branch 'revised_source_json'
# Conflicts: # AltStore.xcodeproj/project.pbxproj # AltStore/App Detail/AppContentViewController.swift # AltStore/App Detail/AppViewController.swift # AltStore/Base.lproj/Main.storyboard # AltStoreCore/Model/DatabaseManager.swift
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D68" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||
<attribute name="appleID" attributeType="String"/>
|
||||
<attribute name="firstName" attributeType="String"/>
|
||||
@@ -42,6 +42,23 @@
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="AppScreenshot" representedClassName="AppScreenshot" syncable="YES">
|
||||
<attribute name="appBundleID" attributeType="String"/>
|
||||
<attribute name="deviceType" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="height" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
|
||||
<attribute name="imageURL" attributeType="URI"/>
|
||||
<attribute name="sourceID" attributeType="String"/>
|
||||
<attribute name="width" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
|
||||
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="screenshots" inverseEntity="StoreApp"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="imageURL"/>
|
||||
<constraint value="deviceType"/>
|
||||
<constraint value="appBundleID"/>
|
||||
<constraint value="sourceID"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
|
||||
<attribute name="appBundleID" attributeType="String"/>
|
||||
<attribute name="buildVersion" attributeType="String"/>
|
||||
@@ -207,6 +224,7 @@
|
||||
<relationship name="loggedErrors" toMany="YES" deletionRule="Nullify" destinationEntity="LoggedError" inverseName="storeApp" inverseEntity="LoggedError"/>
|
||||
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" destinationEntity="NewsItem" inverseName="storeApp" inverseEntity="NewsItem"/>
|
||||
<relationship name="permissions" toMany="YES" deletionRule="Cascade" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission"/>
|
||||
<relationship name="screenshots" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppScreenshot" inverseName="app" inverseEntity="AppScreenshot"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source"/>
|
||||
<relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
|
||||
<uniquenessConstraints>
|
||||
|
||||
@@ -12,7 +12,7 @@ import UIKit
|
||||
import AltSign
|
||||
|
||||
@objc(AppPermission) @dynamicMemberLookup
|
||||
public class AppPermission: NSManagedObject, Decodable, Fetchable
|
||||
public class AppPermission: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public var type: ALTAppPermissionType
|
||||
@@ -47,37 +47,13 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey
|
||||
convenience init(permission: String, usageDescription: String?, type: ALTAppPermissionType, context: NSManagedObjectContext)
|
||||
{
|
||||
case name
|
||||
case usageDescription
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws
|
||||
{
|
||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
||||
self.init(entity: AppPermission.entity(), insertInto: context)
|
||||
|
||||
super.init(entity: AppPermission.entity(), insertInto: context)
|
||||
|
||||
do
|
||||
{
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self._permission = try container.decode(String.self, forKey: .name)
|
||||
self.usageDescription = try container.decodeIfPresent(String.self, forKey: .usageDescription)
|
||||
|
||||
// Will be updated from StoreApp.
|
||||
self.type = .unknown
|
||||
}
|
||||
catch
|
||||
{
|
||||
if let context = self.managedObjectContext
|
||||
{
|
||||
context.delete(self)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
self._permission = permission
|
||||
self.usageDescription = usageDescription
|
||||
self.type = type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,3 +75,114 @@ public extension AppPermission
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnyDecodable: Decodable
|
||||
{
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal struct AppPermissions: Decodable
|
||||
{
|
||||
var entitlements: [AppPermission] = []
|
||||
var privacy: [AppPermission] = []
|
||||
|
||||
private enum CodingKeys: String, CodingKey, Decodable
|
||||
{
|
||||
case entitlements
|
||||
case privacy
|
||||
|
||||
// Legacy
|
||||
case name
|
||||
case usageDescription
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.entitlements = try self.parseEntitlements(from: container, into: context)
|
||||
self.privacy = try self.parsePrivacyPermissions(from: container, into: context)
|
||||
}
|
||||
|
||||
private func parseEntitlements(from container: KeyedDecodingContainer<CodingKeys>, into context: NSManagedObjectContext) throws -> [AppPermission]
|
||||
{
|
||||
guard container.contains(.entitlements) else { return [] }
|
||||
|
||||
do
|
||||
{
|
||||
do
|
||||
{
|
||||
// Legacy
|
||||
// Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error.
|
||||
let rawEntitlements = try container.decode([[String: String]].self, forKey: .entitlements)
|
||||
|
||||
let entitlements = try rawEntitlements.compactMap { (dictionary) -> AppPermission? in
|
||||
guard let name = dictionary[CodingKeys.name.rawValue] else {
|
||||
let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy entitlements must have `name` key.")
|
||||
throw DecodingError.keyNotFound(CodingKeys.name, context)
|
||||
}
|
||||
|
||||
let entitlement = AppPermission(permission: name, usageDescription: nil, type: .entitlement, context: context)
|
||||
return entitlement
|
||||
}
|
||||
|
||||
return entitlements
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Detailed
|
||||
// AnyDecodable ensures we're forward-compatible with any values we may later require for entitlement permissions.
|
||||
let rawEntitlements = try container.decode([String: AnyDecodable?].self, forKey: .entitlements)
|
||||
|
||||
let entitlements = rawEntitlements.map { AppPermission(permission: $0.key, usageDescription: nil, type: .entitlement, context: context) }
|
||||
return entitlements
|
||||
}
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Default
|
||||
let rawEntitlements = try container.decode([String].self, forKey: .entitlements)
|
||||
|
||||
let entitlements = rawEntitlements.map { AppPermission(permission: $0, usageDescription: nil, type: .entitlement, context: context) }
|
||||
return entitlements
|
||||
}
|
||||
}
|
||||
|
||||
private func parsePrivacyPermissions(from container: KeyedDecodingContainer<CodingKeys>, into context: NSManagedObjectContext) throws -> [AppPermission]
|
||||
{
|
||||
guard container.contains(.privacy) else { return [] }
|
||||
|
||||
do
|
||||
{
|
||||
// Legacy
|
||||
// Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error.
|
||||
let rawPermissions = try container.decode([[String: String]].self, forKey: .privacy)
|
||||
|
||||
let permissions = try rawPermissions.compactMap { (dictionary) -> AppPermission? in
|
||||
guard let name = dictionary[CodingKeys.name.rawValue] else {
|
||||
let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy privacy permissions must have `name` key.")
|
||||
throw DecodingError.keyNotFound(CodingKeys.name, context)
|
||||
}
|
||||
|
||||
let usageDescription = dictionary[CodingKeys.usageDescription.rawValue]
|
||||
|
||||
let convertedName = "NS" + name + "UsageDescription" // Convert legacy privacy permissions to their NS[Privacy]UsageDescription equivalent.
|
||||
let permission = AppPermission(permission: convertedName, usageDescription: usageDescription, type: .privacy, context: context)
|
||||
return permission
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Default
|
||||
let rawPermissions = try container.decode([String: String?].self, forKey: .privacy)
|
||||
|
||||
let permissions = rawPermissions.map { AppPermission(permission: $0, usageDescription: $1, type: .privacy, context: context) }
|
||||
return permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
AltStoreCore/Model/AppScreenshot.swift
Normal file
208
AltStoreCore/Model/AppScreenshot.swift
Normal file
@@ -0,0 +1,208 @@
|
||||
//
|
||||
// AppScreenshot.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 9/18/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
import AltSign
|
||||
|
||||
public extension AppScreenshot
|
||||
{
|
||||
static let defaultAspectRatio = CGSize(width: 9, height: 19.5)
|
||||
}
|
||||
|
||||
@objc(AppScreenshot)
|
||||
public class AppScreenshot: NSManagedObject, Fetchable, Decodable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var imageURL: URL
|
||||
|
||||
public private(set) var size: CGSize? {
|
||||
get {
|
||||
guard let width = self.width?.doubleValue, let height = self.height?.doubleValue else { return nil }
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
set {
|
||||
if let newValue
|
||||
{
|
||||
self.width = NSNumber(value: newValue.width)
|
||||
self.height = NSNumber(value: newValue.height)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.width = nil
|
||||
self.height = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@NSManaged private var width: NSNumber?
|
||||
@NSManaged private var height: NSNumber?
|
||||
|
||||
// Defaults to .iphone
|
||||
@nonobjc public var deviceType: ALTDeviceType {
|
||||
get { ALTDeviceType(rawValue: Int(_deviceType)) }
|
||||
set { _deviceType = Int16(newValue.rawValue) }
|
||||
}
|
||||
@NSManaged @objc(deviceType) private var _deviceType: Int16
|
||||
|
||||
@NSManaged public internal(set) var appBundleID: String
|
||||
@NSManaged public internal(set) var sourceID: String
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public internal(set) var app: StoreApp?
|
||||
|
||||
private enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case imageURL
|
||||
case width
|
||||
case height
|
||||
}
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
internal init(imageURL: URL, size: CGSize?, deviceType: ALTDeviceType, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: AppScreenshot.entity(), insertInto: context)
|
||||
|
||||
self.imageURL = imageURL
|
||||
self.size = size
|
||||
self.deviceType = deviceType
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws
|
||||
{
|
||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
||||
|
||||
super.init(entity: AppScreenshot.entity(), insertInto: context)
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.imageURL = try container.decode(URL.self, forKey: .imageURL)
|
||||
|
||||
self.width = try container.decodeIfPresent(Int16.self, forKey: .width).map { NSNumber(value: $0) }
|
||||
self.height = try container.decodeIfPresent(Int16.self, forKey: .height).map { NSNumber(value: $0) }
|
||||
}
|
||||
|
||||
public override func awakeFromInsert()
|
||||
{
|
||||
super.awakeFromInsert()
|
||||
|
||||
self.deviceType = .iphone
|
||||
}
|
||||
}
|
||||
|
||||
public extension AppScreenshot
|
||||
{
|
||||
var aspectRatio: CGSize {
|
||||
return self.size ?? AppScreenshot.defaultAspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
extension AppScreenshot
|
||||
{
|
||||
var screenshotID: String {
|
||||
let screenshotID = "\(self.imageURL.absoluteString)|\(self.deviceType)"
|
||||
return screenshotID
|
||||
}
|
||||
}
|
||||
|
||||
public extension AppScreenshot
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<AppScreenshot>
|
||||
{
|
||||
return NSFetchRequest<AppScreenshot>(entityName: "AppScreenshot")
|
||||
}
|
||||
}
|
||||
|
||||
internal struct AppScreenshots: Decodable
|
||||
{
|
||||
var screenshots: [AppScreenshot] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case iphone
|
||||
case ipad
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
let container: KeyedDecodingContainer<CodingKeys>
|
||||
|
||||
do
|
||||
{
|
||||
container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// ONLY catch the container's DecodingError.typeMismatch, not the below decodeIfPresent()'s
|
||||
|
||||
// Fallback to single array.
|
||||
|
||||
var collection = try Collection(from: decoder)
|
||||
collection.deviceType = .iphone
|
||||
|
||||
self.screenshots = collection.screenshots
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if var collection = try container.decodeIfPresent(Collection.self, forKey: .iphone)
|
||||
{
|
||||
collection.deviceType = .iphone
|
||||
self.screenshots += collection.screenshots
|
||||
}
|
||||
|
||||
if var collection = try container.decodeIfPresent(Collection.self, forKey: .ipad)
|
||||
{
|
||||
collection.deviceType = .ipad
|
||||
self.screenshots += collection.screenshots
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppScreenshots
|
||||
{
|
||||
struct Collection: Decodable
|
||||
{
|
||||
var screenshots: [AppScreenshot] = []
|
||||
|
||||
var deviceType: ALTDeviceType = .iphone {
|
||||
didSet {
|
||||
self.screenshots.forEach { $0.deviceType = self.deviceType }
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
||||
|
||||
var container = try decoder.unkeyedContainer()
|
||||
|
||||
while !container.isAtEnd
|
||||
{
|
||||
do
|
||||
{
|
||||
// Attempt parsing as URL first.
|
||||
let imageURL = try container.decode(URL.self)
|
||||
|
||||
let screenshot = AppScreenshot(imageURL: imageURL, size: nil, deviceType: self.deviceType, context: context)
|
||||
self.screenshots.append(screenshot)
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Fall back to parsing full AppScreenshot (preferred).
|
||||
|
||||
let screenshot = try container.decode(AppScreenshot.self)
|
||||
self.screenshots.append(screenshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ private extension DatabaseManager
|
||||
}
|
||||
|
||||
// Make sure to always update source URL to be current.
|
||||
altStoreSource.sourceURL = Source.altStoreSourceURL
|
||||
try! altStoreSource.setSourceURL(Source.altStoreSourceURL)
|
||||
|
||||
let storeApp: StoreApp
|
||||
|
||||
|
||||
@@ -156,6 +156,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
{
|
||||
appVersion.managedObjectContext?.delete(appVersion)
|
||||
}
|
||||
|
||||
// Delete previous screenshots (different than below).
|
||||
for case let appScreenshot as AppScreenshot in previousApp._screenshots where appScreenshot.app == nil
|
||||
{
|
||||
appScreenshot.managedObjectContext?.delete(appScreenshot)
|
||||
}
|
||||
}
|
||||
|
||||
case is AppVersion where conflict.conflictingObjects.count == 2:
|
||||
@@ -181,8 +187,9 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
return
|
||||
}
|
||||
|
||||
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
|
||||
var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
|
||||
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
|
||||
var sortedScreenshotIDsByGlobalAppID = [String: NSOrderedSet]()
|
||||
|
||||
var featuredAppIDsBySourceID = [String: [String]]()
|
||||
|
||||
@@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
databaseVersion.managedObjectContext?.delete(databaseVersion)
|
||||
}
|
||||
|
||||
// Screenshots
|
||||
let contextScreenshotIDs = NSOrderedSet(array: contextApp._screenshots.lazy.compactMap { $0 as? AppScreenshot }.map { $0.screenshotID })
|
||||
for case let databaseScreenshot as AppScreenshot in databaseObject._screenshots where !contextScreenshotIDs.contains(databaseScreenshot.screenshotID)
|
||||
{
|
||||
// Screenshot ID does NOT exist in context, so delete existing databaseScreenshot.
|
||||
databaseScreenshot.managedObjectContext?.delete(databaseScreenshot)
|
||||
}
|
||||
|
||||
if let globallyUniqueID = contextApp.globallyUniqueID
|
||||
{
|
||||
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
|
||||
sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs
|
||||
sortedScreenshotIDsByGlobalAppID[globallyUniqueID] = contextScreenshotIDs
|
||||
}
|
||||
|
||||
case let databaseObject as Source:
|
||||
@@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
|
||||
appVersions = fixedAppVersions
|
||||
}
|
||||
|
||||
// Screenshots
|
||||
if let sortedScreenshotIDs = sortedScreenshotIDsByGlobalAppID[globallyUniqueID],
|
||||
let sortedScreenshotIDsArray = sortedScreenshotIDs.array as? [String],
|
||||
case let databaseScreenshotIDs = databaseObject.allScreenshots.map({ $0.screenshotID }),
|
||||
databaseScreenshotIDs != sortedScreenshotIDsArray
|
||||
{
|
||||
// Screenshot order is incorrect, so attempt to fix by re-sorting.
|
||||
let fixedScreenshots = databaseObject.allScreenshots.sorted { (screenshotA, screenshotB) in
|
||||
let indexA = sortedScreenshotIDs.index(of: screenshotA.screenshotID)
|
||||
let indexB = sortedScreenshotIDs.index(of: screenshotB.screenshotID)
|
||||
return indexA < indexB
|
||||
}
|
||||
|
||||
let appScreenshotIDs = fixedScreenshots.map { $0.screenshotID }
|
||||
if appScreenshotIDs == sortedScreenshotIDsArray
|
||||
{
|
||||
databaseObject.setScreenshots(fixedScreenshots)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Screenshots are still not in correct order, but not worth throwing error so ignore.
|
||||
print("Failed to re-sort screenshots into correct order. Expected:", sortedScreenshotIDsArray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update versions post-merging to make sure latestSupportedVersion is correct.
|
||||
|
||||
@@ -11,11 +11,7 @@ import UIKit
|
||||
|
||||
public extension Source
|
||||
{
|
||||
#if ALPHA
|
||||
static let altStoreIdentifier = "com.rileytestut.AltStore.Alpha"
|
||||
#else
|
||||
static let altStoreIdentifier = "com.rileytestut.AltStore"
|
||||
#endif
|
||||
static let altStoreIdentifier = try! Source.sourceID(from: Source.altStoreSourceURL)
|
||||
|
||||
#if STAGING
|
||||
|
||||
@@ -62,7 +58,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var identifier: String
|
||||
@NSManaged public private(set) var identifier: String
|
||||
@NSManaged public var sourceURL: URL
|
||||
|
||||
/* Source Detail */
|
||||
@@ -114,7 +110,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
private enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case name
|
||||
case identifier
|
||||
case sourceURL
|
||||
case subtitle
|
||||
case localizedDescription = "description"
|
||||
@@ -143,11 +138,8 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
|
||||
do
|
||||
{
|
||||
self.sourceURL = sourceURL
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
self.identifier = try container.decode(String.self, forKey: .identifier)
|
||||
|
||||
// Optional Values
|
||||
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
|
||||
@@ -173,7 +165,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
|
||||
for (index, app) in apps.enumerated()
|
||||
{
|
||||
app.sourceIdentifier = self.identifier
|
||||
app.sortIndex = Int32(index)
|
||||
}
|
||||
self._apps = NSMutableOrderedSet(array: apps)
|
||||
@@ -181,7 +172,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? []
|
||||
for (index, item) in newsItems.enumerated()
|
||||
{
|
||||
item.sourceIdentifier = self.identifier
|
||||
item.sortIndex = Int32(index)
|
||||
}
|
||||
|
||||
@@ -203,6 +193,9 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
let featuredAppBundleIDs = try container.decodeIfPresent([String].self, forKey: .featuredApps)
|
||||
let featuredApps = featuredAppBundleIDs?.compactMap { appsByID[$0] }
|
||||
self.setFeaturedApps(featuredApps)
|
||||
|
||||
// Updates identifier + apps & newsItems
|
||||
try self.setSourceURL(sourceURL)
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -257,6 +250,54 @@ public extension Source
|
||||
|
||||
internal extension Source
|
||||
{
|
||||
class func sourceID(from sourceURL: URL) throws -> String
|
||||
{
|
||||
// Based on https://encyclopedia.pub/entry/29841
|
||||
|
||||
guard var components = URLComponents(url: sourceURL, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
|
||||
if components.scheme == nil && components.host == nil
|
||||
{
|
||||
// Special handling for URLs without explicit scheme & incorrectly assumed to have nil host (e.g. "altstore.io/my/path")
|
||||
guard let updatedComponents = URLComponents(string: "https://" + sourceURL.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
components = updatedComponents
|
||||
}
|
||||
|
||||
// 1. Don't use percent encoding
|
||||
guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
|
||||
// 2. Ignore scheme
|
||||
var standardizedID = host
|
||||
|
||||
// 3. Add port (if not default)
|
||||
if let port = components.port, port != 80 && port != 443
|
||||
{
|
||||
standardizedID += ":" + String(port)
|
||||
}
|
||||
|
||||
// 4. Add path without fragment or query parameters
|
||||
// 5. Remove duplicate slashes
|
||||
let path = components.path.replacingOccurrences(of: "//", with: "/") // Only remove duplicate slashes from path, not entire URL.
|
||||
standardizedID += path // path has leading `/`
|
||||
|
||||
// 6. Convert to lowercase
|
||||
standardizedID = standardizedID.lowercased()
|
||||
|
||||
// 7. Remove trailing `/`
|
||||
if standardizedID.hasSuffix("/")
|
||||
{
|
||||
standardizedID.removeLast()
|
||||
}
|
||||
|
||||
// 8. Remove leading "www"
|
||||
if standardizedID.hasPrefix("www.")
|
||||
{
|
||||
standardizedID.removeFirst(4)
|
||||
}
|
||||
|
||||
return standardizedID
|
||||
}
|
||||
|
||||
func setFeaturedApps(_ featuredApps: [StoreApp]?)
|
||||
{
|
||||
// Explicitly update relationships for all apps to ensure featuredApps merges correctly.
|
||||
@@ -278,6 +319,27 @@ internal extension Source
|
||||
}
|
||||
}
|
||||
|
||||
public extension Source
|
||||
{
|
||||
func setSourceURL(_ sourceURL: URL) throws
|
||||
{
|
||||
let identifier = try Source.sourceID(from: sourceURL)
|
||||
|
||||
self.identifier = identifier
|
||||
self.sourceURL = sourceURL
|
||||
|
||||
for app in self.apps
|
||||
{
|
||||
app.sourceIdentifier = identifier
|
||||
}
|
||||
|
||||
for newsItem in self.newsItems
|
||||
{
|
||||
newsItem.sourceIdentifier = identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Source
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Source>
|
||||
@@ -289,8 +351,7 @@ public extension Source
|
||||
{
|
||||
let source = Source(context: context)
|
||||
source.name = "AltStore"
|
||||
source.identifier = Source.altStoreIdentifier
|
||||
source.sourceURL = Source.altStoreSourceURL
|
||||
try! source.setSourceURL(Source.altStoreSourceURL)
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
@@ -23,12 +23,6 @@ public extension StoreApp
|
||||
#endif
|
||||
|
||||
static let dolphinAppID = "me.oatmealdome.dolphinios-njb"
|
||||
|
||||
private struct AppPermissions: Decodable
|
||||
{
|
||||
var entitlements: [AppPermission]?
|
||||
var privacy: [AppPermission]?
|
||||
}
|
||||
}
|
||||
|
||||
@objc(StoreApp)
|
||||
@@ -74,6 +68,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
{
|
||||
permission.sourceID = self.sourceIdentifier ?? ""
|
||||
}
|
||||
|
||||
for screenshot in self.allScreenshots
|
||||
{
|
||||
screenshot.sourceID = self.sourceIdentifier ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@NSManaged private var primitiveSourceIdentifier: String?
|
||||
@@ -114,6 +113,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
return self._versions.array as! [AppVersion]
|
||||
}
|
||||
|
||||
@nonobjc public var allScreenshots: [AppScreenshot] {
|
||||
return self._screenshots.array as! [AppScreenshot]
|
||||
}
|
||||
@NSManaged @objc(screenshots) private(set) var _screenshots: NSOrderedSet
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
@@ -125,18 +129,21 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
case bundleIdentifier
|
||||
case developerName
|
||||
case localizedDescription
|
||||
case version
|
||||
case versionDescription
|
||||
case versionDate
|
||||
case iconURL
|
||||
case screenshotURLs
|
||||
case downloadURL
|
||||
case screenshots
|
||||
case tintColor
|
||||
case subtitle
|
||||
case permissions = "appPermissions"
|
||||
case size
|
||||
case isBeta = "beta"
|
||||
case versions
|
||||
|
||||
// Legacy
|
||||
case version
|
||||
case versionDescription
|
||||
case versionDate
|
||||
case downloadURL
|
||||
case screenshotURLs
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws
|
||||
@@ -157,7 +164,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
|
||||
|
||||
self.iconURL = try container.decode(URL.self, forKey: .iconURL)
|
||||
self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
|
||||
|
||||
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
|
||||
{
|
||||
@@ -170,12 +176,37 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
|
||||
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
|
||||
|
||||
let appScreenshots: [AppScreenshot]
|
||||
|
||||
if let screenshots = try container.decodeIfPresent(AppScreenshots.self, forKey: .screenshots)
|
||||
{
|
||||
appScreenshots = screenshots.screenshots
|
||||
}
|
||||
else if let screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs)
|
||||
{
|
||||
// Assume 9:16 iPhone 8 screen dimensions for legacy screenshotURLs.
|
||||
let legacyAspectRatio = CGSize(width: 750, height: 1334)
|
||||
|
||||
appScreenshots = screenshotURLs.map { imageURL in
|
||||
let screenshot = AppScreenshot(imageURL: imageURL, size: legacyAspectRatio, deviceType: .iphone, context: context)
|
||||
return screenshot
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
appScreenshots = []
|
||||
}
|
||||
|
||||
for screenshot in appScreenshots
|
||||
{
|
||||
screenshot.appBundleID = self.bundleIdentifier
|
||||
}
|
||||
|
||||
self.setScreenshots(appScreenshots)
|
||||
|
||||
if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions)
|
||||
{
|
||||
appPermissions.entitlements?.forEach { $0.type = .entitlement }
|
||||
appPermissions.privacy?.forEach { $0.type = .privacy }
|
||||
|
||||
let allPermissions = (appPermissions.entitlements ?? []) + (appPermissions.privacy ?? [])
|
||||
let allPermissions = appPermissions.entitlements + appPermissions.privacy
|
||||
for permission in allPermissions
|
||||
{
|
||||
permission.appBundleID = self.bundleIdentifier
|
||||
@@ -279,6 +310,58 @@ internal extension StoreApp
|
||||
|
||||
self._permissions = permissions as NSSet
|
||||
}
|
||||
|
||||
func setScreenshots(_ screenshots: [AppScreenshot])
|
||||
{
|
||||
for case let screenshot as AppScreenshot in self._screenshots
|
||||
{
|
||||
if screenshots.contains(screenshot)
|
||||
{
|
||||
screenshot.app = self
|
||||
}
|
||||
else
|
||||
{
|
||||
screenshot.app = nil
|
||||
}
|
||||
}
|
||||
|
||||
self._screenshots = NSOrderedSet(array: screenshots)
|
||||
|
||||
// Backwards compatibility
|
||||
self.screenshotURLs = screenshots.map { $0.imageURL }
|
||||
}
|
||||
}
|
||||
|
||||
public extension StoreApp
|
||||
{
|
||||
func screenshots(for deviceType: ALTDeviceType) -> [AppScreenshot]
|
||||
{
|
||||
//TODO: Support multiple device types
|
||||
let filteredScreenshots = self.allScreenshots.filter { $0.deviceType == deviceType }
|
||||
return filteredScreenshots
|
||||
}
|
||||
|
||||
func preferredScreenshots() -> [AppScreenshot]
|
||||
{
|
||||
let deviceType: ALTDeviceType
|
||||
|
||||
if UIDevice.current.model.contains("iPad")
|
||||
{
|
||||
deviceType = .ipad
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceType = .iphone
|
||||
}
|
||||
|
||||
let preferredScreenshots = self.screenshots(for: deviceType)
|
||||
guard !preferredScreenshots.isEmpty else {
|
||||
// There are no screenshots for deviceType, so return _all_ screenshots instead.
|
||||
return self.allScreenshots
|
||||
}
|
||||
|
||||
return preferredScreenshots
|
||||
}
|
||||
}
|
||||
|
||||
public extension StoreApp
|
||||
|
||||
Reference in New Issue
Block a user