mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-08 22:33:26 +01:00
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:
@@ -421,6 +421,7 @@
|
||||
D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; };
|
||||
D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.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 */; };
|
||||
D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645242AF5BC7F0047D980 /* UserAccount.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -2049,6 +2051,7 @@
|
||||
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */,
|
||||
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */,
|
||||
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */,
|
||||
D5A645202AF591980047D980 /* UTType+AltStore.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -3334,6 +3337,7 @@
|
||||
BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */,
|
||||
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */,
|
||||
BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */,
|
||||
D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */,
|
||||
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */,
|
||||
BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */,
|
||||
BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */,
|
||||
|
||||
14
AltStore/Extensions/UTType+AltStore.swift
Normal file
14
AltStore/Extensions/UTType+AltStore.swift
Normal 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")
|
||||
}
|
||||
@@ -195,6 +195,8 @@
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<string>ipa</string>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-ios-app</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
@@ -1549,8 +1549,22 @@ private extension AppManager
|
||||
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return progress
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Roxas
|
||||
import WebKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
import AltStoreCore
|
||||
import AltSign
|
||||
import Roxas
|
||||
|
||||
@objc(DownloadAppOperation)
|
||||
final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
@@ -25,6 +27,8 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
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
|
||||
@@ -183,11 +187,43 @@ private extension DownloadAppOperation {
|
||||
|
||||
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
|
||||
{
|
||||
func finishOperation(_ result: Result<URL, Error>)
|
||||
{
|
||||
Task<Void, Never>.detached(priority: .userInitiated) {
|
||||
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
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
if sourceURL.isFileURL
|
||||
{
|
||||
finishOperation(.success(sourceURL))
|
||||
|
||||
self.progress.completedUnitCount += 3
|
||||
}
|
||||
else
|
||||
|
||||
func downloadFile(from downloadURL: URL) async throws -> URL
|
||||
{
|
||||
let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
|
||||
do
|
||||
{
|
||||
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()
|
||||
finishOperation(.success(fileURL))
|
||||
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
continuation.resume(returning: fileURL)
|
||||
}
|
||||
catch
|
||||
{
|
||||
finishOperation(.failure(error))
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
|
||||
@@ -255,6 +286,157 @@ private extension DownloadAppOperation {
|
||||
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
|
||||
|
||||
@@ -51,6 +51,10 @@ extension OperationError
|
||||
case serverNotFound = 1200
|
||||
case connectionFailed = 1201
|
||||
case connectionDropped = 1202
|
||||
|
||||
/* Pledges */
|
||||
case pledgeRequired = 1401
|
||||
case pledgeInactive = 1402
|
||||
}
|
||||
|
||||
static var cancelled: CancellationError { CancellationError() }
|
||||
@@ -125,6 +129,14 @@ extension OperationError
|
||||
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
|
||||
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:
|
||||
let message = self._failureReason.map { ": \n\($0)" } ?? "."
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ extension OperationError
|
||||
case .connectionFailed: return .connectionFailed
|
||||
case .connectionDropped: return .connectionDropped
|
||||
case .forbidden: return .forbidden()
|
||||
case .pledgeRequired: return .pledgeRequired(appName: "Delta")
|
||||
case .pledgeInactive: return .pledgeInactive(appName: "Delta")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user