Files
SideStore/AltStore/Operations/FetchSourceOperation.swift
Riley Testut f7640e35d1 Supports app versions with explicit build versions
AltStore will now consider an update available if either:

* The source’s marketing version doesn’t match installed app’s version
* The source declares a build version AND it doesn’t match the install app’s build version

The installed app matches an app version if both maketing versions match, and the build versions match (if provided by the source).
2024-12-26 21:15:29 +05:30

208 lines
7.8 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 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 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 = try decoder.decode(Source.self, from: data)
let identifier = source.identifier
try self.verify(source, response: response)
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()
}
}
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
{
switch permission.type
{
case .privacy, .backgroundMode:
guard permission.usageDescription != nil else { throw SourceError.missingPermissionUsageDescription(for: permission.permission, app: app, source: source) }
default: break
}
}
}
if let previousSourceID = self.$source.identifier
{
guard source.identifier == previousSourceID else { throw SourceError.changedID(source.identifier, previousID: previousSourceID, source: source) }
}
}
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)
}
}
}
}
}