Supports downloading apps from locked Patreon posts

Uses cached Patreon session cookies to access post attachments despite no official API support.
This commit is contained in:
Riley Testut
2023-11-30 14:28:57 -06:00
committed by Magesh K
parent dddb9c5ddb
commit ba94886ba9
7 changed files with 261 additions and 20 deletions

View File

@@ -421,6 +421,7 @@
D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; }; D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; };
D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; }; D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; };
D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; }; D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; };
D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645202AF591980047D980 /* UTType+AltStore.swift */; };
D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */; }; D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */; };
D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645242AF5BC7F0047D980 /* UserAccount.swift */; }; D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645242AF5BC7F0047D980 /* UserAccount.swift */; };
D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; }; D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; };
@@ -1095,6 +1096,7 @@
D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = "<group>"; }; D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = "<group>"; };
D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = "<group>"; }; D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = "<group>"; };
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = "<group>"; }; D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = "<group>"; };
D5A645202AF591980047D980 /* UTType+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+AltStore.swift"; sourceTree = "<group>"; };
D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = "<group>"; }; D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = "<group>"; };
D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = "<group>"; }; D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = "<group>"; };
D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = "<group>"; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = "<group>"; };
@@ -2049,6 +2051,7 @@
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */,
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */,
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */, D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */,
D5A645202AF591980047D980 /* UTType+AltStore.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -3334,6 +3337,7 @@
BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */, BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */,
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */, D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */,
BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */, BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */,
D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */,
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */, D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */,
BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */, BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */,
BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */, BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */,

View File

@@ -0,0 +1,14 @@
//
// UTType+AltStore.swift
// AltStore
//
// Created by Riley Testut on 11/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UniformTypeIdentifiers
extension UTType
{
static let ipa = UTType(importedAs: "com.apple.itunes.ipa")
}

View File

@@ -195,6 +195,8 @@
<dict> <dict>
<key>public.filename-extension</key> <key>public.filename-extension</key>
<string>ipa</string> <string>ipa</string>
<key>public.mime-type</key>
<string>application/x-ios-app</string>
</dict> </dict>
</dict> </dict>
<dict> <dict>

View File

@@ -1549,8 +1549,22 @@ private extension AppManager
progress.addChild(installOperation.progress, withPendingUnitCount: 30) progress.addChild(installOperation.progress, withPendingUnitCount: 30)
installOperation.addDependency(sendAppOperation) installOperation.addDependency(sendAppOperation)
let operations = [downloadOperation, verifyOperation, removeAppExtensionsOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, deactivateAppsOperation, patchAppOperation, resignAppOperation, sendAppOperation, installOperation] let operations = [downloadOperation, verifyOperation, deactivateAppsOperation, removeAppExtensionsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation]
group.add(operations) group.add(operations)
if let storeApp = downloadingApp.storeApp, storeApp.isPledgeRequired
{
// Patreon apps may require authenticating with WebViewController,
// so make sure to run DownloadAppOperation serially.
self.run([downloadOperation], context: group.context, requiresSerialQueue: true)
if let index = operations.firstIndex(of: downloadOperation)
{
// Remove downloadOperation from operations to prevent running it twice.
operations.remove(at: index)
}
}
self.run(operations, context: group.context) self.run(operations, context: group.context)
return progress return progress

View File

@@ -7,10 +7,12 @@
// //
import Foundation import Foundation
import Roxas import WebKit
import UniformTypeIdentifiers
import AltStoreCore import AltStoreCore
import AltSign import AltSign
import Roxas
@objc(DownloadAppOperation) @objc(DownloadAppOperation)
final class DownloadAppOperation: ResultOperation<ALTApplication> final class DownloadAppOperation: ResultOperation<ALTApplication>
@@ -25,6 +27,8 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
private let session = URLSession(configuration: .default) private let session = URLSession(configuration: .default)
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL() private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
private var downloadPatreonAppContinuation: CheckedContinuation<URL, Error>?
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext) init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
{ {
self.app = app self.app = app
@@ -183,11 +187,43 @@ private extension DownloadAppOperation {
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void) func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
{ {
func finishOperation(_ result: Result<URL, Error>) Task<Void, Never>.detached(priority: .userInitiated) {
{
do do
{ {
let fileURL = try result.get() let fileURL: URL
if sourceURL.isFileURL
{
fileURL = sourceURL
self.progress.completedUnitCount += 3
}
else if let isPledged = await self.context.$appVersion.perform({ $0?.app?.isPledged }), !isPledged
{
// Not pledged, so just show Patreon page.
guard let presentingViewController = self.context.presentingViewController,
let patreonURL = await self.context.$appVersion.perform({ $0?.app?.source?.patreonURL })
else { throw OperationError.pledgeRequired(appName: self.appName) }
// Intercept downloads just in case they are in fact pledged.
fileURL = try await self.downloadFromPatreon(patreonURL, presentingViewController: presentingViewController)
}
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 var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) } guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) }
@@ -223,31 +259,26 @@ private extension DownloadAppOperation {
completionHandler(.failure(error)) completionHandler(.failure(error))
} }
} }
}
if sourceURL.isFileURL func downloadFile(from downloadURL: URL) async throws -> URL
{ {
finishOperation(.success(sourceURL)) try await withCheckedThrowingContinuation { continuation in
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
self.progress.completedUnitCount += 3
}
else
{
let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in
do do
{ {
if let response = response as? HTTPURLResponse if let response = response as? HTTPURLResponse
{ {
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: sourceURL]) } guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) }
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) }
} }
let (fileURL, _) = try Result((fileURL, response), error).get() let (fileURL, _) = try Result((fileURL, response), error).get()
finishOperation(.success(fileURL)) continuation.resume(returning: fileURL)
try? FileManager.default.removeItem(at: fileURL)
} }
catch catch
{ {
finishOperation(.failure(error)) continuation.resume(throwing: error)
} }
} }
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3) self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
@@ -255,6 +286,157 @@ private extension DownloadAppOperation {
downloadTask.resume() downloadTask.resume()
} }
} }
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
{
do
{
// User is pledged to this app, attempt to download.
let fileURL = try await self.downloadFile(from: patreonURL)
return fileURL
}
catch URLError.noPermissionsToReadFile
{
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
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 self.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 self.downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
}
}
@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)
{
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
{
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)
}
else
{
continuation.resume(throwing: URLError(.badURL))
}
return .cancel
}
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)
else {
continuation.resume(throwing: OperationError.invalidApp)
return .cancel
}
continuation.resume(returning: responseURL)
return .cancel
}
} }
private extension DownloadAppOperation private extension DownloadAppOperation

View File

@@ -51,6 +51,10 @@ extension OperationError
case serverNotFound = 1200 case serverNotFound = 1200
case connectionFailed = 1201 case connectionFailed = 1201
case connectionDropped = 1202 case connectionDropped = 1202
/* Pledges */
case pledgeRequired = 1401
case pledgeInactive = 1402
} }
static var cancelled: CancellationError { CancellationError() } static var cancelled: CancellationError { CancellationError() }
@@ -125,6 +129,14 @@ extension OperationError
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError { static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line) OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line)
} }
static func pledgeRequired(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .pledgeRequired, appName: appName, sourceFile: file, sourceLine: line)
}
static func pledgeInactive(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .pledgeInactive, appName: appName, sourceFile: file, sourceLine: line)
}
} }
@@ -205,6 +217,17 @@ struct OperationError: ALTLocalizedError {
case .invalidParameters: case .invalidParameters:
let message = self._failureReason.map { ": \n\($0)" } ?? "." let message = self._failureReason.map { ": \n\($0)" } ?? "."
return String(format: NSLocalizedString("Invalid parameters%@", comment: ""), message) return String(format: NSLocalizedString("Invalid parameters%@", comment: ""), message)
case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "")
case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "")
case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "")
case .pledgeRequired:
let appName = self.appName ?? NSLocalizedString("This app", comment: "")
return String(format: NSLocalizedString("%@ requires an active pledge in order to be installed.", comment: ""), appName)
case .pledgeInactive:
let appName = self.appName ?? NSLocalizedString("this app", comment: "")
return String(format: NSLocalizedString("Your pledge is no longer active. Please renew it to continue using %@ normally.", comment: ""), appName)
} }
} }

View File

@@ -237,6 +237,8 @@ extension OperationError
case .connectionFailed: return .connectionFailed case .connectionFailed: return .connectionFailed
case .connectionDropped: return .connectionDropped case .connectionDropped: return .connectionDropped
case .forbidden: return .forbidden() case .forbidden: return .forbidden()
case .pledgeRequired: return .pledgeRequired(appName: "Delta")
case .pledgeInactive: return .pledgeInactive(appName: "Delta")
} }
} }
} }