Skip to content

Commit

Permalink
Add DescopeFlowHook for customizing how flow pages look and behave
Browse files Browse the repository at this point in the history
  • Loading branch information
shilgapira committed Dec 29, 2024
1 parent 8143ea1 commit 904f5a1
Show file tree
Hide file tree
Showing 7 changed files with 897 additions and 202 deletions.
49 changes: 31 additions & 18 deletions src/flows/Flow.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@

#if os(iOS)

import Foundation

/// The state of the flow or presenting object.
Expand All @@ -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
Expand All @@ -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.
///
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -93,5 +108,3 @@ extension DescopeFlow: CustomStringConvertible {
return "DescopeFlow(url: \"\(url)\")"
}
}

#endif
105 changes: 58 additions & 47 deletions src/flows/FlowBridge.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@

#if os(iOS)

import UIKit
import WebKit

@MainActor
Expand Down Expand Up @@ -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
Expand All @@ -48,28 +47,38 @@ 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
}

for name in FlowBridgeMessage.allCases {
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)
}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}
})
}
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading

0 comments on commit 904f5a1

Please sign in to comment.