[AltStore] Refactors fetch apps logic to use Source model objects

This commit is contained in:
Riley Testut
2019-07-30 17:00:04 -07:00
parent 75e398822f
commit 87ced5523e
13 changed files with 272 additions and 115 deletions

View File

@@ -109,7 +109,6 @@
BF770E5822BC3D0F002A40FE /* OperationGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF770E5722BC3D0F002A40FE /* OperationGroup.swift */; };
BF770E6722BD57C4002A40FE /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF770E6622BD57C3002A40FE /* BackgroundTaskManager.swift */; };
BF770E6922BD57DD002A40FE /* Silence.m4a in Resources */ = {isa = PBXBuildFile; fileRef = BF770E6822BD57DD002A40FE /* Silence.m4a */; };
BF7B9EF322B82B1F0042C873 /* FetchAppsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7B9EF222B82B1F0042C873 /* FetchAppsOperation.swift */; };
BF8F69C222E659F700049BA1 /* AppContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8F69C122E659F700049BA1 /* AppContentViewController.swift */; };
BF8F69C422E662D300049BA1 /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8F69C322E662D300049BA1 /* AppViewController.swift */; };
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */; };
@@ -176,6 +175,8 @@
BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */; };
BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */; };
BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */; };
BFE338DD22F0E7F3002E24B9 /* Source.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE338DC22F0E7F3002E24B9 /* Source.swift */; };
BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE338DE22F0EADB002E24B9 /* FetchSourceOperation.swift */; };
BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE338E722F10E56002E24B9 /* LaunchViewController.swift */; };
BFE6325A22A83BEB00F30809 /* Authentication.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFE6325922A83BEB00F30809 /* Authentication.storyboard */; };
BFE6325C22A83C0100F30809 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE6325B22A83C0100F30809 /* AuthenticationViewController.swift */; };
@@ -356,7 +357,6 @@
BF770E5722BC3D0F002A40FE /* OperationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationGroup.swift; sourceTree = "<group>"; };
BF770E6622BD57C3002A40FE /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
BF770E6822BD57DD002A40FE /* Silence.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = Silence.m4a; sourceTree = "<group>"; };
BF7B9EF222B82B1F0042C873 /* FetchAppsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchAppsOperation.swift; sourceTree = "<group>"; };
BF8F69C122E659F700049BA1 /* AppContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContentViewController.swift; sourceTree = "<group>"; };
BF8F69C322E662D300049BA1 /* AppViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewController.swift; sourceTree = "<group>"; };
BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewController.swift; sourceTree = "<group>"; };
@@ -427,6 +427,8 @@
BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = "<group>"; };
BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = "<group>"; };
BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendAppOperation.swift; sourceTree = "<group>"; };
BFE338DC22F0E7F3002E24B9 /* Source.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Source.swift; sourceTree = "<group>"; };
BFE338DE22F0EADB002E24B9 /* FetchSourceOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchSourceOperation.swift; sourceTree = "<group>"; };
BFE338E722F10E56002E24B9 /* LaunchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViewController.swift; sourceTree = "<group>"; };
BFE6325922A83BEB00F30809 /* Authentication.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Authentication.storyboard; sourceTree = "<group>"; };
BFE6325B22A83C0100F30809 /* AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = "<group>"; };
@@ -851,6 +853,7 @@
BFBBE2DE22931F73002097FA /* App.swift */,
BF3D648722E79A3700E9056B /* AppPermission.swift */,
BFBBE2E022931F81002097FA /* InstalledApp.swift */,
BFE338DC22F0E7F3002E24B9 /* Source.swift */,
BFE6326522A857C100F30809 /* Team.swift */,
);
path = Model;
@@ -905,7 +908,7 @@
BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */,
BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */,
BF770E5022BB1CF6002A40FE /* InstallAppOperation.swift */,
BF7B9EF222B82B1F0042C873 /* FetchAppsOperation.swift */,
BFE338DE22F0EADB002E24B9 /* FetchSourceOperation.swift */,
);
path = Operations;
sourceTree = "<group>";
@@ -1274,7 +1277,6 @@
files = (
BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */,
BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */,
BF7B9EF322B82B1F0042C873 /* FetchAppsOperation.swift in Sources */,
BF3D649D22E7AC1B00E9056B /* PermissionPopoverViewController.swift in Sources */,
BFE6325E22A8497000F30809 /* SelectTeamViewController.swift in Sources */,
BFD2478F2284C8F900981D42 /* Button.swift in Sources */,
@@ -1283,11 +1285,13 @@
BFB11692229322E400BB457C /* DatabaseManager.swift in Sources */,
BFC1F38D22AEE3A4003AC21A /* DownloadAppOperation.swift in Sources */,
BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */,
BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */,
BFBBE2DF22931F73002097FA /* App.swift in Sources */,
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */,
BF43002E22A714AF0051E2BC /* Keychain.swift in Sources */,
BF770E5422BC044E002A40FE /* AppOperationContext.swift in Sources */,
BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */,
BFE338DD22F0E7F3002E24B9 /* Source.swift in Sources */,
BF8F69C422E662D300049BA1 /* AppViewController.swift in Sources */,
BFE6326822A858F300F30809 /* Account.swift in Sources */,
BFE6326622A857C200F30809 /* Team.swift in Sources */,

View File

@@ -176,22 +176,22 @@ extension AppDelegate
return
}
var fetchAppsResult: Result<[App], Error>?
var fetchSourceResult: Result<Source, Error>?
var serversResult: Result<Void, Error>?
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
dispatchGroup.enter()
AppManager.shared.fetchApps() { (result) in
fetchAppsResult = result
AppManager.shared.fetchSource() { (result) in
fetchSourceResult = result
dispatchGroup.leave()
do
{
let apps = try result.get()
let source = try result.get()
guard let context = apps.first?.managedObjectContext else { return }
guard let context = source.managedObjectContext else { return }
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
updatesFetchRequest.includesPendingChanges = true
@@ -230,13 +230,13 @@ extension AppDelegate
}
dispatchGroup.notify(queue: .main) {
guard let fetchAppsResult = fetchAppsResult, let serversResult = serversResult else {
guard let fetchSourceResult = fetchSourceResult, let serversResult = serversResult else {
completionHandler(.failed)
return
}
// Call completionHandler early to improve chances of refreshing in the background again.
switch (fetchAppsResult, serversResult)
switch (fetchSourceResult, serversResult)
{
case (.success, .success): completionHandler(.newData)
case (.success, .failure(ConnectionError.serverNotFound)): completionHandler(.newData)

View File

@@ -36,7 +36,7 @@ class BrowseViewController: UICollectionViewController
{
super.viewWillAppear(animated)
self.fetchApps()
self.fetchSource()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
@@ -57,10 +57,18 @@ private extension BrowseViewController
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<App, UIImage>
{
let fetchRequest = App.fetchRequest() as NSFetchRequest<App>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \App.name, ascending: false)]
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(App.bundleIdentifier), App.altstoreAppID)
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \App.sortIndex, ascending: true), NSSortDescriptor(keyPath: \App.name, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
if let source = Source.fetchAltStoreSource(in: DatabaseManager.shared.viewContext)
{
fetchRequest.predicate = NSPredicate(format: "%K != %@ AND %K == %@", #keyPath(App.bundleIdentifier), App.altstoreAppID, #keyPath(App.source), source)
}
else
{
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(App.bundleIdentifier), App.altstoreAppID)
}
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<App, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
let cell = cell as! BrowseCollectionViewCell
@@ -98,13 +106,13 @@ private extension BrowseViewController
return dataSource
}
func fetchApps()
func fetchSource()
{
AppManager.shared.fetchApps() { (result) in
AppManager.shared.fetchSource() { (result) in
do
{
let apps = try result.get()
try apps.first?.managedObjectContext?.save()
let source = try result.get()
try source.managedObjectContext?.save()
}
catch
{

View File

@@ -16,7 +16,7 @@ import Roxas
extension AppManager
{
static let didFetchAppsNotification = Notification.Name("com.altstore.AppManager.didFetchApps")
static let didFetchSourceNotification = Notification.Name("com.altstore.AppManager.didFetchSource")
}
class AppManager
@@ -86,21 +86,27 @@ extension AppManager
extension AppManager
{
func fetchApps(completionHandler: @escaping (Result<[App], Error>) -> Void)
func fetchSource(completionHandler: @escaping (Result<Source, Error>) -> Void)
{
let fetchAppsOperation = FetchAppsOperation()
fetchAppsOperation.resultHandler = { (result) in
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
guard let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context) else {
return completionHandler(.failure(OperationError.noSources))
}
let fetchSourceOperation = FetchSourceOperation(sourceURL: source.sourceURL)
fetchSourceOperation.resultHandler = { (result) in
switch result
{
case .failure(let error):
completionHandler(.failure(error))
case .success(let apps):
completionHandler(.success(apps))
NotificationCenter.default.post(name: AppManager.didFetchAppsNotification, object: self)
case .success(let source):
completionHandler(.success(source))
NotificationCenter.default.post(name: AppManager.didFetchSourceNotification, object: self)
}
}
self.operationQueue.addOperation(fetchAppsOperation)
self.operationQueue.addOperation(fetchSourceOperation)
}
}
}

View File

@@ -22,6 +22,7 @@
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="screenshotNames" attributeType="Transformable" syncable="YES"/>
<attribute name="size" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="sortIndex" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="subtitle" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="tintColor" optional="YES" attributeType="Transformable" syncable="YES"/>
<attribute name="version" attributeType="String" syncable="YES"/>
@@ -29,6 +30,7 @@
<attribute name="versionDescription" optional="YES" attributeType="String" syncable="YES"/>
<relationship name="installedApp" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InstalledApp" inverseName="storeApp" inverseEntity="InstalledApp" syncable="YES"/>
<relationship name="permissions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission" syncable="YES"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="bundleIdentifier"/>
@@ -54,6 +56,17 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="name" attributeType="String" syncable="YES"/>
<attribute name="sourceURL" attributeType="URI" syncable="YES"/>
<relationship name="apps" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="App" inverseName="source" inverseEntity="App" syncable="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Team" representedClassName="Team" syncable="YES">
<attribute name="identifier" attributeType="String" syncable="YES"/>
<attribute name="isActiveTeam" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
@@ -68,9 +81,10 @@
</entity>
<elements>
<element name="Account" positionX="-36" positionY="90" width="128" height="135"/>
<element name="App" positionX="-63" positionY="-18" width="128" height="270"/>
<element name="App" positionX="-63" positionY="-18" width="128" height="300"/>
<element name="AppPermission" positionX="-45" positionY="90" width="128" height="90"/>
<element name="InstalledApp" positionX="-63" positionY="0" width="128" height="150"/>
<element name="Team" positionX="-45" positionY="81" width="128" height="120"/>
<element name="Source" positionX="-45" positionY="99" width="128" height="105"/>
</elements>
</model>

View File

@@ -39,8 +39,11 @@ class App: NSManagedObject, Decodable, Fetchable
@NSManaged private(set) var downloadURL: URL
@NSManaged private(set) var tintColor: UIColor?
@NSManaged var sortIndex: Int32
/* Relationships */
@NSManaged var installedApp: InstalledApp?
@NSManaged var source: Source?
@objc(permissions) @NSManaged var _permissions: NSOrderedSet
@nonobjc var permissions: [AppPermission] {

View File

@@ -118,8 +118,11 @@ private extension DatabaseManager
}
else
{
let source = Source.makeAltStoreSource(in: context)
storeApp = App.makeAltStoreApp(in: context)
storeApp.version = localApp.version
storeApp.source = source
}
let installedApp: InstalledApp

View File

@@ -30,6 +30,20 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
permission.managedObjectContext?.delete(permission)
}
case let databaseObject as Source:
guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break }
let bundleIdentifiers = Set(conflictedObject.apps.map { $0.bundleIdentifier })
for app in databaseObject.apps
{
if !bundleIdentifiers.contains(app.bundleIdentifier)
{
// No longer listed in Source, so remove it from database.
app.managedObjectContext?.delete(app)
}
}
default: break
}
}

View File

@@ -0,0 +1,92 @@
//
// Source.swift
// AltStore
//
// Created by Riley Testut on 7/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import CoreData
extension Source
{
static let altStoreIdentifier = "com.rileytestut.AltStore"
}
@objc(Source)
class Source: NSManagedObject, Fetchable, Decodable
{
/* Properties */
@NSManaged var name: String
@NSManaged var identifier: String
@NSManaged var sourceURL: URL
/* Relationships */
@objc(apps) @NSManaged private(set) var _apps: NSOrderedSet
@nonobjc var apps: [App] {
get {
return self._apps.array as! [App]
}
set {
self._apps = NSOrderedSet(array: newValue)
}
}
private enum CodingKeys: String, CodingKey
{
case name
case identifier
case sourceURL
case apps
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
required init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
super.init(entity: Source.entity(), insertInto: nil)
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)
self.sourceURL = try container.decode(URL.self, forKey: .sourceURL)
let apps = try container.decodeIfPresent([App].self, forKey: .apps) ?? []
for (index, app) in apps.enumerated()
{
app.sortIndex = Int32(index)
}
context.insert(self)
// Must assign after we're inserted into context.
self._apps = NSMutableOrderedSet(array: apps)
print("Downloaded Order:", self.apps.map { $0.bundleIdentifier })
}
}
extension Source
{
class func makeAltStoreSource(in context: NSManagedObjectContext) -> Source
{
let source = Source(context: context)
source.name = "AltStore"
source.identifier = Source.altStoreIdentifier
source.sourceURL = URL(string: "https://www.dropbox.com/s/6qi1vt6hsi88lv6/Apps-Dev.json?dl=1")!
return source
}
class func fetchAltStoreSource(in context: NSManagedObjectContext) -> Source?
{
let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context)
return source
}
}

View File

@@ -57,7 +57,7 @@ class MyAppsViewController: UICollectionViewController
{
super.init(coder: aDecoder)
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchApps(_:)), name: AppManager.didFetchAppsNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchSource(_:)), name: AppManager.didFetchSourceNotification, object: nil)
}
override func viewDidLoad()
@@ -522,7 +522,7 @@ private extension MyAppsViewController
private extension MyAppsViewController
{
@objc func didFetchApps(_ notification: Notification)
@objc func didFetchSource(_ notification: Notification)
{
DispatchQueue.main.async {
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil

View File

@@ -1,17 +1,19 @@
//
// FetchAppsOperation.swift
// FetchSourceOperation.swift
// AltStore
//
// Created by Riley Testut on 6/17/19.
// Created by Riley Testut on 7/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Roxas
@objc(FetchAppsOperation)
class FetchAppsOperation: ResultOperation<[App]>
@objc(FetchSourceOperation)
class FetchSourceOperation: ResultOperation<Source>
{
let sourceURL: URL
private let session = URLSession(configuration: .default)
private lazy var dateFormatter: DateFormatter = {
@@ -20,13 +22,16 @@ class FetchAppsOperation: ResultOperation<[App]>
return dateFormatter
}()
init(sourceURL: URL)
{
self.sourceURL = sourceURL
}
override func main()
{
super.main()
let appsURL = URL(string: "https://www.dropbox.com/s/6qi1vt6hsi88lv6/Apps-Dev.json?dl=1")!
let dataTask = self.session.dataTask(with: appsURL) { (data, response, error) in
let dataTask = self.session.dataTask(with: self.sourceURL) { (data, response, error) in
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
do
{
@@ -36,8 +41,8 @@ class FetchAppsOperation: ResultOperation<[App]>
decoder.dateDecodingStrategy = .formatted(self.dateFormatter)
decoder.managedObjectContext = context
let apps = try decoder.decode([App].self, from: data)
self.finish(.success(apps))
let source = try decoder.decode(Source.self, from: data)
self.finish(.success(source))
}
catch
{

View File

@@ -25,6 +25,8 @@ enum OperationError: LocalizedError
case iOSVersionNotSupported(ALTApplication)
case noSources
var errorDescription: String? {
switch self {
case .unknown: return NSLocalizedString("An unknown error occured.", comment: "")
@@ -35,6 +37,7 @@ enum OperationError: LocalizedError
case .unknownUDID: return NSLocalizedString("Unknown device UDID.", comment: "")
case .invalidApp: return NSLocalizedString("The app is invalid.", comment: "")
case .invalidParameters: return NSLocalizedString("Invalid parameters.", comment: "")
case .noSources: return NSLocalizedString("There are no AltStore sources.", comment: "")
case .iOSVersionNotSupported(let app):
let name = app.name

View File

@@ -1,4 +1,8 @@
[
{
"name": "AltStore",
"identifier": "com.rileytestut.AltStore",
"sourceURL": "https://www.dropbox.com/s/6qi1vt6hsi88lv6/Apps-Dev.json?dl=1",
"apps": [
{
"name": "AltStore",
"bundleIdentifier": "com.rileytestut.AltStore",
@@ -43,7 +47,7 @@
"screenshotNames": [
"Delta1",
"Delta2",
"Delta3",
"Delta3"
]
},
{
@@ -67,7 +71,8 @@
],
"screenshotNames": [
"Clip1",
"Clip2",
"Clip2"
]
}
]
}