mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
* Change error from Swift.Error to NSError
* Adds ResultOperation.localizedFailure
* Finish Riley's monster commit
3b38d725d7
May the Gods have mercy on my soul.
* Fix format strings I broke
* Include "Enable JIT" errors in Error Log
* Fix minimuxer status checking
* [skip ci] Update the no wifi message to include VPN
* Opens Error Log when tapping ToastView
* Fixes Error Log context menu covering cell content
* Fixes Error Log context menu appearing while scrolling
* Fixes incorrect Search FAQ URL
* Fix Error Log showing UIAlertController on iOS 14+
* Fix Error Log not showing UIAlertController on iOS <=13
* Fix wrong color in AuthenticationViewController
* Fix typo
* Fixes logging non-AltServerErrors as AltServerError.underlyingError
* Limits quitting other AltStore/SideStore processes to database migrations
* Skips logging cancelled errors
* Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion
We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors.
However, we kept the underlying Core Data property name the same to avoid extra migration.
* Conforms OperatingSystemVersion to Comparable
* Parses AppVersion.minOSVersion/maxOSVersion from source JSON
* Supports non-NSManagedObjects for @Managed properties
This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before.
* Supports optional @Managed properties
* Conforms AppVersion to AppProtocol
* Verifies min/max OS version before downloading app + asks user to download older app version if necessary
* Improves error message when file does not exist at AppVersion.downloadURL
* Removes unnecessary StoreApp convenience properties
* Removes unnecessary StoreApp convenience properties as well as fix other issues
* Remove Settings bundle, add SwiftUI view instead
Fix refresh all shortcut intent
* Update AuthenticationOperation.swift
Signed-off-by: June Park <rjp2030@outlook.com>
* Fix build issues given by develop
* Add availability check to fix CI build(?)
* If it's gonna be that way...
---------
Signed-off-by: June Park <rjp2030@outlook.com>
Co-authored-by: nythepegasus <nythepegasus84@gmail.com>
Co-authored-by: Riley Testut <riley@rileytestut.com>
Co-authored-by: ny <me@nythepegas.us>
357 lines
14 KiB
Swift
357 lines
14 KiB
Swift
//
|
|
// DownloadAppOperation.swift
|
|
// AltStore
|
|
//
|
|
// Created by Riley Testut on 6/10/19.
|
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Roxas
|
|
|
|
import AltStoreCore
|
|
import AltSign
|
|
|
|
@objc(DownloadAppOperation)
|
|
final class DownloadAppOperation: ResultOperation<ALTApplication>
|
|
{
|
|
let app: AppProtocol
|
|
let context: AppOperationContext
|
|
|
|
private let appName: String
|
|
private let bundleIdentifier: String
|
|
private let destinationURL: URL
|
|
|
|
private let session = URLSession(configuration: .default)
|
|
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
|
|
|
init(app: AppProtocol, destinationURL: URL, context: AppOperationContext)
|
|
{
|
|
self.app = app
|
|
self.context = context
|
|
|
|
self.appName = app.name
|
|
self.bundleIdentifier = app.bundleIdentifier
|
|
self.destinationURL = destinationURL
|
|
|
|
super.init()
|
|
|
|
// App = 3, Dependencies = 1
|
|
self.progress.totalUnitCount = 4
|
|
}
|
|
|
|
override func main()
|
|
{
|
|
super.main()
|
|
|
|
if let error = self.context.error
|
|
{
|
|
self.finish(.failure(error))
|
|
return
|
|
}
|
|
|
|
print("Downloading App:", self.bundleIdentifier)
|
|
|
|
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
|
|
|
|
guard let storeApp = self.app as? StoreApp else { return self.download(self.app) }
|
|
storeApp.managedObjectContext?.perform {
|
|
do {
|
|
let latestVersion = try self.verify(storeApp)
|
|
self.download(latestVersion)
|
|
} catch let error as VerificationError where error.code == .iOSVersionNotSupported {
|
|
guard let presentingViewController = self.context.presentingViewController,
|
|
let latestSupportedVersion = storeApp.latestSupportedVersion,
|
|
case let version = latestSupportedVersion.version,
|
|
version != storeApp.installedApp?.version else {
|
|
return self.finish(.failure(error))
|
|
}
|
|
let title = NSLocalizedString("Unsupported iOS Version", comment: "")
|
|
let message = error.localizedDescription + "\n\n" + NSLocalizedString("Would you like to download the last version compatible with this device instead?", comment: "")
|
|
|
|
DispatchQueue.main.async {
|
|
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
|
|
self.finish(.failure(OperationError.cancelled))
|
|
})
|
|
alertController.addAction(UIAlertAction(title: String(format: NSLocalizedString("Download %@ %@", comment: ""), self.appName, version), style: .default) { _ in
|
|
self.download(latestSupportedVersion)
|
|
})
|
|
presentingViewController.present(alertController, animated: true)
|
|
}
|
|
} catch {
|
|
self.finish(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
override func finish(_ result: Result<ALTApplication, any Error>) {
|
|
do {
|
|
try FileManager.default.removeItem(at: self.temporaryDirectory)
|
|
} catch {
|
|
print("Failed to remove DownloadAppOperation temporary directory: \(self.temporaryDirectory).", error)
|
|
}
|
|
super.finish(result)
|
|
}
|
|
}
|
|
|
|
private extension DownloadAppOperation {
|
|
func verify(_ storeApp: StoreApp) throws -> AppVersion {
|
|
guard let version = storeApp.latestAvailableVersion else {
|
|
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
|
|
throw OperationError.unknown(failureReason: failureReason)
|
|
}
|
|
if let minOSVersion = version.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) {
|
|
throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: minOSVersion)
|
|
} else if let maxOSVersion = version.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion {
|
|
throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: maxOSVersion)
|
|
}
|
|
|
|
return version
|
|
}
|
|
|
|
func download(@Managed _ app: AppProtocol) {
|
|
guard let sourceURL = $app.url else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) }
|
|
|
|
self.downloadIPA(from: sourceURL) { result in
|
|
do
|
|
{
|
|
let application = try result.get()
|
|
|
|
if self.context.bundleIdentifier == StoreApp.dolphinAppID, self.context.bundleIdentifier != application.bundleIdentifier
|
|
{
|
|
if var infoPlist = NSDictionary(contentsOf: application.bundle.infoPlistURL) as? [String: Any]
|
|
{
|
|
// Manually update the app's bundle identifier to match the one specified in the source.
|
|
// This allows people who previously installed the app to still update and refresh normally.
|
|
infoPlist[kCFBundleIdentifierKey as String] = StoreApp.dolphinAppID
|
|
(infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true)
|
|
}
|
|
}
|
|
|
|
self.downloadDependencies(for: application) { result in
|
|
do
|
|
{
|
|
_ = try result.get()
|
|
|
|
try FileManager.default.copyItem(at: application.fileURL, to: self.destinationURL, shouldReplace: true)
|
|
|
|
guard let copiedApplication = ALTApplication(fileURL: self.destinationURL) else { throw OperationError.invalidApp }
|
|
self.finish(.success(copiedApplication))
|
|
|
|
self.progress.completedUnitCount += 1
|
|
}
|
|
catch
|
|
{
|
|
self.finish(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
self.finish(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
|
|
{
|
|
func finishOperation(_ result: Result<URL, Error>)
|
|
{
|
|
do
|
|
{
|
|
let fileURL = try result.get()
|
|
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) }
|
|
|
|
try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
|
|
let appBundleURL: URL
|
|
|
|
if isDirectory.boolValue
|
|
{
|
|
// Directory, so assuming this is .app bundle.
|
|
guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp }
|
|
|
|
appBundleURL = self.temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
|
|
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
|
|
}
|
|
else
|
|
{
|
|
// File, so assuming this is a .ipa file.
|
|
appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: self.temporaryDirectory)
|
|
}
|
|
|
|
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
|
|
completionHandler(.success(application))
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
if sourceURL.isFileURL
|
|
{
|
|
finishOperation(.success(sourceURL))
|
|
|
|
self.progress.completedUnitCount += 3
|
|
}
|
|
else
|
|
{
|
|
let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in
|
|
do
|
|
{
|
|
if let response = response as? HTTPURLResponse {
|
|
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: sourceURL]) }
|
|
}
|
|
let (fileURL, _) = try Result((fileURL, response), error).get()
|
|
finishOperation(.success(fileURL))
|
|
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
catch
|
|
{
|
|
finishOperation(.failure(error))
|
|
}
|
|
}
|
|
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
|
|
|
|
downloadTask.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension DownloadAppOperation
|
|
{
|
|
struct AltStorePlist: Decodable
|
|
{
|
|
private enum CodingKeys: String, CodingKey
|
|
{
|
|
case dependencies = "ALTDependencies"
|
|
}
|
|
|
|
var dependencies: [Dependency]
|
|
}
|
|
|
|
struct Dependency: Decodable
|
|
{
|
|
var downloadURL: URL
|
|
var path: String?
|
|
|
|
var preferredFilename: String {
|
|
let preferredFilename = self.path.map { ($0 as NSString).lastPathComponent } ?? self.downloadURL.lastPathComponent
|
|
return preferredFilename
|
|
}
|
|
|
|
init(from decoder: Decoder) throws
|
|
{
|
|
enum CodingKeys: String, CodingKey
|
|
{
|
|
case downloadURL
|
|
case path
|
|
}
|
|
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
let urlString = try container.decode(String.self, forKey: .downloadURL)
|
|
let path = try container.decodeIfPresent(String.self, forKey: .path)
|
|
|
|
guard let downloadURL = URL(string: urlString) else {
|
|
throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "downloadURL is not a valid URL.")
|
|
}
|
|
|
|
self.downloadURL = downloadURL
|
|
self.path = path
|
|
}
|
|
}
|
|
|
|
func downloadDependencies(for application: ALTApplication, completionHandler: @escaping (Result<Set<URL>, Error>) -> Void)
|
|
{
|
|
guard FileManager.default.fileExists(atPath: application.bundle.altstorePlistURL.path) else {
|
|
return completionHandler(.success([]))
|
|
}
|
|
|
|
do
|
|
{
|
|
let data = try Data(contentsOf: application.bundle.altstorePlistURL)
|
|
|
|
let altstorePlist = try PropertyListDecoder().decode(AltStorePlist.self, from: data)
|
|
|
|
var dependencyURLs = Set<URL>()
|
|
var dependencyError: Error?
|
|
|
|
let dispatchGroup = DispatchGroup()
|
|
let progress = Progress(totalUnitCount: Int64(altstorePlist.dependencies.count), parent: self.progress, pendingUnitCount: 1)
|
|
|
|
for dependency in altstorePlist.dependencies
|
|
{
|
|
dispatchGroup.enter()
|
|
|
|
self.download(dependency, for: application, progress: progress) { result in
|
|
switch result
|
|
{
|
|
case .failure(let error): dependencyError = error
|
|
case .success(let fileURL): dependencyURLs.insert(fileURL)
|
|
}
|
|
|
|
dispatchGroup.leave()
|
|
}
|
|
}
|
|
|
|
dispatchGroup.notify(qos: .userInitiated, queue: .global()) {
|
|
if let dependencyError = dependencyError
|
|
{
|
|
completionHandler(.failure(dependencyError))
|
|
}
|
|
else
|
|
{
|
|
completionHandler(.success(dependencyURLs))
|
|
}
|
|
}
|
|
}
|
|
catch let error as DecodingError
|
|
{
|
|
let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not determine dependencies for %@.", comment: ""), application.name))
|
|
completionHandler(.failure(nsError))
|
|
}
|
|
catch
|
|
{
|
|
completionHandler(.failure(error))
|
|
}
|
|
}
|
|
|
|
func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result<URL, Error>) -> Void)
|
|
{
|
|
let downloadTask = self.session.downloadTask(with: dependency.downloadURL) { (fileURL, response, error) in
|
|
do
|
|
{
|
|
let (fileURL, _) = try Result((fileURL, response), error).get()
|
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
|
|
|
let path = dependency.path ?? dependency.preferredFilename
|
|
let destinationURL = application.fileURL.appendingPathComponent(path)
|
|
|
|
let directoryURL = destinationURL.deletingLastPathComponent()
|
|
if !FileManager.default.fileExists(atPath: directoryURL.path)
|
|
{
|
|
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
|
}
|
|
|
|
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
|
|
|
|
completionHandler(.success(destinationURL))
|
|
}
|
|
catch let error as NSError
|
|
{
|
|
let localizedFailure = String(format: NSLocalizedString("The dependency '%@' could not be downloaded.", comment: ""), dependency.preferredFilename)
|
|
completionHandler(.failure(error.withLocalizedFailure(localizedFailure)))
|
|
}
|
|
}
|
|
progress.addChild(downloadTask.progress, withPendingUnitCount: 1)
|
|
|
|
downloadTask.resume()
|
|
}
|
|
}
|