From 904f5a1f23a0893c86a3d3b8ab5361f0a1175fc5 Mon Sep 17 00:00:00 2001 From: Gil Shapira Date: Sun, 29 Dec 2024 13:52:24 +0200 Subject: [PATCH] Add DescopeFlowHook for customizing how flow pages look and behave --- src/flows/Flow.swift | 49 ++-- src/flows/FlowBridge.swift | 105 +++---- src/flows/FlowCoordinator.swift | 245 ++++++++++++---- src/flows/FlowHook.swift | 436 +++++++++++++++++++++++++++++ src/flows/FlowView.swift | 156 ++++++----- src/flows/FlowViewController.swift | 100 +++++-- src/internal/others/Internal.swift | 8 + 7 files changed, 897 insertions(+), 202 deletions(-) create mode 100644 src/flows/FlowHook.swift diff --git a/src/flows/Flow.swift b/src/flows/Flow.swift index cc812cd..d67a879 100644 --- a/src/flows/Flow.swift +++ b/src/flows/Flow.swift @@ -1,6 +1,4 @@ -#if os(iOS) - import Foundation /// The state of the flow or presenting object. @@ -23,24 +21,28 @@ public enum DescopeFlowState: String { /// A helper object that encapsulates a single flow run for authenticating a user. /// -/// You can use Descope Flows as a visual no-code interface to build screens and authentication -/// flows for common user interactions with your application. +/// You can use Descope Flows as a visual no-code interface to build screens and +/// authentication flows for common user interactions with your application. /// /// Flows are hosted on a webpage and are run by creating an instance of -/// ``DescopeFlowViewController``, ``DescopeFlowView``, or ``DescopeFlowCoordinator`` and -/// calling `start(flow:)`. +/// ``DescopeFlowViewController``, ``DescopeFlowView``, or ``DescopeFlowCoordinator`` +/// and calling `start(flow:)`. /// /// There are some preliminary setup steps you might need to do: /// -/// - As a prerequisite, the flow itself must be created and hosted somewhere on the web. You can -/// either host it on your own web server or use Descope's auth hosting. Read more [here](https://docs.descope.com/auth-hosting-app). +/// - As a prerequisite, the flow itself must be created and hosted somewhere on +/// the web. You can either host it on your own web server or use Descope's +/// auth hosting. Read more [here](https://docs.descope.com/auth-hosting-app). /// -/// - You should configure any required Descope authentication methods in the [Descope console](https://app.descope.com/settings/authentication) -/// before making use of them in a Descope Flow. Some of the default configurations might work -/// well enough to start with, but it is likely that some changes will be needed before release. +/// - You should configure any required Descope authentication methods in the +/// [Descope console](https://app.descope.com/settings/authentication) before +/// making use of them in a Descope Flow. Some of the default configurations +/// might work well enough to start with, but it is likely that some changes +/// will be needed before release. /// -/// - For flows that use `Magic Link` authentication you will need to set up [Universal Links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) -/// in your app. See the documentation for ``resume(with:)`` for more details. +/// - For flows that use `Magic Link` authentication you will need to set up +/// [Universal Links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) +/// in your app. See the documentation for ``Descope for more details. /// /// - You can leverage the native `Sign in with Apple` automatically for flows that use `OAuth` /// by setting the ``oauthProvider`` property and configuring native OAuth in your app. See the @@ -50,7 +52,13 @@ public enum DescopeFlowState: String { @MainActor public class DescopeFlow { /// The URL where the flow is hosted. - public let url: URL + public let url: String + + /// A list of hooks that customize how the flow webpage looks or behaves. + /// + /// You can use the built-in hooks or create custom ones. See the documentation + /// for ``DescopeFlowHook`` for more details. + public var hooks: [DescopeFlowHook] = [] /// An optional instance of ``DescopeSDK`` to use for running the flow. /// @@ -71,7 +79,7 @@ public class DescopeFlow { /// You only need to set this if you explicitly want to override whichever URL is /// configured in the flow or in the Descope project, perhaps because the app cannot /// be configured for universal links using the same redirect URL as on the web. - public var magicLinkRedirect: URL? + public var magicLinkRedirect: String? /// An optional timeout interval to set on the `URLRequest` object used for loading /// the flow webpage. If this is not set the platform default value is be used. @@ -80,9 +88,16 @@ public class DescopeFlow { /// Creates a new ``DescopeFlow`` object that encapsulates a single flow run. /// /// - Parameter url: The URL where the flow is hosted. - public init(url: URL) { + public init(url: String) { self.url = url } + + /// Creates a new ``DescopeFlow`` object that encapsulates a single flow run. + /// + /// - Parameter url: The URL where the flow is hosted. + public init(url: URL) { + self.url = url.absoluteString + } } extension DescopeFlow: CustomStringConvertible { @@ -93,5 +108,3 @@ extension DescopeFlow: CustomStringConvertible { return "DescopeFlow(url: \"\(url)\")" } } - -#endif diff --git a/src/flows/FlowBridge.swift b/src/flows/FlowBridge.swift index a770f40..4e55116 100644 --- a/src/flows/FlowBridge.swift +++ b/src/flows/FlowBridge.swift @@ -1,7 +1,4 @@ -#if os(iOS) - -import UIKit import WebKit @MainActor @@ -30,8 +27,10 @@ enum FlowBridgeResponse { @MainActor class FlowBridge: NSObject { - var logger: DescopeLogger? = Descope.sdk.config.logger + /// The coordinator sets a logger automatically. + var logger: DescopeLogger? + /// The coordinator sets itself as the bridge delegate. weak var delegate: FlowBridgeDelegate? /// This property is weak since the bridge is not considered the "owner" of the webview, and in @@ -48,14 +47,13 @@ class FlowBridge: NSObject { } } + /// Injects the JavaScript code below that's required for the bridge to work, as well as + /// handlers for messages sent from the webpage to the bridge. func prepare(configuration: WKWebViewConfiguration) { let setup = WKUserScript(source: setupScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) configuration.userContentController.addUserScript(setup) - let zoom = WKUserScript(source: zoomScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false) - configuration.userContentController.addUserScript(zoom) - - if #available(iOS 17.0, *) { + if #available(iOS 17.0, macOS 14.0, *) { configuration.preferences.inactiveSchedulingPolicy = .none } @@ -63,13 +61,24 @@ class FlowBridge: NSObject { configuration.userContentController.add(self, name: name.rawValue) } } +} +extension FlowBridge { + /// Called by the coordinator once the flow is ready to configure native specific options func set(oauthProvider: String?, magicLinkRedirect: String?) { - webView?.callJavaScript(function: "set", params: oauthProvider ?? "", magicLinkRedirect ?? "") + call(function: "set", params: oauthProvider ?? "", magicLinkRedirect ?? "") } + /// Called by the coordinator when it's done handling a bridge request func send(response: FlowBridgeResponse) { - webView?.callJavaScript(function: "send", params: response.type, response.payload) + call(function: "send", params: response.type, response.payload) + } + + /// Helper method to run one of the namespaced functions with escaped string parameters + private func call(function: String, params: String...) { + let escaped = params.map { $0.javaScriptLiteralString() }.joined(separator: ", ") + let javascript = "\(namespace)_\(function)(\(escaped))" + webView?.evaluateJavaScript(javascript) } } @@ -185,6 +194,30 @@ extension FlowBridge: WKUIDelegate { } } +extension FlowBridge { + func addStyles(_ css: String) { + runJavaScript(""" + const styles = \(css.javaScriptLiteralString()) + const element = document.createElement('style') + element.textContent = styles + document.head.appendChild(element) + """) + } + + func runJavaScript(_ code: String) { + let javascript = anonymousFunction(body: code) + webView?.evaluateJavaScript(javascript) + } + + private func anonymousFunction(body: String) -> String { + return """ + (function() { + \(body) + })() + """ + } +} + private enum FlowBridgeMessage: String, CaseIterable { case log, ready, bridge, failure, success } @@ -260,16 +293,21 @@ private extension FlowBridgeResponse { } private extension WKWebView { - func callJavaScript(function: String, params: String...) { - let escaped = params.map(escapeWithBackticks).joined(separator: ", ") - let javascript = "\(namespace)_\(function)(\(escaped))" - evaluateJavaScript(javascript) - } - - private func escapeWithBackticks(_ str: String) -> String { - return "`" + str.replacingOccurrences(of: #"\"#, with: #"\\"#) - .replacingOccurrences(of: #"$"#, with: #"\$"#) - .replacingOccurrences(of: #"`"#, with: #"\`"#) + "`" + /// Custom asynchronous version of evaluateJavaScript to work around bug in + /// the `WKWebView` method implementation that crashes when the js code doesn't + /// return any value (or returns an explicit `undefined`). + func evaluateJavaScriptSafeAsync(_ javaScriptString: String) async throws -> Any? { + return try await withCheckedThrowingContinuation { continuation in + evaluateJavaScript(javaScriptString, completionHandler: { value, error in + Task { @MainActor in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: value) + } + } + }) + } } } @@ -306,17 +344,6 @@ function \(namespace)_find() { // Attaches event listeners once the Descope web-component is found function \(namespace)_prepare(component) { - const styles = ` - * { - -webkit-touch-callout: none; - -webkit-user-select: none; - } - ` - - const stylesheet = document.createElement('style') - stylesheet.textContent = styles - document.head.appendChild(stylesheet) - component.nativeOptions = { platform: 'ios', bridgeVersion: 1, @@ -377,19 +404,3 @@ function \(namespace)_send(type, payload) { \(namespace)_initialize() """ - -/// Disables two finger and double tap zooming -private let zoomScript = """ - -function \(namespace)_zoom() { - const viewport = document.createElement('meta') - viewport.name = 'viewport' - viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' - document.head.appendChild(viewport) -} - -\(namespace)_zoom() - -""" - -#endif diff --git a/src/flows/FlowCoordinator.swift b/src/flows/FlowCoordinator.swift index fba43ed..a4614ae 100644 --- a/src/flows/FlowCoordinator.swift +++ b/src/flows/FlowCoordinator.swift @@ -1,77 +1,129 @@ -#if os(iOS) - -import UIKit import WebKit /// A set of delegate methods for events about the flow running in a ``DescopeFlowCoordinator``. @MainActor public protocol DescopeFlowCoordinatorDelegate: AnyObject { + /// Called directly after the flow state is updated. + /// + /// Where appropriate, this delegate method is always called before other delegate methods. + /// For example, if there's an error in the flow this method is called first to report the + /// state change to ``DescopeFlowState/failed`` and then the failure delegate method is + /// called with the specific ``DescopeError`` value. func coordinatorDidUpdateState(_ coordinator: DescopeFlowCoordinator, to state: DescopeFlowState, from previous: DescopeFlowState) + + /// Called when the flow is fully loaded and rendered and the view can be displayed. + /// + /// You can use this method to show a loading state until the flow is fully loaded, + /// and do a quick animatad transition to show the flow once this method is called. func coordinatorDidBecomeReady(_ coordinator: DescopeFlowCoordinator) + + /// Called when the user taps on a web link in the flow. + /// + /// The `external` parameter is `true` if the link is configured to open a new + /// browser tab or window in a regular browser app. + /// + /// If your flow doesn't show any web links you can either use an empty implementation + /// or simply call `UIApplication.shared.open(url)` so that links open in the user's + /// default browser app. func coordinatorDidInterceptNavigation(_ coordinator: DescopeFlowCoordinator, url: URL, external: Bool) - func coordinatorDidFailAuthentication(_ coordinator: DescopeFlowCoordinator, error: DescopeError) - func coordinatorDidFinishAuthentication(_ coordinator: DescopeFlowCoordinator, response: AuthenticationResponse) + + /// Called when an error occurs in the flow. + /// + /// The most common failures are due to internet issues, in which case the `error` will + /// usually be ``DescopeError/networkError``. + func coordinatorDidFail(_ coordinator: DescopeFlowCoordinator, error: DescopeError) + + /// Called when the flow completes the authentication successfully. + /// + /// The `response` parameter can be used to create a ``DescopeSession`` as with other + /// authentication methods. + func coordinatorDidFinish(_ coordinator: DescopeFlowCoordinator, response: AuthenticationResponse) } /// A helper class for running Descope Flows. /// -/// You can create an instance of ``DescopeFlowCoordinator``, attach a `WKWebView` by -/// setting the ``webView`` property, and then call ``start(flow:)``. In almost any -/// situation though it would be more convenient to use a ``DescopeFlowViewController`` -/// ot a ``DescopeFlowView`` instead. +/// You can use a ``DescopeFlowCoordinator`` to run a flow in a `WKWebView` that was created +/// manually and attached to the coordinator, but in almost all scenarios it should be more +/// convenient to use a ``DescopeFlowViewController`` or a ``DescopeFlowView`` instead. +/// +/// To start a flow in a ``DescopeFlowCoordinator``, first create a `WKWebViewConfiguration` +/// object and bootstrap it by calling the coordinator's ``prepare(configuration:)`` method. +/// Create an instance of `WKWebView` and pass the bootstrapped configuration object to the +/// initializer. Attach the webview to the coordinator by setting the ``webView`` property, +/// and finally call the ``start(flow:)`` function. @MainActor public class DescopeFlowCoordinator { - private let bridge: FlowBridge - - private var logger: DescopeLogger? + /// A delegate object for receiving events about the state of the flow. public weak var delegate: DescopeFlowCoordinatorDelegate? + /// The flow that's currently running in the ``DescopeFlowCoordinator``. + public private(set) var flow: DescopeFlow? { + didSet { + sdk.resume = resumeClosure + logger = sdk.config.logger + bridge.logger = logger + } + } + + /// The current state of the flow in the ``DescopeFlowCoordinator``. public private(set) var state: DescopeFlowState = .initial { didSet { delegate?.coordinatorDidUpdateState(self, to: state, from: oldValue) } } + /// The instance of `WKWebView` that was attached to the coordinator. + /// + /// When using a ``DescopeFlowView`` or ``DescopeFlowViewController`` this property + /// is set automatically to the webview created by them. public var webView: WKWebView? { didSet { bridge.webView = webView + updateLayoutObserver() } } + // Initialization + + private let bridge: FlowBridge + + private var logger: DescopeLogger? + + /// Creates a new ``DescopeFlowCoordinator`` object. public init() { bridge = FlowBridge() bridge.delegate = self } + /// This method must be called on the `WKWebViewConfiguration` instance that's used + /// when calling the initializer when creating this coordinator's `WKWebView`. public func prepare(configuration: WKWebViewConfiguration) { bridge.prepare(configuration: configuration) } // Flow - private var flow: DescopeFlow? { - didSet { - sdk.resume = resumeClosure - logger = flow?.config.logger - bridge.logger = logger - } - } - + /// Loads and displays a Descope Flow. + /// + /// The ``delegate`` property should be set before calling this function to ensure + /// no delegate updates are missed. public func start(flow: DescopeFlow) { - logger(.info, "Starting flow authentication", flow) #if DEBUG - precondition(flow.config.projectId != "", "The Descope singleton must be setup or an instance of DescopeSDK must be set on the flow") + precondition(webView != nil, "The flow coordinator's webView property must be set before starting the flow") + precondition(sdk.config.projectId != "", "The Descope singleton must be setup or an instance of DescopeSDK must be set on the flow") #endif + logger(.info, "Starting flow authentication", flow) self.flow = flow + handleStarted() - state = .started loadURL(flow.url) } - private func loadURL(_ url: URL) { + private func loadURL(_ url: String) { + let url = URL(string: url) ?? URL(string: "invalid://")! var request = URLRequest(url: url) if let timeout = flow?.requestTimeoutInterval { request.timeoutInterval = timeout @@ -79,6 +131,72 @@ public class DescopeFlowCoordinator { webView?.load(request) } + private var sdk: DescopeSDK { + return flow?.descope ?? Descope.sdk + } + + // WebView + + /// Adds the specified raw CSS to the flow page. + /// + /// ```swift + /// func updateMargins() { + /// flowCoordinator.addStyles("body { margin: 16px; }") + /// } + /// ``` + /// + /// - Parameter css: The raw CSS to add, e.g., `".footer { display: none; }"`. + public func addStyles(_ css: String) { + bridge.addStyles(css) + } + + /// Runs the specified JavaScript code on the flow page. + /// + /// The code is implicitly wrapped in an immediately invoked function expression, so you + /// can safely declare variables and not worry about polluting the global namespace. + /// + /// ```swift + /// func removeFooter() { + /// flowCoordinator.runJavaScript(""" + /// const footer = document.querySelector('#footer') + /// footer?.remove() + /// """) + /// } + /// ``` + /// + /// - Parameter code: The JavaScript code to run, e.g., `"console.log('Hello world')"`. + public func runJavaScript(_ code: String) { + bridge.runJavaScript(code) + } + + // Hooks + + private func executeHooks(event: DescopeFlowHook.Event) { + var hooks = DescopeFlowHook.defaults + if let flow { + hooks.append(contentsOf: flow.hooks) + } + for hook in hooks where hook.events.contains(event) { + hook.execute(event: event, coordinator: self) + } + } + + // Layout + + private var layoutObserver: WebViewLayoutObserver? + + private func updateLayoutObserver() { + if let webView { + layoutObserver = WebViewLayoutObserver(webView: webView, handler: { [weak self] in self?.handleLayoutChange() }) + } else { + layoutObserver = nil + } + } + + private func handleLayoutChange() { + executeHooks(event: .layout) + } + // State private func ensureState(_ states: DescopeFlowState...) -> Bool { @@ -112,23 +230,27 @@ public class DescopeFlowCoordinator { // Events - private func handleFailure(_ error: Error) { - guard ensureState(.started, .ready, .failed) else { return } + private func handleStarted() { + guard ensureState(.initial) else { return } + state = .started + executeHooks(event: .started) + } - // we allow multiple failure events and swallow them here instead of showing a warning above, - // so that the bridge can just delegate any failures to the coordinator without having to - // keep its own state to ensure it only reports a single failure - guard state != .failed else { return } + private func handleLoading() { + guard ensureState(.started) else { return } + executeHooks(event: .loading) + } - state = .failed - let error = error as? DescopeError ?? DescopeError.flowFailed.with(cause: error) - delegate?.coordinatorDidFailAuthentication(self, error: error) + private func handleLoaded() { + guard ensureState(.started) else { return } + executeHooks(event: .loaded) } private func handleReady() { guard ensureState(.started) else { return } - bridge.set(oauthProvider: flow?.oauthProvider?.name, magicLinkRedirect: flow?.magicLinkRedirect?.absoluteString) + bridge.set(oauthProvider: flow?.oauthProvider?.name, magicLinkRedirect: flow?.magicLinkRedirect) state = .ready + executeHooks(event: .ready) delegate?.coordinatorDidBecomeReady(self) } @@ -142,15 +264,31 @@ public class DescopeFlowCoordinator { } } + private func handleError(_ error: DescopeError) { + guard ensureState(.started, .ready, .failed) else { return } + + // we allow multiple failure events and swallow them here instead of showing a warning above, + // so that the bridge can just delegate any failures to the coordinator without having to + // keep its own state to ensure it only reports a single failure + guard state != .failed else { return } + + state = .failed + delegate?.coordinatorDidFail(self, error: error) + } + + private func handleSuccess(_ authResponse: AuthenticationResponse) { + guard ensureState(.ready) else { return } + state = .finished + delegate?.coordinatorDidFinish(self, response: authResponse) + } + // Authentication private func handleAuthentication(_ data: Data) { logger(.info, "Finishing flow authentication") Task { guard let authResponse = await parseAuthentication(data) else { return } - guard ensureState(.ready) else { return } - state = .finished - delegate?.coordinatorDidFinishAuthentication(self, response: authResponse) + handleSuccess(authResponse) } } @@ -163,7 +301,7 @@ public class DescopeFlowCoordinator { return try jwtResponse.convert() } catch { logger(.error, "Unexpected error handling authentication response", error) - handleFailure(DescopeError.flowFailed.with(message: "No valid authentication tokens found")) + handleError(DescopeError.flowFailed.with(message: "No valid authentication tokens found")) return nil } } @@ -211,15 +349,15 @@ public class DescopeFlowCoordinator { extension DescopeFlowCoordinator: FlowBridgeDelegate { func bridgeDidStartLoading(_ bridge: FlowBridge) { - // nothing + handleLoading() } func bridgeDidFailLoading(_ bridge: FlowBridge, error: DescopeError) { - handleFailure(error) + handleError(error) } func bridgeDidFinishLoading(_ bridge: FlowBridge) { - // nothing + handleLoaded() } func bridgeDidBecomeReady(_ bridge: FlowBridge) { @@ -235,7 +373,7 @@ extension DescopeFlowCoordinator: FlowBridgeDelegate { } func bridgeDidFailAuthentication(_ bridge: FlowBridge, error: DescopeError) { - handleFailure(error) + handleError(error) } func bridgeDidFinishAuthentication(_ bridge: FlowBridge, data: Data) { @@ -243,12 +381,6 @@ extension DescopeFlowCoordinator: FlowBridgeDelegate { } } -private extension DescopeFlow { - var config: DescopeConfig { - return descope?.config ?? Descope.sdk.config - } -} - private extension WKHTTPCookieStore { func cookies(for url: URL?) async -> [HTTPCookie] { return await allCookies().filter { cookie in @@ -261,4 +393,19 @@ private extension WKHTTPCookieStore { } } -#endif +@MainActor +private class WebViewLayoutObserver: NSObject { + @objc let webView: WKWebView + var observation: NSKeyValueObservation? + + init(webView: WKWebView, handler: @escaping @MainActor () -> Void) { + self.webView = webView + super.init() + + observation = observe(\.webView.frame, changeHandler: { observer, change in + Task { @MainActor in + handler() + } + }) + } +} diff --git a/src/flows/FlowHook.swift b/src/flows/FlowHook.swift new file mode 100644 index 0000000..0c19413 --- /dev/null +++ b/src/flows/FlowHook.swift @@ -0,0 +1,436 @@ + +import WebKit + +/// The ``DescopeFlowHook`` class allows implementing hooks that customize how the flow +/// webpage looks or behaves, usually by adding CSS, running JavaScript code, or configuring +/// the scroll view or web view. +/// +/// You can use hooks by setting the flow's `hooks` array. For example, these hooks will +/// override the flow to have a transparent background and set margins on the body element. +/// +/// ```swift +/// flow.hooks = [ +/// .setTransparentBody, +/// .addStyles(selector: "body", rules: ["margin: 16px"]), +/// ] +/// ``` +/// +/// Alterntively, create custom hooks in a ``DescopeFlowHook`` extension to have them all +/// in one place: +/// +/// ```swift +/// func showFlow() { +/// let flow = DescopeFlow(url: "https://example.com/myflow") +/// flow.hooks = [.setMaxWidth, .removeFooter, .hideScrollBar] +/// flowView.start(flow: flow) +/// } +/// +/// // elsewhere +/// +/// extension DescopeFlowHook { +/// static let setMaxWidth = addStyles(selector: ".login-container", rules: ["max-width: 250px"]) +/// +/// static let removeFooter = runJavaScript(on: .ready, code: """ +/// const footer = document.querySelector('#footer') +/// footer?.remove() +/// """) +/// +/// static let hideScrollBar = setupScrollView { scrollView in +/// scrollView.showsVerticalScrollIndicator = false +/// } +/// } +/// ``` +/// +/// You can also implement your own hooks by subclassing ``DescopeFlowHook`` and +/// overriding the ``execute(coordinator:)`` method. +@MainActor +open class DescopeFlowHook { + + /// The hook event determines when a hook is executed. + public enum Event: String { + /// The hook is executed when the flow is started with `start(flow:)`. + /// + /// - Note: The flow is not loaded and the `document` element isn't available + /// at this point, so this event is not appropriate for making changes to + /// the flow page itself. + case started + + /// The hook is executed when the flow page begins loading. + /// + /// - Note: The flow is not loaded and the `document` element isn't available + /// at this point, so this event is not appropriate for making changes to + /// the flow page itself. + case loading + + /// The hook is executed when the `document` element is available in the page. + case loaded + + /// The hook is executed when the flow page is fully loaded and ready to be displayed. + case ready + + /// The hook is executed when the underlying `WKWebView` that's displaying changes + /// its layout, i.e., when the value of its `frame` property changes. + /// + /// - Important: This event is experimental. It might be called both before + /// and after the flow is loaded or ready, so your `execute` method should + /// probably check the coordinator's `state` property. It's recommended to + /// test well any hook that uses it. + case layout + } + + /// When the hook should be executed. + public let events: Set + + /// Creates a new ``DescopeFlowHook`` object. + /// + /// - Parameter events: A set of events for which the hook will be executed. + public init(events: Set) { + self.events = events + } + + /// Override this method to implement your hook. + /// + /// This method is called by the ``DescopeFlowCoordinator`` when one of the events in + /// the ``events`` set takes place. If the set has more than one member you can check + /// the `event` parameter and take different actions depending on the specific event. + /// + /// The default implementation of this method does nothing. + /// + /// - Parameters: + /// - event: The event that took place. + /// - coordinator: The ``DescopeFlowCoordinator`` that's running the flow. + open func execute(event: Event, coordinator: DescopeFlowCoordinator) { + } + + /// The list of default hooks. + /// + /// These hooks are always executed, but you can override them by adding the + /// counterpart hook to the ``DescopeFlow/hooks`` array. + static let defaults: [DescopeFlowHook] = [ + .disableZoom, + .disableTouchCallouts, + .disableTextSelection, + .disableInputAccessoryView, + ] +} + +/// Basic hooks for customizing the behavior of the flow. +extension DescopeFlowHook { + + /// Creates a hook that will add the specified CSS rules when executed. + /// + /// ```swift + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ + /// .addStyles(selector: ".login-container", rules: [ + /// "max-width: 250px", + /// "box-shadow: none", + /// ]), + /// ] + /// ``` + /// + /// - Parameters: + /// - event: When the hook should be executed, the default value is `.loaded`. + /// - selector: The CSS selector, e.g., `"body"` or `"html, .container"`. + /// - rules: The CSS rules, e.g., `"background-color: black"`. + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static func addStyles(on event: Event = .loaded, selector: String, rules: [String]) -> DescopeFlowHook { + return AddStylesHook(event: event, css: """ + \(selector) { + \(rules.map { $0 + ";" }.joined(separator: "\n")) + } + """) + } + + /// Creates a hook that will add the specified raw CSS when executed. + /// + /// ```swift + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ .addStyles(css: "body { margin: 16px; }") ] + /// ``` + /// + /// - Parameters: + /// - event: When the hook should be executed, the default value is `.loaded`. + /// - css: The raw CSS to add, e.g., `".footer { display: none; }"`. + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static func addStyles(on event: Event = .loaded, css: String) -> DescopeFlowHook { + return AddStylesHook(event: event, css: css) + } + + /// Creates a hook that will run the specified JavaScript code when executed. + /// + /// The code is implicitly wrapped in an immediately invoked function expression, so you + /// can safely declare variables and not worry about polluting the global namespace. + /// + /// ```swift + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ + /// .runJavaScript(on: ready, code: """ + /// const footer = document.querySelector('#footer') + /// footer?.remove() + /// """), + /// ] + /// ``` + /// + /// You can call the various `console` functions and in `debug` builds the log messages + /// are redirected to the ``DescopeLogger`` if you've configured one. + /// + /// ```swift + /// Descope.setup(projectId: "...") { config in + /// config.logger = DescopeLogger() + /// } + /// + /// // elsewhere + /// + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ .runJavaScript("console.log(navigator.userAgent)") ] + /// ``` + /// + /// - Parameters: + /// - event: When the hook should be executed, the default value is `.loaded`. + /// - code: The JavaScript code to run, e.g., `"console.log('Hello world')"`. + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static func runJavaScript(on event: Event = .loaded, code: String) -> DescopeFlowHook { + return RunJavaScriptHook(event: event, code: code) + } + + #if os(iOS) + /// Creates a hook that will run the provided closure when the flow is started + /// on the `UIScrollView` used to display it. + /// + /// You can use this function to customize the scrolling behavior of the flow. For example: + /// + /// ```swift + /// func showFlow() { + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ .disableScrolling ] + /// flowView.start(flow: flow) + /// } + /// + /// // elsewhere + /// + /// extension DescopeFlowHook { + /// static let disableScrolling = setupScrollView { scrollView in + /// scrollView.isScrollEnabled = false + /// scrollView.showsVerticalScrollIndicator = false + /// scrollView.showsHorizontalScrollIndicator = false + /// scrollView.contentInsetAdjustmentBehavior = .never + /// } + /// } + /// ``` + /// + /// - Parameter setup: A closure that receives the `UIScrollView` instance as its only parameter. + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static func setupScrollView(_ closure: @escaping (UIScrollView) -> Void) -> DescopeFlowHook { + return SetupSubviewHook(getter: { $0.webView?.scrollView }, closure: closure) + } + #endif + + /// Creates a hook that will run the provided closure when the flow is started + /// on the `WKWebView` used to display it. + /// + /// - Parameter setup: A closure that receives the `WKWebView` instance as its only parameter. + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static func setupWebView(_ closure: @escaping (WKWebView) -> Void) -> DescopeFlowHook { + return SetupSubviewHook(getter: { $0.webView }, closure: closure) + } +} + +/// Default hooks that are automatically applied and that configure the flow to behave +/// in a manner that is more consistent with native controls. +extension DescopeFlowHook { + + /// Disables long press interactions on page elements. + /// + /// This hook is always run automatically when the flow is loaded, so there's + /// usually no need to use it in application code. + public static let disableTouchCallouts = addStyles(selector: "*", rules: ["-webkit-touch-callout: none"]) + + /// Enables long press interactions on page elements. + /// + /// Add this hook if you want to override the default behavior and enable long press interactions. + public static let enableTouchCallouts = addStyles(selector: "*", rules: ["-webkit-touch-callout: default"]) + + /// Disables text selection in page elements such as labels and buttons. + /// + /// This hook is always run automatically when the flow is loaded, so there's + /// usually no need to use it in application code. + public static let disableTextSelection = addStyles(selector: "*", rules: ["-webkit-user-select: none"]) + + /// Enables text selection in page elements such as labels and buttons. + /// + /// Add this hook if you want to override the default behavior and allow text selection. + public static let enableTextSelection = addStyles(selector: "*", rules: ["-webkit-user-select: auto"]) + + /// Disables two finger and double tap zoom gestures. + /// + /// This hook is always run automatically when the flow is loaded, so there's + /// usually no need to use it in application code. + public static let disableZoom = setViewport("width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no") + + /// Enables two finger and double tap zoom gestures. + /// + /// Add this hook if you want to override the default behavior and enable zoom gestures. + public static let enableZoom = setViewport("width=device-width, initial-scale=1") + + /// Disables the input accessory view that's displayed above the on-screen keyboard. + /// + /// This is the default behavior, so there's usually no need to use this hook in application code. + public static let disableInputAccessoryView = setInputAccessoryView(enabled: false) + + /// Enables the input accessory view that's displayed above the on-screen keyboard. + /// + /// Add this hook if you want to override the default behavior and show the input accessory view. + /// + /// - Note: This hook only works when running on iOS and when using the default webView + /// instance in a ``DescopeFlowView``. + public static let enableInputAccessoryView = setInputAccessoryView(enabled: true) +} + +/// Hooks for overriding the flow background color. +extension DescopeFlowHook { + + /// Creates a hook that will make the flow page have a transparent background. + /// + /// You can use this hook when you prefer showing the app's view hierarchy as the + /// flow background, instead of whatever is defined in the page itself. + /// + /// ```swift + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ .setTransparentBody ] + /// flowView.start(flow: flow) + /// + /// containerView.isOpaque = false + /// containerView.backgroundColor = .clear + /// containerView.addSubview(flowView) + /// ``` + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static let setTransparentBody = setBackgroundColor(selector: "body", color: .clear) + + /// Creates a hook that will override an element's background color. + /// + /// ```swift + /// let flow = DescopeFlow(url: "https://example.com/myflow") + /// flow.hooks = [ + /// .setBackgroundColor(selector: "body", color: .secondarySystemBackground), + /// ] + /// ``` + /// + /// - Parameters: + /// - selector: The CSS selector. + /// - color: The color to use for the background. + /// + /// - Returns: A ``DescopeFlowHook`` object that can be added to the ``DescopeFlow/hooks`` array. + public static func setBackgroundColor(selector: String, color: PlatformColor) -> DescopeFlowHook { + return BackgroundColorHook(selector: selector, color: color) + } + + #if os(iOS) + public typealias PlatformColor = UIColor + #else + public typealias PlatformColor = NSColor + #endif +} + +// Internal + +private class AddStylesHook: DescopeFlowHook { + let css: String + + init(event: Event, css: String) { + self.css = css + super.init(events: [event]) + } + + override func execute(event: Event, coordinator: DescopeFlowCoordinator) { + coordinator.addStyles(css) + } +} + +private class RunJavaScriptHook: DescopeFlowHook { + let code: String + + init(event: Event, code: String) { + self.code = code + super.init(events: [event]) + } + + override func execute(event: Event, coordinator: DescopeFlowCoordinator) { + coordinator.runJavaScript(code) + } +} + +private class SetupSubviewHook: DescopeFlowHook { + let getter: (DescopeFlowCoordinator) -> T? + let closure: (T) -> Void + + init(getter: @escaping (DescopeFlowCoordinator) -> T?, closure: @escaping (T) -> Void) { + self.getter = getter + self.closure = closure + super.init(events: [.started]) + } + + override func execute(event: Event, coordinator: DescopeFlowCoordinator) { + guard let object = getter(coordinator) else { return } + closure(object) + } +} + +private extension DescopeFlowHook { + static func setViewport(_ value: String) -> DescopeFlowHook { + return RunJavaScriptHook(event: .loaded, code: """ + const content = \(value.javaScriptLiteralString()) + let viewport = document.head.querySelector('meta[name=viewport]') + if (viewport) { + viewport.content = content + } else { + viewport = document.createElement('meta') + viewport.name = 'viewport' + viewport.content = content + document.head.appendChild(viewport) + } + """) + } + + static func setInputAccessoryView(enabled: Bool) -> DescopeFlowHook { + return setupWebView { webView in + #if os(iOS) + guard let customWebView = webView as? DescopeCustomWebView else { return } + customWebView.showsInputAccessoryView = false + #endif + } + } +} + +private class BackgroundColorHook: DescopeFlowHook { + let selector: String + let color: PlatformColor + + init(selector: String, color: PlatformColor) { + self.selector = selector + self.color = color + super.init(events: [.started, .loaded]) + } + + override func execute(event: Event, coordinator: DescopeFlowCoordinator) { + if event == .started { + guard #available(iOS 15.0, *) else { return } + coordinator.webView?.underPageBackgroundColor = color + } else if event == .loaded { + coordinator.addStyles("\(selector) { background-color: \(colorStringValue); }") + } + } + + private var colorStringValue: String { + var (red, green, blue, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + guard alpha > 0 else { return "transparent" } + return "rgba(\(round(red * 255)), \(round(green * 255)), \(round(blue * 255)), \(alpha))" + } +} diff --git a/src/flows/FlowView.swift b/src/flows/FlowView.swift index 9c8654e..c44d35d 100644 --- a/src/flows/FlowView.swift +++ b/src/flows/FlowView.swift @@ -1,7 +1,6 @@ #if os(iOS) -import UIKit import WebKit /// A set of delegate methods for events about the flow running in a ``DescopeFlowView``. @@ -10,9 +9,9 @@ public protocol DescopeFlowViewDelegate: AnyObject { /// Called directly after the flow state is updated. /// /// Where appropriate, this delegate method is always called before other delegate methods. - /// For example, if there's an error in the flow this method is called first to report - /// the state change to ``DescopeFlowState/failed`` and then the failure delegate methud - /// is called with the specific ``DescopeError`` value. + /// For example, if there's an error in the flow this method is called first to report the + /// state change to ``DescopeFlowState/failed`` and then the failure delegate method is + /// called with the specific ``DescopeError`` value. func flowViewDidUpdateState(_ flowView: DescopeFlowView, to state: DescopeFlowState, from previous: DescopeFlowState) /// Called when the flow is fully loaded and rendered and the view can be displayed. @@ -35,13 +34,13 @@ public protocol DescopeFlowViewDelegate: AnyObject { /// /// The most common failures are due to internet issues, in which case the `error` will /// usually be ``DescopeError/networkError``. - func flowViewDidFailAuthentication(_ flowView: DescopeFlowView, error: DescopeError) + func flowViewDidFail(_ flowView: DescopeFlowView, error: DescopeError) /// Called when the flow completes the authentication successfully. /// /// The `response` parameter can be used to create a ``DescopeSession`` as with other /// authentication methods. - func flowViewDidFinishAuthentication(_ flowView: DescopeFlowView, response: AuthenticationResponse) + func flowViewDidFinish(_ flowView: DescopeFlowView, response: AuthenticationResponse) } /// A view for showing authentication screens built using [Descope Flows](https://app.descope.com/flows). @@ -62,8 +61,7 @@ public protocol DescopeFlowViewDelegate: AnyObject { /// flowView.autoresizingMask = [.flexibleWidth, .flexibleHeight] /// view.addSubview(flowView) /// -/// let flowURL = URL(string: "https://example.com/myflow")! -/// let flow = DescopeFlow(url: flowURL) +/// let flow = DescopeFlow(url: "https://example.com/myflow") /// flowView.start(flow: flow) /// } /// ``` @@ -85,14 +83,14 @@ public protocol DescopeFlowViewDelegate: AnyObject { /// UIApplication.shared.open(url) // open any links in the user's default browser app /// } /// -/// public func flowViewDidFailAuthentication(_ flowView: DescopeFlowView, error: DescopeError) { +/// public func flowViewDidFail(_ flowView: DescopeFlowView, error: DescopeError) { /// // called when the flow fails, because of a network error or some other reason /// let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) /// alert.addAction(UIAlertAction(title: "OK", style: .cancel)) /// self.present(alert, animated: true) /// } /// -/// public func flowViewDidFinishAuthentication(_ flowView: DescopeFlowView, response: AuthenticationResponse) { +/// public func flowViewDidFinish(_ flowView: DescopeFlowView, response: AuthenticationResponse) { /// let session = DescopeSession(from: response) /// Descope.sessionManager.manageSession(session) /// // for example, transition the app to some other screen @@ -107,36 +105,41 @@ public protocol DescopeFlowViewDelegate: AnyObject { /// an issue or pull request [here](https://github.com/descope/swift-sdk). open class DescopeFlowView: UIView { - private let coordinator = DescopeFlowCoordinator() - - private lazy var webView: WKWebView = createWebView() - /// A delegate object for receiving events about the state of the flow. public weak var delegate: DescopeFlowViewDelegate? - /// The current state of the ``DescopeFlowView``. + /// Returns the flow that's currently running in the ``DescopeFlowView``. + public var flow: DescopeFlow? { + return coordinator.flow + } + + /// Returns the current state of the flow in the ``DescopeFlowView``. public var state: DescopeFlowState { return coordinator.state } // Initialization + private let coordinator = DescopeFlowCoordinator() + + private lazy var proxy = FlowCoordinatorDelegateProxy(view: self) + public convenience init() { self.init(frame: .zero) } public override init(frame: CGRect) { super.init(frame: frame) - setupView() + prepareView() } public required init?(coder: NSCoder) { super.init(coder: coder) - setupView() + prepareView() } - private func setupView() { - coordinator.delegate = delegateWrapper + private func prepareView() { + coordinator.delegate = proxy coordinator.webView = webView addSubview(webView) } @@ -156,8 +159,7 @@ open class DescopeFlowView: UIView { /// no delegate updates are missed. /// /// ```swift - /// let flowURL = URL(string: "https://example.com/myflow")! - /// let flow = DescopeFlow(url: flowURL) + /// let flow = DescopeFlow(url: "https://example.com/myflow") /// flowView.start(flow: flow) /// ``` /// @@ -169,83 +171,80 @@ open class DescopeFlowView: UIView { // WebView + private lazy var webView: WKWebView = createWebView() + private func createWebView() -> WKWebView { let configuration = WKWebViewConfiguration() - _prepareConfiguration(configuration) - - let webViewClass = Self.webViewClass - let webView = webViewClass.init(frame: bounds, configuration: configuration) - _prepareWebView(webView) - - return webView - } - - private func _prepareConfiguration(_ configuration: WKWebViewConfiguration) { - prepareConfiguration(configuration) + willCreateWebView(configuration) coordinator.prepare(configuration: configuration) - } - private func _prepareWebView(_ webView: WKWebView) { + let webView = Self.webViewClass.init(frame: bounds, configuration: configuration) webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.keyboardDismissMode = .interactiveWithAccessory - prepareWebView(webView) + didCreateWebView(webView) + + return webView } // Override points - /// Override this method if you want your ``DescopeFlowView`` to use a spsecific + /// Override this getter if you want your ``DescopeFlowView`` to use a specific /// type of `WKWebView` for its webview instance. open class var webViewClass: WKWebView.Type { return DescopeCustomWebView.self } - /// Override this method if you need to customize the webview's configuration before it's created. + /// Override this method if you need to customize the webview's configuration + /// before it's created. /// /// The default implementation of this method does nothing. - open func prepareConfiguration(_ configuration: WKWebViewConfiguration) { + open func willCreateWebView(_ configuration: WKWebViewConfiguration) { } /// Override this method if you need to customize the webview itself after it's created. /// /// The default implementation of this method does nothing. - open func prepareWebView(_ webView: WKWebView) { - } - - // Delegation points (not public for now) - - private lazy var delegateWrapper = CoordinatorDelegateWrapper(view: self) - - func didUpdateState(to state: DescopeFlowState, from previous: DescopeFlowState) { - delegate?.flowViewDidUpdateState(self, to: state, from: previous) + open func didCreateWebView(_ webView: WKWebView) { } - func didBecomeReady() { - delegate?.flowViewDidBecomeReady(self) + /// Override this method if your subclass needs to do something when the flow state is updated. + /// + /// The default implementation of this method does nothing. + open func didUpdateState(to state: DescopeFlowState, from previous: DescopeFlowState) { } - func didInterceptNavigation(url: URL, external: Bool) { - delegate?.flowViewDidInterceptNavigation(self, url: url, external: external) + /// Override this method if your subclass needs to do something when the flow is + /// fully loaded and rendered and the view can be displayed. + /// + /// The default implementation of this method does nothing. + open func didBecomeReady() { } - func didFailAuthentication(error: DescopeError) { - delegate?.flowViewDidFailAuthentication(self, error: error) + /// Override this method if your subclass needs to do something when the user taps + /// on a web link in the flow. + /// + /// The default implementation of this method does nothing. + open func didInterceptNavigation(url: URL, external: Bool) { } - func didFinishAuthentication(response: AuthenticationResponse) { - delegate?.flowViewDidFinishAuthentication(self, response: response) + /// Override this method if your subclass needs to do something when an error occurs + /// in the flow. + /// + /// The default implementation of this method does nothing. + open func didFail(error: DescopeError) { } -} -/// A custom WKWebView subclass to hide the form navigation bar. -private class DescopeCustomWebView: WKWebView { - override var inputAccessoryView: UIView? { - return nil + /// Override this method if your subclass needs to do something when the flow completes + /// the authentication successfully. + /// + /// The default implementation of this method does nothing. + open func didFinish(response: AuthenticationResponse) { } } -/// A helper class to hide the coordinator delegate implementations. -private class CoordinatorDelegateWrapper: DescopeFlowCoordinatorDelegate { +/// A helper class to not expose the coordinator delegate conformance. +private class FlowCoordinatorDelegateProxy: DescopeFlowCoordinatorDelegate { private weak var view: DescopeFlowView? init(view: DescopeFlowView) { @@ -253,23 +252,42 @@ private class CoordinatorDelegateWrapper: DescopeFlowCoordinatorDelegate { } func coordinatorDidUpdateState(_ coordinator: DescopeFlowCoordinator, to state: DescopeFlowState, from previous: DescopeFlowState) { - view?.didUpdateState(to: state, from: previous) + guard let view else { return } + view.didUpdateState(to: state, from: previous) + view.delegate?.flowViewDidUpdateState(view, to: state, from: previous) } func coordinatorDidBecomeReady(_ coordinator: DescopeFlowCoordinator) { - view?.didBecomeReady() + guard let view else { return } + view.didBecomeReady() + view.delegate?.flowViewDidBecomeReady(view) } func coordinatorDidInterceptNavigation(_ coordinator: DescopeFlowCoordinator, url: URL, external: Bool) { - view?.didInterceptNavigation(url: url, external: external) + guard let view else { return } + view.didInterceptNavigation(url: url, external: external) + view.delegate?.flowViewDidInterceptNavigation(view, url: url, external: external) + } + + func coordinatorDidFail(_ coordinator: DescopeFlowCoordinator, error: DescopeError) { + guard let view else { return } + view.didFail(error: error) + view.delegate?.flowViewDidFail(view, error: error) } - func coordinatorDidFailAuthentication(_ coordinator: DescopeFlowCoordinator, error: DescopeError) { - view?.didFailAuthentication(error: error) + func coordinatorDidFinish(_ coordinator: DescopeFlowCoordinator, response: AuthenticationResponse) { + guard let view else { return } + view.didFinish(response: response) + view.delegate?.flowViewDidFinish(view, response: response) } +} - func coordinatorDidFinishAuthentication(_ coordinator: DescopeFlowCoordinator, response: AuthenticationResponse) { - view?.didFinishAuthentication(response: response) +/// A custom WKWebView subclass to hide the form navigation bar. +class DescopeCustomWebView: WKWebView { + var showsInputAccessoryView: Bool = false + + override var inputAccessoryView: UIView? { + return showsInputAccessoryView ? super.inputAccessoryView : nil } } diff --git a/src/flows/FlowViewController.swift b/src/flows/FlowViewController.swift index 9f84a86..2ad5af2 100644 --- a/src/flows/FlowViewController.swift +++ b/src/flows/FlowViewController.swift @@ -1,17 +1,51 @@ #if os(iOS) -import UIKit import WebKit /// A set of delegate methods for events about the flow running in a ``DescopeFlowViewController``. @MainActor public protocol DescopeFlowViewControllerDelegate: AnyObject { + /// Called directly after the flow state is updated. + /// + /// Where appropriate, this delegate method is always called before other delegate methods. + /// For example, if there's an error in the flow this method is called first to report the + /// state change to ``DescopeFlowState/failed`` and then the failure delegate method is + /// called with the specific ``DescopeError`` value. func flowViewControllerDidUpdateState(_ controller: DescopeFlowViewController, to state: DescopeFlowState, from previous: DescopeFlowState) + + /// Called when the flow is fully loaded and rendered and the view can be displayed. + /// + /// You can use this method to show a loading state until the flow is fully loaded, + /// and do a quick animatad transition to show the flow once this method is called. func flowViewControllerDidBecomeReady(_ controller: DescopeFlowViewController) + + /// Called when the user taps on a web link in the flow. + /// + /// The `external` parameter is `true` if the link is configured to open a new + /// browser tab or window in a regular browser app. + /// + /// If your flow doesn't show any web links you can either use an empty implementation + /// or simply call `UIApplication.shared.open(url)` so that links open in the user's + /// default browser app. func flowViewControllerShouldShowURL(_ controller: DescopeFlowViewController, url: URL, external: Bool) -> Bool + + /// Called when the user cancels the flow. + /// + /// The flow is cancelled either via the built-in Cancel button in the navigation bar, + /// or if the ``DescopeFlowViewController/cancel()`` method is called programmatically. func flowViewControllerDidCancel(_ controller: DescopeFlowViewController) + + /// Called when an error occurs in the flow. + /// + /// The most common failures are due to internet issues, in which case the `error` will + /// usually be ``DescopeError/networkError``. func flowViewControllerDidFail(_ controller: DescopeFlowViewController, error: DescopeError) + + /// Called when the flow completes the authentication successfully. + /// + /// The `response` parameter can be used to create a ``DescopeSession`` as with other + /// authentication methods. func flowViewControllerDidFinish(_ controller: DescopeFlowViewController, response: AuthenticationResponse) } @@ -23,8 +57,7 @@ public protocol DescopeFlowViewControllerDelegate: AnyObject { /// /// ```swift /// fun showLoginScreen() { -/// let url = URL(string: "https://example.com/myflow")! -/// let flow = DescopeFlow(url: url) +/// let flow = DescopeFlow(url: "https://example.com/myflow") /// /// let flowViewController = DescopeFlowViewController() /// flowViewController.delegate = self @@ -42,21 +75,34 @@ public protocol DescopeFlowViewControllerDelegate: AnyObject { /// /// You can also use the source code for this class as an example of how to incorporate /// a ``DescopeFlowView`` into your own view controller. -public class DescopeFlowViewController: UIViewController { - - private lazy var flowView: DescopeFlowView = createFlowView() +open class DescopeFlowViewController: UIViewController { /// A delegate object for receiving events about the state of the flow. public weak var delegate: DescopeFlowViewControllerDelegate? - /// The current state of the ``DescopeFlowViewController``. + /// Returns the flow that's currently running in the ``DescopeFlowViewController``. + public var flow: DescopeFlow? { + return flowView.flow + } + + /// Returns the current state of the flow in the ``DescopeFlowViewController``. public var state: DescopeFlowState { return flowView.state } + /// The underlying ``DescopeFlowView`` used by this controller. + public var flowView: DescopeFlowView { + return underlyingView + } + // UIViewController - public override func viewDidLoad() { + /// Called after the controller's view is loaded into memory. + /// + /// You can override this method to perform additional initialization in + /// your controller subclass. You must call through to `super.viewDidLoad` + /// in your implementation. + open override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .secondarySystemBackground @@ -69,8 +115,11 @@ public class DescopeFlowViewController: UIViewController { view.addSubview(flowView) } - /// Overridden to ensure the cancel button is only set when appropriate - public override func willMove(toParent parent: UIViewController?) { + /// Called just before the view controller is added or removed from a container + /// view controller. + /// + /// You must call through to `super.willMove(toParent: parent)` in your implementation. + open override func willMove(toParent parent: UIViewController?) { super.willMove(toParent: parent) if navigationController?.viewControllers.first == self { navigationItem.leftBarButtonItem = cancelBarButton @@ -81,14 +130,19 @@ public class DescopeFlowViewController: UIViewController { // Flow + /// Override this method if you want your controller to use your own subclass + /// of ``DescopeFlowView`` as its underlying view. + open func createFlowView() -> DescopeFlowView { + return DescopeFlowView(frame: isViewLoaded ? view.bounds : UIScreen.main.bounds) + } + /// Loads and displays a Descope Flow. /// /// The ``delegate`` property should be set before calling this function to ensure /// no delegate updates are missed. /// /// ```swift - /// let flowURL = URL(string: "https://example.com/myflow")! - /// let flow = DescopeFlow(url: flowURL) + /// let flow = DescopeFlow(url: "https://example.com/myflow") /// flowViewController.start(flow: flow) /// ``` /// @@ -99,18 +153,26 @@ public class DescopeFlowViewController: UIViewController { flowView.start(flow: flow) } + /// Cancels the view controller. + /// + /// This function is called when the user taps on the Cancel button in the navigation bar + /// and it notifies the delegate about the cancellation. Apps or subclasses can call this + /// method to preserve the same behavior even if they use a different interaction for + /// letting users cancel the flow.` + public func cancel() { + delegate?.flowViewControllerDidCancel(self) + } + // Internal + private lazy var underlyingView = createFlowView() + private lazy var activityView = UIActivityIndicatorView() private lazy var cancelBarButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancel)) @objc private func handleCancel() { - delegate?.flowViewControllerDidCancel(self) - } - - private func createFlowView() -> DescopeFlowView { - return DescopeFlowView(frame: isViewLoaded ? view.bounds : UIScreen.main.bounds) + cancel() } } @@ -135,11 +197,11 @@ extension DescopeFlowViewController: DescopeFlowViewDelegate { } } - public func flowViewDidFailAuthentication(_ flowView: DescopeFlowView, error: DescopeError) { + public func flowViewDidFail(_ flowView: DescopeFlowView, error: DescopeError) { delegate?.flowViewControllerDidFail(self, error: error) } - public func flowViewDidFinishAuthentication(_ flowView: DescopeFlowView, response: AuthenticationResponse) { + public func flowViewDidFinish(_ flowView: DescopeFlowView, response: AuthenticationResponse) { delegate?.flowViewControllerDidFinish(self, response: response) } } diff --git a/src/internal/others/Internal.swift b/src/internal/others/Internal.swift index 8cffdb9..847a820 100644 --- a/src/internal/others/Internal.swift +++ b/src/internal/others/Internal.swift @@ -42,6 +42,14 @@ extension Data { } } +extension String { + func javaScriptLiteralString() -> String { + return "`" + replacingOccurrences(of: #"\"#, with: #"\\"#) + .replacingOccurrences(of: #"$"#, with: #"\$"#) + .replacingOccurrences(of: #"`"#, with: #"\`"#) + "`" + } +} + class DefaultPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding, ASAuthorizationControllerPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { return presentationAnchor