diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 6bfce3e0..1161737c 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -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 = ""; }; D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = ""; }; D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = ""; }; + D5A645202AF591980047D980 /* UTType+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+AltStore.swift"; sourceTree = ""; }; D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = ""; }; D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = ""; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = ""; }; @@ -2049,6 +2051,7 @@ D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */, + D5A645202AF591980047D980 /* UTType+AltStore.swift */, ); path = Extensions; sourceTree = ""; @@ -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 */, diff --git a/AltStore/Extensions/UTType+AltStore.swift b/AltStore/Extensions/UTType+AltStore.swift new file mode 100644 index 00000000..b7212d2c --- /dev/null +++ b/AltStore/Extensions/UTType+AltStore.swift @@ -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") +} diff --git a/AltStore/Info.plist b/AltStore/Info.plist index ca2f4c64..88d24bc1 100644 --- a/AltStore/Info.plist +++ b/AltStore/Info.plist @@ -195,6 +195,8 @@ public.filename-extension ipa + public.mime-type + application/x-ios-app diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 4e8ea028..126ee7c5 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -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 diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 040e4def..38aa4228 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -7,10 +7,12 @@ // import Foundation -import Roxas +import WebKit +import UniformTypeIdentifiers import AltStoreCore import AltSign +import Roxas @objc(DownloadAppOperation) final class DownloadAppOperation: ResultOperation @@ -25,6 +27,8 @@ final class DownloadAppOperation: ResultOperation private let session = URLSession(configuration: .default) private let temporaryDirectory = FileManager.default.uniqueTemporaryURL() + private var downloadPatreonAppContinuation: CheckedContinuation? + 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) -> Void) { - func finishOperation(_ result: Result) - { + Task.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 - { - let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in + } + + func downloadFile(from downloadURL: URL) async throws -> URL + { + 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 diff --git a/AltStore/Operations/Errors/OperationError.swift b/AltStore/Operations/Errors/OperationError.swift index 32de6717..e62ac963 100644 --- a/AltStore/Operations/Errors/OperationError.swift +++ b/AltStore/Operations/Errors/OperationError.swift @@ -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) } } diff --git a/AltTests/TestErrors.swift b/AltTests/TestErrors.swift index 02728df0..9e8d6985 100644 --- a/AltTests/TestErrors.swift +++ b/AltTests/TestErrors.swift @@ -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") } } }