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:
Riley Testut
2023-10-19 16:43:50 -05:00
29 changed files with 1869 additions and 482 deletions

View File

@@ -0,0 +1,31 @@
//
// Regex+Permissions.swift
// AltStore
//
// Created by Riley Testut on 10/10/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import RegexBuilder
@available(iOS 16, *)
public extension Regex where Output == (Substring, Substring)
{
static var privacyPermission: some RegexComponent<(Substring, Substring)> {
Regex {
Optionally {
"NS"
}
// Capture permission "name"
Capture {
OneOrMore(.anyGraphemeCluster)
}
"UsageDescription"
// Optional suffix
Optionally(OneOrMore(.anyGraphemeCluster))
}
}
}

View File

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

View File

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

View 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)
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@
// Copyright © 2023 Riley Testut. All rights reserved.
//
import RegexBuilder
import AltSign
extension ALTAppPermissionType
@@ -156,5 +158,10 @@ extension ALTAppPrivacyPermission: ALTAppPermission
{
public var type: ALTAppPermissionType { .privacy }
public var synthesizedName: String? { nil }
public var synthesizedName: String? {
guard #available(iOS 16, *), let match = self.rawValue.wholeMatch(of: Regex.privacyPermission) else { return nil }
let synthesizedNamed = String(match.1)
return synthesizedNamed
}
}

View File

@@ -865,291 +865,291 @@
</dict>
<key>privacy</key>
<dict>
<key>BluetoothAlways</key>
<key>NSBluetoothAlwaysUsageDescription</key>
<dict>
<key>name</key>
<string>Bluetooth</string>
<key>key</key>
<string>BluetoothAlways</string>
<string>NSBluetoothAlwaysUsageDescription</string>
<key>symbol</key>
<string>antenna.radiowaves.left.and.right</string>
</dict>
<key>BluetoothPeripheral</key>
<key>NSBluetoothPeripheralUsageDescription</key>
<dict>
<key>name</key>
<string>Bluetooth (Peripherals)</string>
<key>key</key>
<string>BluetoothPeripheral</string>
<string>NSBluetoothPeripheralUsageDescription</string>
<key>symbol</key>
<string>antenna.radiowaves.left.and.right</string>
</dict>
<key>Calendars</key>
<key>NSCalendarsUsageDescription</key>
<dict>
<key>name</key>
<string>Calendars</string>
<key>key</key>
<string>Calendars</string>
<string>NSCalendarsUsageDescription</string>
<key>symbol</key>
<string>calendar</string>
</dict>
<key>Reminders</key>
<key>NSRemindersUsageDescription</key>
<dict>
<key>name</key>
<string>Reminders</string>
<key>key</key>
<string>Reminders</string>
<string>NSRemindersUsageDescription</string>
<key>symbol</key>
<string>note.text</string>
</dict>
<key>Camera</key>
<key>NSCameraUsageDescription</key>
<dict>
<key>name</key>
<string>Camera</string>
<key>key</key>
<string>Camera</string>
<string>NSCameraUsageDescription</string>
<key>symbol</key>
<string>camera</string>
</dict>
<key>Microphone</key>
<key>NSMicrophoneUsageDescription</key>
<dict>
<key>name</key>
<string>Microphone</string>
<key>key</key>
<string>Microphone</string>
<string>NSMicrophoneUsageDescription</string>
<key>symbol</key>
<string>mic</string>
</dict>
<key>Contacts</key>
<key>NSContactsUsageDescription</key>
<dict>
<key>name</key>
<string>Contacts</string>
<key>key</key>
<string>Contacts</string>
<string>NSContactsUsageDescription</string>
<key>symbol</key>
<string>person.crop.circle</string>
</dict>
<key>FaceID</key>
<key>NSFaceIDUsageDescription</key>
<dict>
<key>name</key>
<string>Face ID</string>
<key>key</key>
<string>FaceID</string>
<string>NSFaceIDUsageDescription</string>
<key>symbol</key>
<string>faceid</string>
</dict>
<key>GKFriendList</key>
<key>NSGKFriendListUsageDescription</key>
<dict>
<key>name</key>
<string>Game Center Friends List</string>
<key>key</key>
<string>GKFriendList</string>
<string>NSGKFriendListUsageDescription</string>
<key>symbol</key>
<string>gamecontroller</string>
</dict>
<key>HealthClinicalHealthRecordsShare</key>
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
<dict>
<key>name</key>
<string>Health (Clinical Records)</string>
<key>key</key>
<string>HealthClinicalHealthRecordsShare</string>
<string>NSHealthClinicalHealthRecordsShareUsageDescription</string>
<key>symbol</key>
<string>heart.text.square</string>
</dict>
<key>HealthShare</key>
<key>NSHealthShareUsageDescription</key>
<dict>
<key>name</key>
<string>Health</string>
<key>key</key>
<string>HealthShare</string>
<string>NSHealthShareUsageDescription</string>
<key>symbol</key>
<string>heart.text.square</string>
</dict>
<key>HealthUpdate</key>
<key>NSHealthUpdateUsageDescription</key>
<dict>
<key>name</key>
<string>Health (Add)</string>
<key>key</key>
<string>HealthUpdate</string>
<string>NSHealthUpdateUsageDescription</string>
<key>symbol</key>
<string>heart.text.square</string>
</dict>
<key>HomeKit</key>
<key>NSHomeKitUsageDescription</key>
<dict>
<key>name</key>
<string>HomeKit</string>
<key>key</key>
<string>HomeKit</string>
<string>NSHomeKitUsageDescription</string>
<key>symbol</key>
<string>homekit</string>
</dict>
<key>LocationAlways</key>
<key>NSLocationAlwaysUsageDescription</key>
<dict>
<key>name</key>
<string>Location (Background)</string>
<key>key</key>
<string>LocationAlways</string>
<string>NSLocationAlwaysUsageDescription</string>
<key>symbol</key>
<string>location.fill</string>
</dict>
<key>LocationAlwaysAndWhenInUse</key>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<dict>
<key>name</key>
<string>Location (Always)</string>
<key>key</key>
<string>LocationAlwaysAndWhenInUse</string>
<string>NSLocationAlwaysAndWhenInUseUsageDescription</string>
<key>symbol</key>
<string>location.fill</string>
</dict>
<key>Location</key>
<key>NSLocationUsageDescription</key>
<dict>
<key>name</key>
<string>Location</string>
<key>key</key>
<string>Location</string>
<string>NSLocationUsageDescription</string>
<key>symbol</key>
<string>location.fill</string>
</dict>
<key>LocationWhenInUse</key>
<key>NSLocationWhenInUseUsageDescription</key>
<dict>
<key>name</key>
<string>Location (When Using)</string>
<key>key</key>
<string>LocationWhenInUse</string>
<string>NSLocationWhenInUseUsageDescription</string>
<key>symbol</key>
<string>location</string>
</dict>
<key>LocationTemporary</key>
<key>NSLocationTemporaryUsageDescription</key>
<dict>
<key>name</key>
<string>Location (Temporary)</string>
<key>key</key>
<string>LocationTemporary</string>
<string>NSLocationTemporaryUsageDescription</string>
<key>symbol</key>
<string>location</string>
</dict>
<key>AppleMusic</key>
<key>NSAppleMusicUsageDescription</key>
<dict>
<key>name</key>
<string>Apple Music</string>
<key>key</key>
<string>AppleMusic</string>
<string>NSAppleMusicUsageDescription</string>
<key>symbol</key>
<string>music.note</string>
</dict>
<key>Motion</key>
<key>NSMotionUsageDescription</key>
<dict>
<key>name</key>
<string>Motion</string>
<key>key</key>
<string>Motion</string>
<string>NSMotionUsageDescription</string>
<key>symbol</key>
<string>figure.run</string>
</dict>
<key>FallDetection</key>
<key>NSFallDetectionUsageDescription</key>
<dict>
<key>name</key>
<string>Fall Detection</string>
<key>key</key>
<string>FallDetection</string>
<string>NSFallDetectionUsageDescription</string>
<key>symbol</key>
<string>figure.fall</string>
</dict>
<key>LocalNetwork</key>
<key>NSLocalNetworkUsageDescription</key>
<dict>
<key>name</key>
<string>Local Network</string>
<key>key</key>
<string>LocalNetwork</string>
<string>NSLocalNetworkUsageDescription</string>
<key>symbol</key>
<string>network</string>
</dict>
<key>NearbyInteraction</key>
<key>NSNearbyInteractionUsageDescription</key>
<dict>
<key>name</key>
<string>Nearby Interaction</string>
<key>key</key>
<string>NearbyInteraction</string>
<string>NSNearbyInteractionUsageDescription</string>
<key>symbol</key>
<string>dot.radiowaves.left.and.right</string>
</dict>
<key>NearbyInteractionAllowOnce</key>
<key>NSNearbyInteractionAllowOnceUsageDescription</key>
<dict>
<key>name</key>
<string>Nearby Interaction (Once)</string>
<key>key</key>
<string>NearbyInteractionAllowOnce</string>
<string>NSNearbyInteractionAllowOnceUsageDescription</string>
<key>symbol</key>
<string>dot.radiowaves.left.and.right</string>
</dict>
<key>NFCReader</key>
<key>NFCReaderUsageDescription</key>
<dict>
<key>name</key>
<string>NFC</string>
<key>key</key>
<string>NFCReader</string>
<string>NSNFCReaderUsageDescription</string>
<key>symbol</key>
<string>sensor.tag.radiowaves.forward</string>
</dict>
<key>PhotoLibraryAdd</key>
<key>NSPhotoLibraryAddUsageDescription</key>
<dict>
<key>name</key>
<string>Photos (Add)</string>
<key>key</key>
<string>PhotoLibraryAdd</string>
<string>NSPhotoLibraryAddUsageDescription</string>
<key>symbol</key>
<string>photo</string>
</dict>
<key>PhotoLibrary</key>
<key>NSPhotoLibraryUsageDescription</key>
<dict>
<key>name</key>
<string>Photos</string>
<key>key</key>
<string>PhotoLibrary</string>
<string>NSPhotoLibraryUsageDescription</string>
<key>symbol</key>
<string>photo.stack</string>
</dict>
<key>UserTracking</key>
<key>NSUserTrackingUsageDescription</key>
<dict>
<key>name</key>
<string>User Tracking</string>
<key>key</key>
<string>UserTracking</string>
<string>NSUserTrackingUsageDescription</string>
<key>symbol</key>
<string>location.magnifyingglass</string>
</dict>
<key>SensorKit</key>
<key>NSSensorKitUsageDescription</key>
<dict>
<key>name</key>
<string>SensorKit</string>
<key>key</key>
<string>SensorKit</string>
<string>NSSensorKitUsageDescription</string>
<key>symbol</key>
<string>sensor</string>
</dict>
<key>Siri</key>
<key>NSSiriUsageDescription</key>
<dict>
<key>name</key>
<string>Siri</string>
<key>key</key>
<string>Siri</string>
<string>NSSiriUsageDescription</string>
<key>symbol</key>
<string>mic.circle</string>
</dict>
<key>SpeechRecognition</key>
<key>NSSpeechRecognitionUsageDescription</key>
<dict>
<key>name</key>
<string>Speech Recognition</string>
<key>key</key>
<string>SpeechRecognition</string>
<string>NSSpeechRecognitionUsageDescription</string>
<key>symbol</key>
<string>waveform.and.mic</string>
</dict>
<key>Identity</key>
<key>NSIdentityUsageDescription</key>
<dict>
<key>name</key>
<string>ID Cards</string>
<key>key</key>
<string>Identity</string>
<string>NSIdentityUsageDescription</string>
<key>symbol</key>
<string>wallet.pass</string>
</dict>