mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-14 01:03:27 +01:00
clean-checkpoint-1
This commit is contained in:
@@ -244,14 +244,23 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, A
|
||||
{
|
||||
team.isActiveTeam = false
|
||||
}
|
||||
|
||||
|
||||
let activeAppsMinimumVersion = OperatingSystemVersion(majorVersion: 13, minorVersion: 3, patchVersion: 1)
|
||||
if team.type == .free, !UserDefaults.standard.isAppLimitDisabled, ProcessInfo().sparseRestorePatched {
|
||||
UserDefaults.standard.activeAppsLimit = ALTActiveAppsLimit
|
||||
} else if UserDefaults.standard.isAppLimitDisabled, !ProcessInfo().sparseRestorePatched {
|
||||
UserDefaults.standard.activeAppsLimit = 10
|
||||
} else {
|
||||
UserDefaults.standard.activeAppsLimit = nil
|
||||
|
||||
let isMinimumVersionMatching = ProcessInfo.processInfo.isOperatingSystemAtLeast(activeAppsMinimumVersion)
|
||||
let isSparseRestorePatched = ProcessInfo().sparseRestorePatched
|
||||
let isAppLimitDisabled = UserDefaults.standard.isAppLimitDisabled
|
||||
|
||||
UserDefaults.standard.activeAppsLimit = nil
|
||||
// TODO: @mahee96: is the minimum ver match for ios 13.3.1 check required?
|
||||
// if so what is the app limit? As nil app limit specifies unlimited apps?!
|
||||
if team.type == .free//, isMinimumVersionMatching
|
||||
{
|
||||
if (!isAppLimitDisabled && isSparseRestorePatched) ||
|
||||
(isAppLimitDisabled && !isSparseRestorePatched)
|
||||
{
|
||||
UserDefaults.standard.activeAppsLimit = InstalledApp.freeAccountActiveAppsLimit
|
||||
}
|
||||
}
|
||||
|
||||
// Save
|
||||
@@ -620,6 +629,8 @@ private extension AuthenticationOperation
|
||||
}
|
||||
else
|
||||
{
|
||||
// We don't have private keys for any of the certificates,
|
||||
// so we need to revoke one and create a new one.
|
||||
replaceCertificate(from: certificates)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ private extension BackgroundRefreshAppsOperation
|
||||
do
|
||||
{
|
||||
let results = try result.get()
|
||||
shouldPresentAlert = false
|
||||
shouldPresentAlert = !results.isEmpty
|
||||
|
||||
for (_, result) in results
|
||||
{
|
||||
@@ -242,6 +242,8 @@ private extension BackgroundRefreshAppsOperation
|
||||
|
||||
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
|
||||
content.body = error.localizedDescription
|
||||
|
||||
shouldPresentAlert = true
|
||||
}
|
||||
|
||||
if shouldPresentAlert
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
import AltStoreCore
|
||||
/*
|
||||
|
||||
|
||||
import Nuke
|
||||
|
||||
@@ -41,7 +41,7 @@ struct BatchError: ALTLocalizedError
|
||||
return message
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@objc(ClearAppCacheOperation)
|
||||
class ClearAppCacheOperation: ResultOperation<Void>
|
||||
{
|
||||
|
||||
@@ -31,7 +31,11 @@ final class DeactivateAppOperation: ResultOperation<InstalledApp>
|
||||
{
|
||||
super.main()
|
||||
|
||||
if let error = self.context.error { return self.finish(.failure(error)) }
|
||||
if let error = self.context.error
|
||||
{
|
||||
self.finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
let installedApp = context.object(with: self.app.objectID) as! InstalledApp
|
||||
|
||||
@@ -19,24 +19,27 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
{
|
||||
@Managed
|
||||
private(set) var app: AppProtocol
|
||||
|
||||
|
||||
let context: InstallAppOperationContext
|
||||
|
||||
|
||||
private let appName: String
|
||||
private let bundleIdentifier: String
|
||||
private var sourceURL: URL?
|
||||
private let destinationURL: URL
|
||||
|
||||
private let session = URLSession(configuration: .default)
|
||||
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
|
||||
private var downloadPatreonAppContinuation: CheckedContinuation<URL, Error>?
|
||||
|
||||
|
||||
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
|
||||
{
|
||||
self.app = app
|
||||
self.context = context
|
||||
|
||||
self.appName = app.name
|
||||
self.bundleIdentifier = app.bundleIdentifier
|
||||
self.sourceURL = app.url
|
||||
self.destinationURL = destinationURL
|
||||
|
||||
super.init()
|
||||
@@ -57,23 +60,14 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
|
||||
print("Downloading App:", self.bundleIdentifier)
|
||||
|
||||
|
||||
Logger.sideload.notice("Downloading app \(self.bundleIdentifier, privacy: .public)...")
|
||||
|
||||
// Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors.
|
||||
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
|
||||
|
||||
guard let storeApp = self.app as? StoreApp else {
|
||||
// Only StoreApp allows falling back to previous versions.
|
||||
// AppVersion can only install itself, and ALTApplication doesn't have previous versions.
|
||||
return self.download(self.app)
|
||||
}
|
||||
|
||||
|
||||
self.$app.perform { app in
|
||||
do
|
||||
{
|
||||
var appVersion: AppVersion?
|
||||
|
||||
|
||||
if let version = app as? AppVersion
|
||||
{
|
||||
appVersion = version
|
||||
@@ -83,33 +77,35 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
guard let latestVersion = storeApp.latestAvailableVersion else {
|
||||
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
|
||||
throw OperationError.unknown(failureReason: failureReason)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Attempt to download latest _available_ version, and fall back to older versions if necessary.
|
||||
appVersion = latestVersion
|
||||
}
|
||||
|
||||
|
||||
if let appVersion
|
||||
{
|
||||
try self.verify(appVersion)
|
||||
}
|
||||
|
||||
|
||||
self.download(appVersion ?? app)
|
||||
}
|
||||
catch let error as VerificationError where error.code == .iOSVersionNotSupported
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController, let storeApp = app.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion
|
||||
guard let presentingViewController = self.context.presentingViewController, let storeApp = app.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion,
|
||||
case let version = latestSupportedVersion.version,
|
||||
version != storeApp.installedApp?.version
|
||||
else { return self.finish(.failure(error)) }
|
||||
|
||||
|
||||
if let installedApp = storeApp.installedApp
|
||||
{
|
||||
guard !installedApp.matches(latestSupportedVersion) 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: "")
|
||||
let localizedVersion = latestSupportedVersion.localizedVersion
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
|
||||
@@ -120,19 +116,25 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
})
|
||||
presentingViewController.present(alertController, animated: true)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
catch
|
||||
{
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func finish(_ result: Result<ALTApplication, any Error>) {
|
||||
do {
|
||||
override func finish(_ result: Result<ALTApplication, Error>)
|
||||
{
|
||||
do
|
||||
{
|
||||
try FileManager.default.removeItem(at: self.temporaryDirectory)
|
||||
} catch {
|
||||
print("Failed to remove DownloadAppOperation temporary directory: \(self.temporaryDirectory).", error)
|
||||
Logger.sideload.error("Failed to remove DownloadAppOperation temporary directory: \(self.temporaryDirectory, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Failed to remove DownloadAppOperation temporary directory: \(self.temporaryDirectory).", error)
|
||||
}
|
||||
|
||||
super.finish(result)
|
||||
}
|
||||
}
|
||||
@@ -153,274 +155,273 @@ private extension DownloadAppOperation
|
||||
|
||||
func download(@Managed _ app: AppProtocol)
|
||||
{
|
||||
guard let sourceURL = $app.url else { return self.finish(.failure(OperationError.appNotFound(name: self.appName))) }
|
||||
|
||||
if let appVersion = app as? AppVersion
|
||||
{
|
||||
// All downloads go through this path, and `app` is
|
||||
// always an AppVersion if downloading from a source,
|
||||
// so context.appVersion != nil means downloading from source.
|
||||
self.context.appVersion = appVersion
|
||||
}
|
||||
|
||||
self.downloadIPA(from: sourceURL) { result in
|
||||
do
|
||||
guard let sourceURL = self.sourceURL else { return self.finish(.failure(OperationError.appNotFound(name: self.appName)))
|
||||
|
||||
if let appVersion = app as? AppVersion
|
||||
{
|
||||
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 }
|
||||
|
||||
Logger.sideload.notice("Downloaded app \(copiedApplication.bundleIdentifier, privacy: .public) from \(sourceURL, privacy: .public)")
|
||||
|
||||
self.finish(.success(copiedApplication))
|
||||
|
||||
self.progress.completedUnitCount += 1
|
||||
}
|
||||
catch
|
||||
{
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
// All downloads go through this path, and `app` is
|
||||
// always an AppVersion if downloading from a source,
|
||||
// so context.appVersion != nil means downloading from source.
|
||||
self.context.appVersion = appVersion
|
||||
}
|
||||
catch
|
||||
{
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
|
||||
{
|
||||
Task<Void, Never>.detached(priority: .userInitiated) {
|
||||
do
|
||||
{
|
||||
let fileURL: URL
|
||||
|
||||
if sourceURL.isFileURL
|
||||
{
|
||||
fileURL = sourceURL
|
||||
self.progress.completedUnitCount += 3
|
||||
}
|
||||
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
|
||||
{
|
||||
// Patreon app
|
||||
fileURL = try await self.downloadPatreonApp(from: sourceURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular app
|
||||
fileURL = try await self.downloadFile(from: sourceURL)
|
||||
}
|
||||
|
||||
defer {
|
||||
if !sourceURL.isFileURL
|
||||
{
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Use context's temporaryDirectory to ensure .ipa isn't deleted before we're done installing.
|
||||
let ipaURL = self.context.temporaryDirectory.appendingPathComponent("App.ipa")
|
||||
try FileManager.default.copyItem(at: fileURL, to: ipaURL)
|
||||
|
||||
self.context.ipaURL = ipaURL
|
||||
}
|
||||
|
||||
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
|
||||
completionHandler(.success(application))
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(from downloadURL: URL) async throws -> URL
|
||||
{
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
|
||||
downloadIPA(from: sourceURL!) { result in
|
||||
do
|
||||
{
|
||||
if let response = response as? HTTPURLResponse
|
||||
let application = try result.get()
|
||||
|
||||
if self.context.bundleIdentifier == StoreApp.dolphinAppID, self.context.bundleIdentifier != application.bundleIdentifier
|
||||
{
|
||||
guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) }
|
||||
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
||||
continuation.resume(returning: fileURL)
|
||||
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
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
|
||||
|
||||
downloadTask.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
|
||||
{
|
||||
guard !UserDefaults.shared.skipPatreonDownloads else {
|
||||
// Skip all hacks, take user straight to Patreon post.
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
|
||||
do
|
||||
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
|
||||
{
|
||||
// User is pledged to this app, attempt to download.
|
||||
|
||||
let fileURL = try await self.downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
Task<Void, Never>.detached(priority: .userInitiated) {
|
||||
do
|
||||
{
|
||||
let fileURL: URL
|
||||
|
||||
if sourceURL.isFileURL
|
||||
{
|
||||
fileURL = sourceURL
|
||||
self.progress.completedUnitCount += 3
|
||||
}
|
||||
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
|
||||
{
|
||||
// Patreon app
|
||||
fileURL = try await downloadPatreonApp(from: sourceURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular app
|
||||
fileURL = try await downloadFile(from: sourceURL)
|
||||
}
|
||||
|
||||
defer {
|
||||
if !sourceURL.isFileURL
|
||||
{
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Use context's temporaryDirectory to ensure .ipa isn't deleted before we're done installing.
|
||||
let ipaURL = self.context.temporaryDirectory.appendingPathComponent("App.ipa")
|
||||
try FileManager.default.copyItem(at: fileURL, to: ipaURL)
|
||||
|
||||
self.context.ipaURL = ipaURL
|
||||
}
|
||||
|
||||
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
|
||||
completionHandler(.success(application))
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
|
||||
func downloadFile(from downloadURL: URL) async throws -> URL
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
// Attempt to sign-in again in case our Patreon session has expired.
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
|
||||
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
if let response = response as? HTTPURLResponse
|
||||
{
|
||||
guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) }
|
||||
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) }
|
||||
}
|
||||
|
||||
continuation.resume()
|
||||
let (fileURL, _) = try Result((fileURL, response), error).get()
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
continuation.resume(returning: fileURL)
|
||||
}
|
||||
catch
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
|
||||
|
||||
downloadTask.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
|
||||
{
|
||||
guard !UserDefaults.shared.skipPatreonDownloads else {
|
||||
// Skip all hacks, take user straight to Patreon post.
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// Success, so try to download once more now that we're definitely authenticated.
|
||||
// User is pledged to this app, attempt to download.
|
||||
|
||||
let fileURL = try await self.downloadFile(from: patreonURL)
|
||||
let fileURL = try await downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
{
|
||||
// We know authentication succeeded, so failure must mean user isn't patron/on the correct tier,
|
||||
// or that our hacky workaround for downloading Patreon attachments has failed.
|
||||
// Either way, taking them directly to the post serves as a decent fallback.
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
return try await downloadFromPatreonPost()
|
||||
// Attempt to sign-in again in case our Patreon session has expired.
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
catch
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// Success, so try to download once more now that we're definitely authenticated.
|
||||
|
||||
let fileURL = try await downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
{
|
||||
// We know authentication succeeded, so failure must mean user isn't patron/on the correct tier,
|
||||
// or that our hacky workaround for downloading Patreon attachments has failed.
|
||||
// Either way, taking them directly to the post serves as a decent fallback.
|
||||
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFromPatreonPost() async throws -> URL
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false),
|
||||
let postItem = components.queryItems?.first(where: { $0.name == "h" }),
|
||||
let postID = postItem.value,
|
||||
let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID)
|
||||
{
|
||||
downloadURL = patreonPostURL
|
||||
}
|
||||
else
|
||||
{
|
||||
downloadURL = patreonURL
|
||||
}
|
||||
|
||||
return try await downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFromPatreonPost() async throws -> URL
|
||||
@MainActor
|
||||
func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
let webViewController = WebViewController(url: patreonURL)
|
||||
webViewController.delegate = self
|
||||
webViewController.webView.navigationDelegate = self
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false),
|
||||
let postItem = components.queryItems?.first(where: { $0.name == "h" }),
|
||||
let postID = postItem.value,
|
||||
let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID)
|
||||
do
|
||||
{
|
||||
downloadURL = patreonPostURL
|
||||
}
|
||||
else
|
||||
{
|
||||
downloadURL = patreonURL
|
||||
defer {
|
||||
navigationController.dismiss(animated: true)
|
||||
}
|
||||
|
||||
downloadURL = try await withCheckedThrowingContinuation { continuation in
|
||||
self.downloadPatreonAppContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
return try await self.downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
|
||||
let fileURL = try await downloadFile(from: downloadURL)
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL
|
||||
{
|
||||
let webViewController = WebViewController(url: patreonURL)
|
||||
webViewController.delegate = self
|
||||
webViewController.webView.navigationDelegate = self
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
do
|
||||
{
|
||||
defer {
|
||||
navigationController.dismiss(animated: true)
|
||||
}
|
||||
|
||||
downloadURL = try await withCheckedThrowingContinuation { continuation in
|
||||
self.downloadPatreonAppContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
let fileURL = try await self.downloadFile(from: downloadURL)
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadAppOperation: WebViewControllerDelegate
|
||||
{
|
||||
func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
{
|
||||
guard let continuation = self.downloadPatreonAppContinuation else { return }
|
||||
self.downloadPatreonAppContinuation = nil
|
||||
|
||||
|
||||
continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadAppOperation: WKNavigationDelegate
|
||||
{
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy
|
||||
{
|
||||
guard #available(iOS 14.5, *), navigationAction.shouldPerformDownload else { return .allow }
|
||||
|
||||
|
||||
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
|
||||
self.downloadPatreonAppContinuation = nil
|
||||
|
||||
|
||||
if let downloadURL = navigationAction.request.url
|
||||
{
|
||||
continuation.resume(returning: downloadURL)
|
||||
@@ -429,19 +430,19 @@ extension DownloadAppOperation: WKNavigationDelegate
|
||||
{
|
||||
continuation.resume(throwing: URLError(.badURL))
|
||||
}
|
||||
|
||||
|
||||
return .cancel
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy
|
||||
{
|
||||
// Called for Patreon attachments
|
||||
|
||||
|
||||
guard !navigationResponse.canShowMIMEType else { return .allow }
|
||||
|
||||
|
||||
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
|
||||
self.downloadPatreonAppContinuation = nil
|
||||
|
||||
|
||||
guard let response = navigationResponse.response as? HTTPURLResponse, let responseURL = response.url,
|
||||
let mimeType = response.mimeType, let type = UTType(mimeType: mimeType),
|
||||
type.conforms(to: .ipa) || type.conforms(to: .zip) || type.conforms(to: .application)
|
||||
@@ -449,9 +450,9 @@ extension DownloadAppOperation: WKNavigationDelegate
|
||||
continuation.resume(throwing: OperationError.invalidApp)
|
||||
return .cancel
|
||||
}
|
||||
|
||||
|
||||
continuation.resume(returning: responseURL)
|
||||
|
||||
|
||||
return .cancel
|
||||
}
|
||||
}
|
||||
@@ -546,7 +547,7 @@ private extension DownloadAppOperation
|
||||
}
|
||||
catch let error as DecodingError
|
||||
{
|
||||
let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("The dependencies for %@ could not be determined.", comment: ""), application.name))
|
||||
let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not determine dependencies for %@.", comment: ""), application.name))
|
||||
completionHandler(.failure(nsError))
|
||||
}
|
||||
catch
|
||||
@@ -578,7 +579,7 @@ private extension DownloadAppOperation
|
||||
}
|
||||
catch let error as NSError
|
||||
{
|
||||
let localizedFailure = String(format: NSLocalizedString("The dependency “%@” could not be downloaded.", comment: ""), dependency.preferredFilename)
|
||||
let localizedFailure = String(format: NSLocalizedString("The dependency '%@' could not be downloaded.", comment: ""), dependency.preferredFilename)
|
||||
completionHandler(.failure(error.withLocalizedFailure(localizedFailure)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,12 @@ extension OperationError
|
||||
{
|
||||
enum Code: Int, ALTErrorCode, CaseIterable {
|
||||
typealias Error = OperationError
|
||||
|
||||
|
||||
// General
|
||||
case unknown = 1000
|
||||
case unknownResult = 1001
|
||||
// case cancelled = 1002
|
||||
case cancelled = 1002
|
||||
case timedOut = 1003
|
||||
case unableToConnectSideJIT
|
||||
case unableToRespondSideJITDevice
|
||||
case wrongSideJITIP
|
||||
case SideJITIssue // (error: String)
|
||||
case refreshsidejit
|
||||
case notAuthenticated = 1004
|
||||
case appNotFound = 1005
|
||||
case unknownUDID = 1006
|
||||
@@ -35,18 +30,11 @@ extension OperationError
|
||||
case noSources = 1010
|
||||
case openAppFailed = 1011
|
||||
case missingAppGroup = 1012
|
||||
case refreshAppFailed
|
||||
|
||||
// Connection
|
||||
case noWiFi = 1200
|
||||
case tooNewError
|
||||
case anisetteV1Error//(message: String)
|
||||
case provisioningError//(result: String, message: String?)
|
||||
case anisetteV3Error//(message: String)
|
||||
|
||||
case cacheClearError//(errors: [String])
|
||||
case forbidden = 1013
|
||||
case sourceNotAdded = 1014
|
||||
|
||||
|
||||
// Connection
|
||||
|
||||
/* Connection */
|
||||
case serverNotFound = 1200
|
||||
@@ -56,6 +44,20 @@ extension OperationError
|
||||
/* Pledges */
|
||||
case pledgeRequired = 1401
|
||||
case pledgeInactive = 1402
|
||||
|
||||
/* SideStore Only */
|
||||
case unableToConnectSideJIT
|
||||
case unableToRespondSideJITDevice
|
||||
case wrongSideJITIP
|
||||
case SideJITIssue // (error: String)
|
||||
case refreshsidejit
|
||||
case refreshAppFailed
|
||||
case tooNewError
|
||||
case anisetteV1Error//(message: String)
|
||||
case provisioningError//(result: String, message: String?)
|
||||
case anisetteV3Error//(message: String)
|
||||
case cacheClearError//(errors: [String])
|
||||
case noWiFi
|
||||
}
|
||||
|
||||
static var cancelled: CancellationError { CancellationError() }
|
||||
@@ -70,13 +72,13 @@ extension OperationError
|
||||
static let invalidApp: OperationError = .init(code: .invalidApp)
|
||||
static let noSources: OperationError = .init(code: .noSources)
|
||||
static let missingAppGroup: OperationError = .init(code: .missingAppGroup)
|
||||
|
||||
|
||||
static let noWiFi: OperationError = .init(code: .noWiFi)
|
||||
static let tooNewError: OperationError = .init(code: .tooNewError)
|
||||
static let provisioningError: OperationError = .init(code: .provisioningError)
|
||||
static let anisetteV1Error: OperationError = .init(code: .anisetteV1Error)
|
||||
static let anisetteV3Error: OperationError = .init(code: .anisetteV3Error)
|
||||
|
||||
|
||||
static let cacheClearError: OperationError = .init(code: .cacheClearError)
|
||||
|
||||
static func unknown(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||
@@ -126,6 +128,7 @@ extension OperationError
|
||||
|
||||
static func invalidParameters(_ message: String? = nil) -> OperationError {
|
||||
OperationError(code: .invalidParameters, failureReason: message)
|
||||
}
|
||||
|
||||
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line)
|
||||
@@ -168,8 +171,8 @@ struct OperationError: ALTLocalizedError {
|
||||
private var _failureReason: String?
|
||||
|
||||
private init(code: Code, failureReason: String? = nil,
|
||||
appName: String? = nil, requiredAppIDs: Int? = nil, availableAppIDs: Int? = nil,
|
||||
expirationDate: Date? = nil, sourceFile: String? = nil, sourceLine: UInt? = nil){
|
||||
appName: String? = nil, sourceName: String? = nil, requiredAppIDs: Int? = nil,
|
||||
availableAppIDs: Int? = nil, expirationDate: Date? = nil, sourceFile: String? = nil, sourceLine: UInt? = nil){
|
||||
self.code = code
|
||||
self._failureReason = failureReason
|
||||
|
||||
@@ -245,7 +248,6 @@ struct OperationError: ALTLocalizedError {
|
||||
}
|
||||
|
||||
}
|
||||
private var _failureReason: String?
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self.code
|
||||
|
||||
@@ -16,7 +16,7 @@ extension VerificationError
|
||||
typealias Error = VerificationError
|
||||
|
||||
// Legacy
|
||||
// case privateEntitlements = 0
|
||||
// case privateEntitlements = 0
|
||||
|
||||
case mismatchedBundleIdentifiers = 1
|
||||
case iOSVersionNotSupported = 2
|
||||
@@ -28,7 +28,11 @@ extension VerificationError
|
||||
case undeclaredPermissions = 6
|
||||
case addedPermissions = 7
|
||||
}
|
||||
|
||||
|
||||
// static func privateEntitlements(_ entitlements: [String: Any], app: ALTApplication) -> VerificationError {
|
||||
// VerificationError(code: .privateEntitlements, app: app, entitlements: entitlements)
|
||||
// }
|
||||
|
||||
static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError {
|
||||
VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID)
|
||||
}
|
||||
@@ -108,6 +112,10 @@ struct VerificationError: ALTLocalizedError
|
||||
var errorFailureReason: String {
|
||||
switch self.code
|
||||
{
|
||||
// case .privateEntitlements:
|
||||
// let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
|
||||
// return String(formatted: "“%@” requires private permissions.", appName)
|
||||
|
||||
case .mismatchedBundleIdentifiers:
|
||||
if let appBundleID = self.$app.bundleIdentifier, let bundleID = self.sourceBundleID
|
||||
{
|
||||
|
||||
@@ -46,9 +46,7 @@ final class InstallAppOperation: ResultOperation<InstalledApp>
|
||||
else {
|
||||
return self.finish(.failure(OperationError.invalidParameters("InstallAppOperation.main: self.context.certificate or self.context.resignedApp or self.context.provisioningProfiles is nil")))
|
||||
}
|
||||
|
||||
Logger.sideload.notice("Installing resigned app \(resignedApp.bundleIdentifier, privacy: .public)...")
|
||||
|
||||
|
||||
@Managed var appVersion = self.context.appVersion
|
||||
let storeBuildVersion = $appVersion.buildVersion
|
||||
|
||||
@@ -257,7 +255,6 @@ final class InstallAppOperation: ResultOperation<InstalledApp>
|
||||
catch
|
||||
{
|
||||
print("Failed to remove refreshed .ipa: \(error)")
|
||||
Logger.sideload.error("Failed to remove refreshed .ipa: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,43 +264,6 @@ final class InstallAppOperation: ResultOperation<InstalledApp>
|
||||
|
||||
private extension InstallAppOperation
|
||||
{
|
||||
func receive(from connection: ServerConnection, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
connection.receiveResponse() { (result) in
|
||||
do
|
||||
{
|
||||
let response = try result.get()
|
||||
|
||||
switch response
|
||||
{
|
||||
case .installationProgress(let response):
|
||||
Logger.sideload.debug("Installing \(self.context.resignedApp?.bundleIdentifier ?? self.context.bundleIdentifier, privacy: .public)... \((response.progress * 100).rounded())%")
|
||||
|
||||
if response.progress == 1.0
|
||||
{
|
||||
self.progress.completedUnitCount = self.progress.totalUnitCount
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
else
|
||||
{
|
||||
self.progress.completedUnitCount = Int64(response.progress * 100)
|
||||
self.receive(from: connection, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
case .error(let response):
|
||||
completionHandler(.failure(response.error))
|
||||
|
||||
default:
|
||||
completionHandler(.failure(ALTServerError(.unknownRequest)))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(ALTServerError(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanUp()
|
||||
{
|
||||
guard !self.didCleanUp else { return }
|
||||
@@ -315,7 +275,7 @@ private extension InstallAppOperation
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.sideload.error("Failed to remove temporary directory: \(error.localizedDescription, privacy: .public)")
|
||||
print("Failed to remove temporary directory.", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ final class SendAppOperation: ResultOperation<()>
|
||||
guard let resignedApp = self.context.resignedApp else {
|
||||
return self.finish(.failure(OperationError.invalidParameters("SendAppOperation.main: self.resignedApp is nil")))
|
||||
}
|
||||
|
||||
Logger.sideload.notice("Sending app \(self.context.bundleIdentifier, privacy: .public) to AltServer \(server.localizedName ?? "nil", privacy: .public)...")
|
||||
|
||||
|
||||
// self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa.
|
||||
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
|
||||
let fileURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
|
||||
@@ -13,87 +13,6 @@ import AltStoreCore
|
||||
import AltSign
|
||||
import Roxas
|
||||
|
||||
extension VerificationError
|
||||
{
|
||||
enum Code: Int, ALTErrorCode, CaseIterable {
|
||||
typealias Error = VerificationError
|
||||
|
||||
case privateEntitlements
|
||||
case mismatchedBundleIdentifiers
|
||||
case iOSVersionNotSupported
|
||||
}
|
||||
|
||||
static func privateEntitlements(_ entitlements: [String: Any], app: ALTApplication) -> VerificationError {
|
||||
VerificationError(code: .privateEntitlements, app: app, entitlements: entitlements)
|
||||
}
|
||||
|
||||
static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError {
|
||||
VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID)
|
||||
}
|
||||
|
||||
static func iOSVersionNotSupported(app: AppProtocol, osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, requiredOSVersion: OperatingSystemVersion?) -> VerificationError {
|
||||
VerificationError(code: .iOSVersionNotSupported, app: app)
|
||||
}
|
||||
}
|
||||
|
||||
struct VerificationError: ALTLocalizedError {
|
||||
let code: Code
|
||||
|
||||
var errorTitle: String?
|
||||
var errorFailure: String?
|
||||
@Managed var app: AppProtocol?
|
||||
var sourceBundleID: String?
|
||||
var deviceOSVersion: OperatingSystemVersion?
|
||||
var requiredOSVersion: OperatingSystemVersion?
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self.code {
|
||||
case .iOSVersionNotSupported:
|
||||
guard let deviceOSVersion else { return nil }
|
||||
|
||||
var failureReason = self.errorFailureReason
|
||||
if self.app == nil {
|
||||
let firstLetter = failureReason.prefix(1).lowercased()
|
||||
failureReason = firstLetter + failureReason.dropFirst()
|
||||
}
|
||||
|
||||
return String(formatted: "This device is running iOS %@, but %@", deviceOSVersion.stringValue, failureReason)
|
||||
default: return nil
|
||||
}
|
||||
|
||||
return self.errorFailureReason
|
||||
}
|
||||
|
||||
var errorFailureReason: String {
|
||||
switch self.code
|
||||
{
|
||||
case .privateEntitlements:
|
||||
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
|
||||
return String(formatted: "“%@” requires private permissions.", appName)
|
||||
|
||||
case .mismatchedBundleIdentifiers:
|
||||
if let appBundleID = self.$app.bundleIdentifier, let bundleID = self.sourceBundleID {
|
||||
return String(formatted: "The bundle ID '%@' does not match the one specified by the source ('%@').", appBundleID, bundleID)
|
||||
} else {
|
||||
return NSLocalizedString("The bundle ID does not match the one specified by the source.", comment: "")
|
||||
}
|
||||
|
||||
case .iOSVersionNotSupported:
|
||||
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
|
||||
let deviceOSVersion = self.deviceOSVersion ?? ProcessInfo.processInfo.operatingSystemVersion
|
||||
|
||||
guard let requiredOSVersion else {
|
||||
return String(formatted: "%@ does not support iOS %@.", appName, deviceOSVersion.stringValue)
|
||||
}
|
||||
if deviceOSVersion > requiredOSVersion {
|
||||
return String(formatted: "%@ requires iOS %@ or earlier", appName, requiredOSVersion.stringValue)
|
||||
} else {
|
||||
return String(formatted: "%@ requires iOS %@ or later", appName, requiredOSVersion.stringValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import RegexBuilder
|
||||
|
||||
private extension ALTEntitlement
|
||||
|
||||
Reference in New Issue
Block a user