mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
In case our workaround for downloading Patreon post attachments breaks, we can remotely disable it and force AltStore to use its fallback instead (taking user to post directly).
310 lines
12 KiB
Swift
310 lines
12 KiB
Swift
//
|
|
// FetchSourceOperation.swift
|
|
// AltStore
|
|
//
|
|
// Created by Riley Testut on 7/30/19.
|
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreData
|
|
|
|
import AltStoreCore
|
|
import Roxas
|
|
|
|
@objc(FetchSourceOperation)
|
|
final class FetchSourceOperation: ResultOperation<Source>
|
|
{
|
|
let sourceURL: URL
|
|
let managedObjectContext: NSManagedObjectContext
|
|
|
|
// Non-nil when updating an existing source.
|
|
@Managed
|
|
private var source: Source?
|
|
|
|
private let session: URLSession
|
|
private weak var dataTask: URLSessionDataTask?
|
|
|
|
private lazy var dateFormatter: ISO8601DateFormatter = {
|
|
let dateFormatter = ISO8601DateFormatter()
|
|
return dateFormatter
|
|
}()
|
|
|
|
// New source
|
|
convenience init(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext())
|
|
{
|
|
self.init(sourceURL: sourceURL, source: nil, managedObjectContext: managedObjectContext)
|
|
}
|
|
|
|
// Existing source
|
|
convenience init(source: Source, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext())
|
|
{
|
|
self.init(sourceURL: source.sourceURL, source: source, managedObjectContext: managedObjectContext)
|
|
}
|
|
|
|
private init(sourceURL: URL, source: Source?, managedObjectContext: NSManagedObjectContext)
|
|
{
|
|
self.sourceURL = sourceURL
|
|
self.managedObjectContext = managedObjectContext
|
|
self.source = source
|
|
|
|
let configuration = URLSessionConfiguration.default
|
|
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
configuration.urlCache = nil
|
|
|
|
self.session = URLSession(configuration: configuration)
|
|
}
|
|
|
|
override func cancel()
|
|
{
|
|
super.cancel()
|
|
|
|
self.dataTask?.cancel()
|
|
}
|
|
|
|
override func main()
|
|
{
|
|
super.main()
|
|
|
|
if let source = self.source
|
|
{
|
|
// Check if source is blocked before fetching it.
|
|
|
|
do
|
|
{
|
|
try self.managedObjectContext.performAndWait {
|
|
// Source must be from self.managedObjectContext
|
|
let source = self.managedObjectContext.object(with: source.objectID) as! Source
|
|
try self.verifySourceNotBlocked(source, response: nil)
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
self.managedObjectContext.perform {
|
|
self.finish(.failure(error))
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
let dataTask = self.session.dataTask(with: self.sourceURL) { (data, response, error) in
|
|
|
|
let childContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(withParent: self.managedObjectContext)
|
|
childContext.mergePolicy = NSOverwriteMergePolicy
|
|
childContext.perform {
|
|
do
|
|
{
|
|
let (data, response) = try Result((data, response), error).get()
|
|
|
|
let decoder = AltStoreCore.JSONDecoder()
|
|
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
|
|
let container = try decoder.singleValueContainer()
|
|
let text = try container.decode(String.self)
|
|
|
|
// Full ISO8601 Format.
|
|
self.dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withTimeZone]
|
|
if let date = self.dateFormatter.date(from: text)
|
|
{
|
|
return date
|
|
}
|
|
|
|
// Just date portion of ISO8601.
|
|
self.dateFormatter.formatOptions = [.withFullDate]
|
|
if let date = self.dateFormatter.date(from: text)
|
|
{
|
|
return date
|
|
}
|
|
|
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.")
|
|
})
|
|
|
|
decoder.managedObjectContext = childContext
|
|
decoder.sourceURL = self.sourceURL
|
|
|
|
let source: Source
|
|
|
|
do
|
|
{
|
|
source = try decoder.decode(Source.self, from: data)
|
|
}
|
|
catch let error as DecodingError
|
|
{
|
|
let nsError = error as NSError
|
|
guard let codingPath = nsError.userInfo[ALTNSCodingPathKey] as? [CodingKey] else { throw error }
|
|
|
|
let rawComponents = codingPath.map { $0.intValue?.description ?? $0.stringValue }
|
|
let pathDescription = rawComponents.joined(separator: " > ")
|
|
|
|
var userInfo = nsError.userInfo
|
|
|
|
if let debugDescription = nsError.localizedDebugDescription
|
|
{
|
|
let detailedDescription = debugDescription + "\n\n" + pathDescription
|
|
userInfo[NSDebugDescriptionErrorKey] = detailedDescription
|
|
}
|
|
else
|
|
{
|
|
userInfo[NSDebugDescriptionErrorKey] = pathDescription
|
|
}
|
|
|
|
throw NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo)
|
|
}
|
|
|
|
let identifier = source.identifier
|
|
|
|
if identifier == Source.altStoreIdentifier, let skipPatreonDownloads = source.userInfo?[.skipPatreonDownloads]
|
|
{
|
|
UserDefaults.shared.skipPatreonDownloads = (skipPatreonDownloads == "true")
|
|
}
|
|
|
|
try self.verify(source, response: response)
|
|
try self.verifyPledges(for: source, in: childContext)
|
|
|
|
try childContext.save()
|
|
|
|
self.managedObjectContext.perform {
|
|
if let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), identifier), in: self.managedObjectContext)
|
|
{
|
|
self.finish(.success(source))
|
|
}
|
|
else
|
|
{
|
|
self.finish(.failure(OperationError.noSources))
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
self.managedObjectContext.perform {
|
|
self.finish(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.progress.addChild(dataTask.progress, withPendingUnitCount: 1)
|
|
|
|
dataTask.resume()
|
|
|
|
self.dataTask = dataTask
|
|
}
|
|
}
|
|
|
|
private extension FetchSourceOperation
|
|
{
|
|
func verify(_ source: Source, response: URLResponse) throws
|
|
{
|
|
try self.verifySourceNotBlocked(source, response: response)
|
|
|
|
var bundleIDs = Set<String>()
|
|
for app in source.apps
|
|
{
|
|
guard !bundleIDs.contains(app.bundleIdentifier) else { throw SourceError.duplicateBundleID(app.bundleIdentifier, source: source) }
|
|
bundleIDs.insert(app.bundleIdentifier)
|
|
|
|
var versions = Set<String>()
|
|
for version in app.versions
|
|
{
|
|
guard !versions.contains(version.versionID) else { throw SourceError.duplicateVersion(version.localizedVersion, for: app, source: source) }
|
|
versions.insert(version.versionID)
|
|
}
|
|
|
|
for permission in app.permissions where permission.type == .privacy
|
|
{
|
|
// Privacy permissions MUST have a usage description.
|
|
guard permission.usageDescription != nil else { throw SourceError.missingPermissionUsageDescription(for: permission.permission, app: app, source: source) }
|
|
}
|
|
|
|
for screenshot in app.screenshots(for: .ipad)
|
|
{
|
|
// All iPad screenshots MUST have an explicit size.
|
|
guard screenshot.size != nil else { throw SourceError.missingScreenshotSize(for: screenshot, source: source) }
|
|
}
|
|
}
|
|
|
|
if let previousSourceID = self.$source.identifier
|
|
{
|
|
guard source.identifier == previousSourceID else { throw SourceError.changedID(source.identifier, previousID: previousSourceID, source: source) }
|
|
}
|
|
}
|
|
|
|
func verifyPledges(for source: Source, in context: NSManagedObjectContext) throws
|
|
{
|
|
guard let patreonURL = source.patreonURL, let patreonAccount = DatabaseManager.shared.patreonAccount(in: context) else { return }
|
|
|
|
let normalizedPatreonURL = try patreonURL.normalized()
|
|
|
|
guard let pledge = patreonAccount.pledges.first(where: { pledge in
|
|
do
|
|
{
|
|
let normalizedCampaignURL = try pledge.campaignURL.normalized()
|
|
return normalizedCampaignURL == normalizedPatreonURL
|
|
}
|
|
catch
|
|
{
|
|
Logger.main.error("Failed to normalize Patreon URL \(pledge.campaignURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
return false
|
|
}
|
|
}) else { return }
|
|
|
|
// User is pledged to this source's Patreon, so check which apps they're pledged to.
|
|
|
|
// We only assign `isPledged = true` because false is already the default,
|
|
// and only one check needs to be true for isPledged to be true.
|
|
|
|
for app in source.apps where app.isPledgeRequired
|
|
{
|
|
if let requiredAppPledge = app.pledgeAmount
|
|
{
|
|
if pledge.amount >= requiredAppPledge
|
|
{
|
|
app.isPledged = true
|
|
continue
|
|
}
|
|
}
|
|
|
|
if let tierIDs = app._tierIDs
|
|
{
|
|
let tier = pledge.tiers.first { tierIDs.contains($0.identifier) }
|
|
if tier != nil
|
|
{
|
|
app.isPledged = true
|
|
continue
|
|
}
|
|
}
|
|
|
|
if let rewardID = app._rewardID
|
|
{
|
|
let reward = pledge.rewards.first { $0.identifier == rewardID }
|
|
if reward != nil
|
|
{
|
|
app.isPledged = true
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws
|
|
{
|
|
guard let blockedSources = UserDefaults.shared.blockedSources else { return }
|
|
|
|
for blockedSource in blockedSources
|
|
{
|
|
guard
|
|
source.identifier != blockedSource.identifier,
|
|
source.sourceURL.absoluteString.lowercased() != blockedSource.sourceURL?.absoluteString.lowercased()
|
|
else { throw SourceError.blocked(source, bundleIDs: blockedSource.bundleIDs, existingSource: self.source) }
|
|
|
|
if let responseURL = response?.url
|
|
{
|
|
// responseURL may differ from source.sourceURL (e.g. due to redirects), so double-check it's also not blocked.
|
|
guard responseURL.absoluteString.lowercased() != blockedSource.sourceURL?.absoluteString.lowercased() else {
|
|
throw SourceError.blocked(source, bundleIDs: blockedSource.bundleIDs, existingSource: self.source)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|