diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index fd1ad822..6bfce3e0 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -356,6 +356,7 @@ D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D51AD28029356B8000967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D52A2F972ACB40F700BDF8E3 /* Logger+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */; }; + D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52B4ABE2AF183F0005991C3 /* WebViewController.swift */; }; D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; }; D52DD35E2AAA89A600A7F2B6 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D52DD35D2AAA89A600A7F2B6 /* AltSign-Dynamic */; }; D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */; }; @@ -1031,6 +1032,7 @@ D51AD27C29356B7B00967AAA /* ALTWrappedError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTWrappedError.h; sourceTree = ""; }; D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = ""; }; D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = ""; }; + D52B4ABE2AF183F0005991C3 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = ""; }; @@ -1637,6 +1639,7 @@ isa = PBXGroup; children = ( BF66EE8B2501AEB1007EE018 /* Keychain.swift */, + D52B4ABE2AF183F0005991C3 /* WebViewController.swift */, ); path = Components; sourceTree = ""; @@ -3097,6 +3100,7 @@ D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */, BF66EED32501AECA007EE018 /* AltStore2ToAltStore3.xcmappingmodel in Sources */, BF66EEA52501AEC5007EE018 /* Benefit.swift in Sources */, + D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */, BF66EED22501AECA007EE018 /* AltStore4ToAltStore5.xcmappingmodel in Sources */, D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */, D5189C022A01BC6800F44625 /* UserInfoValue.swift in Sources */, diff --git a/AltStore/Settings/PatreonViewController.swift b/AltStore/Settings/PatreonViewController.swift index c7df8d70..26ff280a 100644 --- a/AltStore/Settings/PatreonViewController.swift +++ b/AltStore/Settings/PatreonViewController.swift @@ -202,7 +202,7 @@ private extension PatreonViewController @IBAction func authenticate(_ sender: UIBarButtonItem) { - PatreonAPI.shared.authenticate { (result) in + PatreonAPI.shared.authenticate(presentingViewController: self) { (result) in do { let account = try result.get() @@ -212,9 +212,12 @@ private extension PatreonViewController self.update() } } - catch ASWebAuthenticationSessionError.canceledLogin + catch is CancellationError { - // Ignore + // Clear in-app browser cache in case they are signed into wrong account. + Task.detached { + await PatreonAPI.shared.deleteAuthCookies() + } } catch { diff --git a/AltStoreCore/Components/WebViewController.swift b/AltStoreCore/Components/WebViewController.swift new file mode 100644 index 00000000..f9d72b25 --- /dev/null +++ b/AltStoreCore/Components/WebViewController.swift @@ -0,0 +1,356 @@ +// +// WebViewController.swift +// AltStoreCore +// +// Created by Riley Testut on 10/31/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import WebKit +import Combine + +public protocol WebViewControllerDelegate: NSObject +{ + func webViewControllerDidFinish(_ webViewController: WebViewController) +} + +public class WebViewController: UIViewController +{ + //MARK: Public Properties + public weak var delegate: WebViewControllerDelegate? + + // WKWebView used to display webpages + public private(set) var webView: WKWebView! + + public private(set) lazy var backButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.backward"), style: .plain, target: self, action: #selector(WebViewController.goBack(_:))) + public private(set) lazy var forwardButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.forward"), style: .plain, target: self, action: #selector(WebViewController.goForward(_:))) + public private(set) lazy var shareButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(WebViewController.shareLink(_:))) + + public private(set) lazy var reloadButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(WebViewController.refresh(_:))) + public private(set) lazy var stopLoadingButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(WebViewController.refresh(_:))) + + public private(set) lazy var doneButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(WebViewController.dismissWebViewController(_:))) + + //MARK: Private Properties + private let progressView = UIProgressView() + private lazy var refreshButton: UIBarButtonItem = self.reloadButton + + private let initialReqest: URLRequest? + private var ignoreUpdateProgress: Bool = false + private var cancellables: Set = [] + + public required init(request: URLRequest?, configuration: WKWebViewConfiguration = WKWebViewConfiguration()) + { + self.initialReqest = request + + super.init(nibName: nil, bundle: nil) + + self.webView = WKWebView(frame: CGRectZero, configuration: configuration) + self.webView.allowsBackForwardNavigationGestures = true + + self.progressView.progressViewStyle = .bar + self.progressView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.progressView.progress = 0.5 + self.progressView.alpha = 0.0 + self.progressView.isHidden = true + } + + public convenience init(url: URL?, configuration: WKWebViewConfiguration = WKWebViewConfiguration()) + { + if let url + { + let request = URLRequest(url: url) + self.init(request: request, configuration: configuration) + } + else + { + self.init(request: nil, configuration: configuration) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: UIViewController + + public override func loadView() + { + self.preparePipeline() + + if let request = self.initialReqest + { + self.webView.load(request) + } + + self.view = self.webView + } + + public override func viewDidLoad() + { + super.viewDidLoad() + + self.navigationController?.isModalInPresentation = true + self.navigationController?.view.tintColor = .altPrimary + + if let navigationBar = self.navigationController?.navigationBar + { + navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance + } + + if let toolbar = self.navigationController?.toolbar, #available(iOS 15, *) + { + toolbar.scrollEdgeAppearance = toolbar.standardAppearance + } + } + + public override func viewIsAppearing(_ animated: Bool) + { + super.viewIsAppearing(animated) + + if self.webView.estimatedProgress < 1.0 + { + self.transitionCoordinator?.animate(alongsideTransition: { context in + self.showProgressBar(animated: true) + }) { context in + if context.isCancelled + { + self.hideProgressBar(animated: false) + } + } + } + + self.navigationController?.setToolbarHidden(false, animated: false) + + self.update() + } + + public override func viewWillDisappear(_ animated: Bool) + { + super.viewWillDisappear(animated) + + var shouldHideToolbarItems = true + + if let toolbarItems = self.navigationController?.topViewController?.toolbarItems + { + if toolbarItems.count > 0 + { + shouldHideToolbarItems = false + } + } + + if shouldHideToolbarItems + { + self.navigationController?.setToolbarHidden(true, animated: false) + } + + self.transitionCoordinator?.animate(alongsideTransition: { context in + self.hideProgressBar(animated: true) + }) { (context) in + if context.isCancelled && self.webView.estimatedProgress < 1.0 + { + self.showProgressBar(animated: false) + } + } + } + + public override func didMove(toParent parent: UIViewController?) + { + super.didMove(toParent: parent) + + if parent == nil + { + self.webView.stopLoading() + } + } + + deinit + { + self.webView.stopLoading() + } +} + +private extension WebViewController +{ + func preparePipeline() + { + self.webView.publisher(for: \.title, options: [.initial, .new]) + .sink { [weak self] title in + self?.title = title + } + .store(in: &self.cancellables) + + self.webView.publisher(for: \.estimatedProgress, options: [.new]) + .sink { [weak self] progress in + self?.updateProgress(progress) + } + .store(in: &self.cancellables) + + Publishers.Merge3( + self.webView.publisher(for: \.isLoading, options: [.new]), + self.webView.publisher(for: \.canGoBack, options: [.new]), + self.webView.publisher(for: \.canGoForward, options: [.new]) + ) + .sink { [weak self] _ in + self?.update() + } + .store(in: &self.cancellables) + } + + func update() + { + if self.webView.isLoading + { + self.refreshButton = self.stopLoadingButton + } + else + { + self.refreshButton = self.reloadButton + } + + self.backButton.isEnabled = self.webView.canGoBack + self.forwardButton.isEnabled = self.webView.canGoForward + + self.navigationItem.leftBarButtonItem = self.doneButton + self.navigationItem.rightBarButtonItem = self.refreshButton + + self.toolbarItems = [self.backButton, .fixedSpace(70), self.forwardButton, .flexibleSpace(), self.shareButton] + } + + func updateProgress(_ progress: Double) + { + if self.progressView.isHidden + { + self.showProgressBar(animated: true) + } + + if self.ignoreUpdateProgress + { + self.ignoreUpdateProgress = false + self.hideProgressBar(animated: true) + } + else if progress < Double(self.progressView.progress) + { + // If progress is less than self.progressView.progress, another webpage began to load before the first one completed + // In this case, we set the progress back to 0.0, and then wait until the next updateProgress, because it results in a much better animation + + self.progressView.setProgress(0.0, animated: false) + } + else + { + UIView.animate(withDuration: 0.4, animations: { + self.progressView.setProgress(Float(progress), animated: true) + }, completion: { (finished) in + if progress == 1.0 + { + // This delay serves two purposes. One, it keeps the progress bar on screen just a bit longer so it doesn't appear to disappear too quickly. + // Two, it allows us to prevent the progress bar from disappearing if the user actually started loading another webpage before the current one finished loading. + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if self.webView.estimatedProgress == 1.0 + { + self.hideProgressBar(animated: true) + } + } + } + }) + } + } + + func showProgressBar(animated: Bool) + { + let navigationBarBounds = self.navigationController?.navigationBar.bounds ?? .zero + self.progressView.frame = CGRect(x: 0, y: navigationBarBounds.height - self.progressView.bounds.height, width: navigationBarBounds.width, height: self.progressView.bounds.height) + + self.navigationController?.navigationBar.addSubview(self.progressView) + + self.progressView.setProgress(Float(self.webView.estimatedProgress), animated: false) + self.progressView.isHidden = false + + if animated + { + UIView.animate(withDuration: 0.4) { + self.progressView.alpha = 1.0 + } + } + else + { + self.progressView.alpha = 1.0 + } + } + + func hideProgressBar(animated: Bool) + { + if animated + { + UIView.animate(withDuration: 0.4, animations: { + self.progressView.alpha = 0.0 + }, completion: { (finished) in + self.progressView.setProgress(0.0, animated: false) + self.progressView.isHidden = true + self.progressView.removeFromSuperview() + }) + } + else + { + self.progressView.alpha = 0.0 + + // Completion + self.progressView.setProgress(0.0, animated: false) + self.progressView.isHidden = true + self.progressView.removeFromSuperview() + } + } +} + +@objc +private extension WebViewController +{ + func goBack(_ sender: UIBarButtonItem) + { + self.webView.goBack() + } + + func goForward(_ sender: UIBarButtonItem) + { + self.webView.goForward() + } + + func refresh(_ sender: UIBarButtonItem) + { + if self.webView.isLoading + { + self.ignoreUpdateProgress = true + self.webView.stopLoading() + } + else + { + if let initialRequest = self.initialReqest, self.webView.url == nil && self.webView.backForwardList.backList.count == 0 + { + self.webView.load(initialRequest) + } + else + { + self.webView.reload() + } + } + } + + func shareLink(_ sender: UIBarButtonItem) + { + let url = self.webView.url ?? (NSURL() as URL) + + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activityViewController.modalPresentationStyle = .popover + activityViewController.popoverPresentationController?.barButtonItem = sender + self.present(activityViewController, animated: true) + } + + func dismissWebViewController(_ sender: UIBarButtonItem) + { + self.delegate?.webViewControllerDidFinish(self) + + self.parent?.dismiss(animated: true) + } +} diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index 8f22d28f..07e119b8 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -9,6 +9,7 @@ import Foundation import AuthenticationServices import CoreData +import WebKit private let clientID = "my4hpHHG4iVRme6QALnQGlhSBQiKdB_AinrVgPpIpiC-xiHstTYiLKO5vfariFo1" private let clientSecret = "Zow0ggt9YgwIyd4DVLoO9Z02KuuIXW44xhx4lfL27x2u-_u4FE4rYR48bEKREPS5" @@ -61,6 +62,10 @@ public class PatreonAPI: NSObject private let session = URLSession(configuration: .ephemeral) private let baseURL = URL(string: "https://www.patreon.com/")! + private var authHandlers = [(Result) -> Void]() + private var authContinuation: CheckedContinuation? + private weak var webViewController: WebViewController? + private override init() { super.init() @@ -69,19 +74,40 @@ public class PatreonAPI: NSObject public extension PatreonAPI { - func authenticate(completion: @escaping (Result) -> Void) + func authenticate(presentingViewController: UIViewController, completion: @escaping (Result) -> Void) { - var components = URLComponents(string: "/oauth2/authorize")! - components.queryItems = [URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore")] - - let requestURL = components.url(relativeTo: self.baseURL)! - - self.authenticationSession = ASWebAuthenticationSession(url: requestURL, callbackURLScheme: "altstore") { (callbackURL, error) in + Task.detached { @MainActor in + guard self.authHandlers.isEmpty else { + self.authHandlers.append(completion) + return + } + + self.authHandlers.append(completion) + do { - let callbackURL = try Result(callbackURL, error).get() + var components = URLComponents(string: "/oauth2/authorize")! + components.queryItems = [URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore"), + URLQueryItem(name: "scope", value: "identity identity[email] identity.memberships campaigns.posts")] + + let requestURL = components.url(relativeTo: self.baseURL) + + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(self, forURLScheme: "altstore") + configuration.websiteDataStore = .default() + + let webViewController = WebViewController(url: requestURL, configuration: configuration) + webViewController.delegate = self + self.webViewController = webViewController + + let callbackURL = try await withCheckedThrowingContinuation { continuation in + self.authContinuation = continuation + + let navigationController = UINavigationController(rootViewController: webViewController) + presentingViewController.present(navigationController, animated: true) + } guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), @@ -89,26 +115,45 @@ public extension PatreonAPI let code = codeQueryItem.value else { throw PatreonAPIError(.unknown) } - self.fetchAccessToken(oauthCode: code) { (result) in - switch result + let (accessToken, refreshToken) = try await withCheckedThrowingContinuation { continuation in + self.fetchAccessToken(oauthCode: code) { result in + continuation.resume(with: result) + } + } + Keychain.shared.patreonAccessToken = accessToken + Keychain.shared.patreonRefreshToken = refreshToken + + let patreonAccount = try await withCheckedThrowingContinuation { continuation in + self.fetchAccount { result in + let result = result.map { AsyncManaged(wrappedValue: $0) } + continuation.resume(with: result) + } + } + + await self.saveAuthCookies() + + await patreonAccount.perform { patreonAccount in + for callback in self.authHandlers { - case .failure(let error): completion(.failure(error)) - case .success((let accessToken, let refreshToken)): - Keychain.shared.patreonAccessToken = accessToken - Keychain.shared.patreonRefreshToken = refreshToken - - self.fetchAccount(completion: completion) + callback(.success(patreonAccount)) } } } catch { - completion(.failure(error)) + for callback in self.authHandlers + { + callback(.failure(error)) + } + } + + self.authHandlers = [] + + await MainActor.run { + self.webViewController?.dismiss(animated: true) + self.webViewController = nil } } - - self.authenticationSession?.presentationContextProvider = self - self.authenticationSession?.start() } func fetchAccount(completion: @escaping (Result) -> Void) @@ -208,7 +253,10 @@ public extension PatreonAPI Keychain.shared.patreonRefreshToken = nil Keychain.shared.patreonAccountID = nil - completion(.success(())) + Task.detached { + await self.deleteAuthCookies() + completion(.success(())) + } } catch { @@ -242,6 +290,56 @@ public extension PatreonAPI } } +extension PatreonAPI +{ + private func saveAuthCookies() async + { + let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor + + let cookies = await cookieStore.allCookies() + for cookie in cookies where cookie.domain.lowercased().hasSuffix("patreon.com") + { + Logger.main.debug("Saving Patreon cookie \(cookie.name, privacy: .public): \(cookie.value, privacy: .private(mask: .hash)) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))") + HTTPCookieStorage.shared.setCookie(cookie) + } + } + + public func deleteAuthCookies() async + { + Logger.main.info("Clearing Patreon cookie cache...") + + let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor + + if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.patreon.com")!) + { + for cookie in cookies + { + Logger.main.debug("Deleting Patreon cookie \(cookie.name, privacy: .public) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))") + + await cookieStore.deleteCookie(cookie) + HTTPCookieStorage.shared.deleteCookie(cookie) + } + + Logger.main.info("Cleared Patreon cookie cache!") + } + else + { + Logger.main.info("No Patreon cookies to clear.") + } + } +} + +extension PatreonAPI: WebViewControllerDelegate +{ + public func webViewControllerDidFinish(_ webViewController: WebViewController) + { + guard let authContinuation else { return } + self.authContinuation = nil + + authContinuation.resume(throwing: CancellationError()) + } +} + private extension PatreonAPI { func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void) @@ -369,25 +467,25 @@ private extension PatreonAPI } } -extension PatreonAPI: ASWebAuthenticationPresentationContextProviding +extension PatreonAPI: WKURLSchemeHandler { - public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor + public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - //TODO: Properly support multiple scenes. - - guard let windowScene = UIApplication.alt_shared?.connectedScenes.lazy.compactMap({ $0 as? UIWindowScene }).first else { return UIWindow() } - - if #available(iOS 15, *), let keyWindow = windowScene.keyWindow + guard let authContinuation else { return } + self.authContinuation = nil + + if let callbackURL = urlSchemeTask.request.url { - return keyWindow + authContinuation.resume(returning: callbackURL) } - else if let delegate = windowScene.delegate as? UIWindowSceneDelegate, - let optionalWindow = delegate.window, - let window = optionalWindow + else { - return window + authContinuation.resume(throwing: URLError(.badURL)) } - - return UIWindow() + } + + public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) + { + Logger.main.debug("WKWebView stopped handling url scheme.") } }