diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index d198f5418f5..29b171f443a 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1664,6 +1664,9 @@ extension Auth: AuthInterop { settings = AuthSettings() GULAppDelegateSwizzler.proxyOriginalDelegateIncludingAPNSMethods() GULSceneDelegateSwizzler.proxyOriginalSceneDelegate() + #elseif os(macOS) + authURLPresenter = AuthURLPresenter() + settings = AuthSettings() #endif requestConfiguration = AuthRequestConfiguration(apiKey: apiKey, appID: app.options.googleAppID, @@ -2370,6 +2373,11 @@ extension Auth: AuthInterop { /// An object that takes care of presenting URLs via the auth instance. var authURLPresenter: AuthWebViewControllerDelegate + #elseif os(macOS) + + /// An object that takes care of presenting URLs via the auth instance. + var authURLPresenter: AuthWebViewControllerDelegate + #endif // TARGET_OS_IOS // MARK: Private properties diff --git a/FirebaseAuth/Sources/Swift/AuthProvider/OAuthProvider.swift b/FirebaseAuth/Sources/Swift/AuthProvider/OAuthProvider.swift index 761f57cf5d1..0db655e14e4 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/OAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/OAuthProvider.swift @@ -320,6 +320,102 @@ import Foundation } #endif + #if os(macOS) + /// Used to obtain an auth credential via a web flow on macOS. + /// + /// This method is available on macOS only. + /// - Parameter uiDelegate: An optional UI delegate used to present the web flow. + /// - Parameter completion: Optionally; a block which is invoked asynchronously on the main + /// thread when the web flow is completed. + open func getCredentialWith(_ uiDelegate: AuthUIDelegate?, + completion: ((AuthCredential?, Error?) -> Void)? = nil) { + guard let urlTypes = auth.mainBundleUrlTypes, + AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: callbackScheme, + urlTypes: urlTypes) else { + fatalError( + "Please register custom URL scheme \(callbackScheme) in the app's Info.plist file." + ) + } + kAuthGlobalWorkQueue.async { [weak self] in + guard let self = self else { return } + let eventID = AuthWebUtils.randomString(withLength: 10) + let sessionID = AuthWebUtils.randomString(withLength: 10) + + let callbackOnMainThread: ((AuthCredential?, Error?) -> Void) = { credential, error in + if let completion { + DispatchQueue.main.async { + completion(credential, error) + } + } + } + Task { + do { + guard let headfulLiteURL = try await self.getHeadfulLiteUrl(eventID: eventID, + sessionID: sessionID) else { + fatalError( + "FirebaseAuth Internal Error: Both error and headfulLiteURL return are nil" + ) + } + let callbackMatcher: (URL?) -> Bool = { callbackURL in + AuthWebUtils.isExpectedCallbackURL(callbackURL, + eventID: eventID, + authType: "signInWithRedirect", + callbackScheme: self.callbackScheme) + } + self.auth.authURLPresenter.present(headfulLiteURL, + uiDelegate: uiDelegate, + callbackMatcher: callbackMatcher) { callbackURL, error in + if let error { + callbackOnMainThread(nil, error) + return + } + guard let callbackURL else { + fatalError("FirebaseAuth Internal Error: Both error and callbackURL return are nil") + } + let (oAuthResponseURLString, error) = self.oAuthResponseForURL(url: callbackURL) + if let error { + callbackOnMainThread(nil, error) + return + } + guard let oAuthResponseURLString else { + fatalError( + "FirebaseAuth Internal Error: Both error and oAuthResponseURLString return are nil" + ) + } + let credential = OAuthCredential(withProviderID: self.providerID, + sessionID: sessionID, + OAuthResponseURLString: oAuthResponseURLString) + callbackOnMainThread(credential, nil) + } + } catch { + callbackOnMainThread(nil, error) + } + } + } + } + + /// Used to obtain an auth credential via a web flow on macOS. + /// This method is available on macOS only. + /// - Parameter uiDelegate: An optional UI delegate used to present the web flow. + /// - Parameter completionHandler: Optionally; a block which is invoked + /// asynchronously on the main thread when the web flow is + /// completed. + @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *) + @objc(getCredentialWithUIDelegate:completion:) + @MainActor + open func credential(with uiDelegate: AuthUIDelegate?) async throws -> AuthCredential { + return try await withCheckedThrowingContinuation { continuation in + getCredentialWith(uiDelegate) { credential, error in + if let credential = credential { + continuation.resume(returning: credential) + } else { + continuation.resume(throwing: error!) // TODO: Change to ?? and generate unknown error + } + } + } + } + #endif + /// Creates an `AuthCredential` for the Sign in with Apple OAuth 2 provider identified by ID /// token, raw nonce, and full name.This method is specific to the Sign in with Apple OAuth 2 /// provider as this provider requires the full name to be passed explicitly. diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthDefaultUIDelegate.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthDefaultUIDelegate.swift index 4106977a5e1..aa6420ccdab 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthDefaultUIDelegate.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthDefaultUIDelegate.swift @@ -87,4 +87,85 @@ private let viewController: UIViewController? } + +#elseif os(macOS) + + import AppKit + import Foundation + #if COCOAPODS + internal import GoogleUtilities + #else + internal import GoogleUtilities_Environment + #endif + + /// Custom window class for OAuth flow + final class AuthWebWindow: NSWindow { + weak var authViewController: NSViewController? + + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, + backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + setupWindow() + } + + private func setupWindow() { + title = "Sign In" + isReleasedWhenClosed = false + center() + level = .floating + styleMask = [.titled, .closable, .miniaturizable, .resizable] + } + + override func close() { + if let authVC = authViewController as? AuthWebViewController { + authVC.handleWindowClose() + } + + super.close() + } + } + + /// Class responsible for providing a default AuthUIDelegate. + /// + /// This class should be used in the case that a UIDelegate was expected and necessary to + /// continue a given flow, but none was provided. + final class AuthDefaultUIDelegate: NSObject, AuthUIDelegate { + private var authWindow: AuthWebWindow? + + /// Returns a default AuthUIDelegate object. + /// - Returns: The default AuthUIDelegate object. + @MainActor static func defaultUIDelegate() -> AuthUIDelegate? { + if GULAppEnvironmentUtil.isAppExtension() { + // macOS App extensions should not access NSApplication.shared. + return nil + } + + return AuthDefaultUIDelegate() + } + + func present(_ viewControllerToPresent: NSViewController, + completion: (() -> Void)? = nil) { + // Create a new window for the OAuth flow + let windowRect = NSRect(x: 0, y: 0, width: 800, height: 600) + authWindow = AuthWebWindow( + contentRect: windowRect, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + authWindow?.authViewController = viewControllerToPresent + authWindow?.contentViewController = viewControllerToPresent + authWindow?.makeKeyAndOrderFront(nil) + + completion?() + } + + func dismiss(completion: (() -> Void)? = nil) { + authWindow?.close() + authWindow = nil + completion?() + } + } + #endif diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthUIDelegate.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthUIDelegate.swift index 94025574ab4..305d39f72c4 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthUIDelegate.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthUIDelegate.swift @@ -53,4 +53,43 @@ return dismiss(animated: flag, completion: nil) } } + +#elseif os(macOS) + + import AppKit + import Foundation + + /// A protocol to handle user interface interactions for Firebase Auth. + /// + /// This protocol is available on macOS only. + @objc(FIRAuthUIDelegate) public protocol AuthUIDelegate: NSObjectProtocol { + /// If implemented, this method will be invoked when Firebase Auth needs to display a view + /// controller. + /// - Parameter viewControllerToPresent: The view controller to be presented. + /// - Parameter completion: The block to execute after the presentation finishes. + /// This block has no return value and takes no parameters. + @objc(presentViewController:completion:) + func present(_ viewControllerToPresent: NSViewController, + completion: (() -> Void)?) + + /// If implemented, this method will be invoked when Firebase Auth needs to dismiss a view + /// controller. + /// - Parameter completion: The block to execute after the dismissal finishes. + /// This block has no return value and takes no parameters. + @objc(dismissViewControllerWithCompletion:) + func dismiss(completion: (() -> Void)?) + } + + // Extension to support default argument variations. + extension AuthUIDelegate { + func present(_ viewControllerToPresent: NSViewController, + completion: (() -> Void)? = nil) { + return present(viewControllerToPresent, completion: nil) + } + + func dismiss(completion: (() -> Void)? = nil) { + return dismiss(completion: nil) + } + } + #endif diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthURLPresenter.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthURLPresenter.swift index c59ced1cfe1..d44dde01bf5 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthURLPresenter.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthURLPresenter.swift @@ -186,4 +186,147 @@ } } } + +#elseif os(macOS) + + import AppKit + import Foundation + import WebKit + + /// A Class responsible for presenting URL via WKWebView on macOS. + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + class AuthURLPresenter: NSObject, AuthWebViewControllerDelegate { + /// Presents an URL to interact with user. + /// - Parameter url: The URL to present. + /// - Parameter uiDelegate: The UI delegate to present view controller. + /// - Parameter completion: A block to be called either synchronously if the presentation fails + /// to start, or asynchronously in future on an unspecified thread once the presentation + /// finishes. + func present(_ url: URL, + uiDelegate: AuthUIDelegate?, + callbackMatcher: @escaping (URL?) -> Bool, + completion: @escaping (URL?, Error?) -> Void) { + if isPresenting { + // Unable to start a new presentation on top of another. + // Invoke the new completion closure and leave the old one as-is + // to be invoked when the presentation finishes. + DispatchQueue.main.async { + completion(nil, AuthErrorUtils.webContextCancelledError(message: nil)) + } + return + } + isPresenting = true + self.callbackMatcher = callbackMatcher + self.completion = completion + DispatchQueue.main.async { + self.uiDelegate = uiDelegate ?? AuthDefaultUIDelegate.defaultUIDelegate() + self.webViewController = AuthWebViewController(url: url, delegate: self) + if let webViewController = self.webViewController { + if let fakeUIDelegate = self.fakeUIDelegate { + fakeUIDelegate.present(webViewController) + } else { + self.uiDelegate?.present(webViewController) + } + } + } + } + + /// Determines if a URL was produced by the currently presented URL. + /// - Parameter url: The URL to handle. + /// - Returns: Whether the URL could be handled or not. + func canHandle(url: URL) -> Bool { + if isPresenting, + let callbackMatcher = callbackMatcher, + callbackMatcher(url) { + finishPresentation(withURL: url, error: nil) + return true + } + return false + } + + // MARK: AuthWebViewControllerDelegate + + func webViewControllerDidCancel(_ controller: AuthWebViewController) { + kAuthGlobalWorkQueue.async { + if self.webViewController == controller { + self.finishPresentation(withURL: nil, + error: AuthErrorUtils.webContextCancelledError(message: nil)) + } + } + } + + func webViewController(_ controller: AuthWebViewController, canHandle url: URL) -> Bool { + var result = false + kAuthGlobalWorkQueue.sync { + if self.webViewController == controller { + result = self.canHandle(url: url) + } + } + return result + } + + func webViewController(_ controller: AuthWebViewController, + didFailWithError error: Error) { + kAuthGlobalWorkQueue.async { + if self.webViewController == controller { + self.finishPresentation(withURL: nil, error: error) + } + } + } + + /// Whether or not some web-based content is being presented. + /// + /// Accesses to this property are serialized on the global Auth work queue + /// and thus this variable should not be read or written outside of the work queue. + private var isPresenting: Bool = false + + /// The callback URL matcher for the current presentation, if one is active. + private var callbackMatcher: ((URL) -> Bool)? + + /// The `AuthWebViewController` used for the current presentation, if any. + private var webViewController: AuthWebViewController? + + /// The UIDelegate used to present the view controller. + var uiDelegate: AuthUIDelegate? + + /// The completion handler for the current presentation, if one is active. + /// + /// Accesses to this variable are serialized on the global Auth work queue + /// and thus this variable should not be read or written outside of the work queue. + /// + /// This variable is also used as a flag to indicate a presentation is active. + var completion: ((URL?, Error?) -> Void)? + + /// Test-only option to validate the calls to the uiDelegate. + var fakeUIDelegate: AuthUIDelegate? + + // MARK: Private methods + + private func finishPresentation(withURL url: URL?, error: Error?) { + callbackMatcher = nil + let uiDelegate = self.uiDelegate + self.uiDelegate = nil + let completion = self.completion + self.completion = nil + let webViewController = self.webViewController + self.webViewController = nil + + if webViewController != nil { + DispatchQueue.main.async { + uiDelegate?.dismiss { + self.isPresenting = false + if let completion { + completion(url, error) + } + } + } + } else { + isPresenting = false + if let completion { + completion(url, error) + } + } + } + } + #endif diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthWebView.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthWebView.swift index 8950052112e..b48a3b97ad2 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthWebView.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthWebView.swift @@ -73,4 +73,64 @@ return UIActivityIndicatorView(style: .medium) } } + +#elseif os(macOS) + + import AppKit + import WebKit + + /// A class responsible for creating a WKWebView for use within Firebase Auth. + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + class AuthWebView: NSView { + lazy var webView: WKWebView = createWebView() + lazy var spinner: NSProgressIndicator = createSpinner() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.white.cgColor + initializeSubviews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func initializeSubviews() { + // The order of the following controls z-order. + addSubview(webView) + addSubview(spinner) + + layoutSubviews() + webView = webView + spinner = spinner + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + layoutSubviews() + } + + private func layoutSubviews() { + let height = bounds.size.height + let width = bounds.size.width + webView.frame = NSRect(x: 0, y: 0, width: width, height: height) + spinner.frame = NSRect(x: (width - 32) / 2, y: (height - 32) / 2, width: 32, height: 32) + } + + private func createWebView() -> WKWebView { + let webView = WKWebView(frame: .zero) + webView.wantsLayer = true + return webView + } + + private func createSpinner() -> NSProgressIndicator { + let spinner = NSProgressIndicator() + spinner.style = .spinning + spinner.isIndeterminate = true + return spinner + } + } + #endif diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthWebViewController.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthWebViewController.swift index ee954b0029f..82e6632d9f8 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthWebViewController.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthWebViewController.swift @@ -129,4 +129,119 @@ delegate?.webViewController(self, didFailWithError: error) } } + +#elseif os(macOS) + + import AppKit + import Foundation + import WebKit + + /// Defines a delegate for AuthWebViewController + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + protocol AuthWebViewControllerDelegate: AnyObject { + /// Notifies the delegate that the web view controller is being cancelled by the user. + /// - Parameter webViewController: The web view controller in question. + func webViewControllerDidCancel(_ controller: AuthWebViewController) + + /// Determines if a URL should be handled by the delegate. + /// - Parameter url: The URL to handle. + /// - Returns: Whether the URL could be handled or not. + func webViewController(_ controller: AuthWebViewController, canHandle url: URL) -> Bool + + /// Notifies the delegate that the web view controller failed to load a page. + /// - Parameter webViewController: The web view controller in question. + /// - Parameter error: The error that has occurred. + func webViewController(_ controller: AuthWebViewController, didFailWithError error: Error) + + /// Presents an URL to interact with user. + /// - Parameter url: The URL to present. + /// - Parameter uiDelegate: The UI delegate to present view controller. + /// - Parameter completion: A block to be called either synchronously if the presentation fails + /// to start, or asynchronously in future on an unspecified thread once the presentation + /// finishes. + func present(_ url: URL, + uiDelegate: AuthUIDelegate?, + callbackMatcher: @escaping (URL?) -> Bool, + completion: @escaping (URL?, Error?) -> Void) + } + + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + class AuthWebViewController: NSViewController, + WKNavigationDelegate { + // MARK: - Properties + + private var url: URL + weak var delegate: AuthWebViewControllerDelegate? + private weak var webView: AuthWebView? + + // MARK: - Initialization + + init(url: URL, delegate: AuthWebViewControllerDelegate) { + self.url = url + self.delegate = delegate + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func loadView() { + let webView = AuthWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + webView.webView.navigationDelegate = self + view = webView + self.webView = webView + } + + override func viewDidAppear() { + super.viewDidAppear() + webView?.webView.load(URLRequest(url: url)) + } + + // MARK: - Public Methods + + func handleWindowClose() { + // Called when user closes the window manually using standard macOS controls + delegate?.webViewControllerDidCancel(self) + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction) async + -> WKNavigationActionPolicy { + _ = delegate?.webViewController( + self, + canHandle: navigationAction.request.url ?? url + ) + return .allow + } + + func webView(_ webView: WKWebView, + didStartProvisionalNavigation navigation: WKNavigation!) { + self.webView?.spinner.isHidden = false + self.webView?.spinner.startAnimation(nil) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.webView?.spinner.isHidden = true + self.webView?.spinner.stopAnimation(nil) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, + withError error: Error) { + if (error as NSError).domain == NSURLErrorDomain, + (error as NSError).code == NSURLErrorCancelled { + // It's okay for the page to be redirected before it is completely loaded. See b/32028062 . + return + } + // Forward notification to our delegate. + self.webView(webView, didFinish: navigation) + delegate?.webViewController(self, didFailWithError: error) + } + } + #endif