UNPKG

@getpassage/react-native

Version:

Passage React Native SDK for mobile authentication

441 lines (429 loc) 18.5 kB
import React, { useRef, useEffect, useState } from "react"; import { Modal, View, StyleSheet, SafeAreaView, StatusBar, Platform, Animated, Dimensions, } from "react-native"; import WebView from "react-native-webview"; import { RemoteControlManager } from "./remote-control"; import { logger } from "./logger"; import { captureRef } from "react-native-view-shot"; export const WebViewModal = ({ visible, url, automationUrl, presentationStyle, automationUserAgent, marginBottom, onClose, onSuccess, onError, onImageCaptured, onWebviewChange, }) => { const uiWebViewRef = useRef(null); const automationWebViewRef = useRef(null); const remoteControl = RemoteControlManager.getInstance(); const imageRef = useRef(null); // State for controlling which webview is visible const [activeWebView, setActiveWebView] = useState("ui"); // Screenshot state management const [currentScreenshot, setCurrentScreenshot] = useState(null); const [previousScreenshot, setPreviousScreenshot] = useState(null); // Animated values for fade transitions const uiOpacity = useRef(new Animated.Value(1)).current; const automationOpacity = useRef(new Animated.Value(0)).current; const captureImage = async () => { // Only capture image if record flag is true const shouldRecord = remoteControl.getRecordFlag(); if (!shouldRecord) { logger.debug("[WebViewModal] Screenshot capture skipped - record flag is false"); return null; } try { const localUri = await captureRef(imageRef, { quality: 0.5, width: Dimensions.get("window").width, height: Dimensions.get("window").height, useRenderInContext: true, result: "data-uri", }); logger.debug("[WebViewModal] Image captured successfully"); // Update screenshot state - move current to previous, set new as current setPreviousScreenshot(currentScreenshot); setCurrentScreenshot(localUri); onImageCaptured === null || onImageCaptured === void 0 ? void 0 : onImageCaptured(localUri); return localUri; } catch (e) { logger.error("Error capturing screenshot:", e); return null; } }; useEffect(() => { if (visible) { // Reset to UI webview when modal opens setActiveWebView("ui"); uiOpacity.setValue(1); automationOpacity.setValue(0); // Set both webview refs remoteControl.setWebViewRefs({ ui: uiWebViewRef.current, automation: automationWebViewRef.current, }); // Set screenshot access methods remoteControl.setScreenshotAccessors({ getCurrentScreenshot: () => currentScreenshot, getPreviousScreenshot: () => previousScreenshot, }); // Set captureImage function for remote control to trigger screenshots remoteControl.setCaptureImageFunction(captureImage); // Set callback for webview switching remoteControl.setWebViewSwitchCallback((type) => { logger.debug(`[WebViewModal] Switching to ${type} webview`); setActiveWebView(type); // Call the onWebviewChange callback onWebviewChange === null || onWebviewChange === void 0 ? void 0 : onWebviewChange(type); // Animate the transition const duration = 300; if (type === "automation") { Animated.parallel([ Animated.timing(uiOpacity, { toValue: 0, duration, useNativeDriver: true, }), Animated.timing(automationOpacity, { toValue: 1, duration, useNativeDriver: true, }), ]).start(); } else { Animated.parallel([ Animated.timing(uiOpacity, { toValue: 1, duration, useNativeDriver: true, }), Animated.timing(automationOpacity, { toValue: 0, duration, useNativeDriver: true, }), ]).start(); } }); } else { remoteControl.setWebViewRefs(null); remoteControl.setWebViewSwitchCallback(null); remoteControl.setScreenshotAccessors(null); remoteControl.setCaptureImageFunction(null); } }, [visible]); const handleMessage = (event, webViewType) => { try { const message = JSON.parse(event.nativeEvent.data); // Handle internal messages (fire and forget) remoteControl.handleWebViewMessage(event, webViewType).catch((error) => { logger.error("Error handling WebView message:", error); }); // Handle SDK messages from UI webview if (webViewType === "ui") { if (message.type === "CONNECTION_SUCCESS") { const connections = message.connections || []; onSuccess === null || onSuccess === void 0 ? void 0 : onSuccess({ connections, }); onClose(); } else if (message.type === "CONNECTION_ERROR") { const error = message.error || "Unknown error"; onError === null || onError === void 0 ? void 0 : onError({ error, }); onClose(); } else if (message.type === "CLOSE_MODAL") { onClose(); } else if (message.type === "SEND_PAGE_DATA") { // Forward page data request to remote control logger.debug("[WebViewModal] Received SEND_PAGE_DATA request:", { commandId: message.commandId, webViewType: message.webViewType, }); // The remote control will handle sending the page data with the current screenshot remoteControl.handleSendPageDataRequest(message.commandId); } } } catch (error) { logger.error("Error parsing WebView message:", error); } }; const handleNavigationStateChange = (navState, webViewType) => { logger.debug(`[WebViewModal] Navigation state changed for ${webViewType}: ${navState.url}`, Object.assign({ url: navState.url, loading: navState.loading }, navState)); // Send browser state update to backend and handle screenshots/reinjection when loading is complete if (navState.url) { // Only capture screenshot and reinject when loading is false (page fully loaded) if (!navState.loading) { // Handle injectScript command reinjection for record mode if (webViewType === "automation") { remoteControl.sendBrowserState(navState.url, webViewType); remoteControl.handleNavigationComplete(navState.url); logger.debug(`[WebViewModal] Page loaded for ${webViewType}, capturing screenshot and checking for reinjection`); captureImage(); } } } }; const generateInjectedScript = (webViewType) => { const baseScript = ` (function() { // Create WebViewModal API for compatibility window.WebViewModal = { postMessage: function(data) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'message', data: data, webViewType: '${webViewType}' })); }, navigate: function(url) { window.location.href = url; }, close: function() { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'CLOSE_MODAL' })); }, setTitle: function(title) { // No-op in React Native } }; // Create window.passage object window.passage = { webViewType: '${webViewType}', postMessage: function(data) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'passage_message', data: data, webViewType: '${webViewType}' })); }, navigate: function(url) { window.location.href = url; }, close: function() { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'CLOSE_MODAL' })); }, getWebViewType: function() { return '${webViewType}'; }, isAutomationWebView: function() { return '${webViewType}' === 'automation'; }, isUIWebView: function() { return '${webViewType}' === 'ui'; }, showUIWebView: function() { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'SWITCH_WEBVIEW', targetWebView: 'ui', webViewType: '${webViewType}' })); }, showAutomationWebView: function() { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'SWITCH_WEBVIEW', targetWebView: 'automation', webViewType: '${webViewType}' })); }, sendPageData: function(commandId) { // Trigger page data collection and send as command result window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'SEND_PAGE_DATA', commandId: commandId, webViewType: '${webViewType}' })); } }; `; if (webViewType === "automation") { // Add automation-specific functionality return (baseScript + ` window.passage.automation = { waitForElement: function(selector, timeout = 5000) { return new Promise(function(resolve, reject) { const element = document.querySelector(selector); if (element) { resolve(element); return; } const observer = new MutationObserver(function(mutations) { const element = document.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(function() { observer.disconnect(); reject(new Error('Element not found: ' + selector)); }, timeout); }); }, getPageData: function() { const localStorageData = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); localStorageData.push({ name: key, value: localStorage.getItem(key) }); } const sessionStorageData = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); sessionStorageData.push({ name: key, value: sessionStorage.getItem(key) }); } return { url: window.location.href, title: document.title, html: document.documentElement.outerHTML, localStorage: localStorageData, sessionStorage: sessionStorageData }; } }; console.log('[Passage] Automation webview initialized'); })(); true;`); } else { // UI webview script return (baseScript + ` })(); true;`); } }; const generateGlobalJavaScript = () => { // Get global JavaScript from remote control manager const globalScript = remoteControl.getGlobalJavascript(); if (!globalScript) { return ""; // Return minimal script if no global JS } // Wrap the global script in an IIFE for safety return ` (function() { try { ${globalScript} return true; } catch (error) { console.error('[Passage] Error executing global JavaScript:', error); } })(); true; `; }; const isFullScreen = presentationStyle === "fullScreen"; // Create dynamic automationWebView style with marginBottom const automationWebViewStyle = [ styles.webViewWrapper, { opacity: automationOpacity }, marginBottom ? { marginBottom } : null, ]; return (<Modal visible={visible} animationType="slide" presentationStyle={isFullScreen ? "fullScreen" : "pageSheet"} onRequestClose={onClose}> <SafeAreaView style={styles.container}> {Platform.OS === "ios" && isFullScreen && (<StatusBar barStyle="dark-content"/>)} {/* Header */} {/* <View style={styles.header}> <TouchableOpacity onPress={onClose} style={styles.closeButton}> <Text style={styles.closeButtonText}>✕</Text> </TouchableOpacity> </View> */} {/* WebView Container with both webviews */} <View style={styles.webViewContainer}> {/* UI WebView */} <Animated.View style={[styles.webViewWrapper, { opacity: uiOpacity }]}> <WebView ref={uiWebViewRef} source={{ uri: url }} onMessage={(event) => handleMessage(event, "ui")} onNavigationStateChange={(navState) => handleNavigationStateChange(navState, "ui")} injectedJavaScript={generateInjectedScript("ui")} onLoadStart={() => { remoteControl.handleLoadStart("ui"); }} onLoadEnd={() => { remoteControl.handleLoadEnd("ui"); }} onError={(syntheticEvent) => { const { nativeEvent } = syntheticEvent; logger.error("[WEBVIEW MODAL] UI WebView error:", nativeEvent); remoteControl.handleLoadError(nativeEvent, "ui"); // Show alert for debugging if (nativeEvent.description) { logger.error("[WEBVIEW MODAL] Error details:", nativeEvent.description); } }} onHttpError={(syntheticEvent) => { const { nativeEvent } = syntheticEvent; logger.error("[WEBVIEW MODAL] UI WebView HTTP error:", { statusCode: nativeEvent.statusCode, description: nativeEvent.description, url: nativeEvent.url, }); remoteControl.handleLoadError(nativeEvent, "ui"); }} javaScriptEnabled={true} domStorageEnabled={true} sharedCookiesEnabled={true} thirdPartyCookiesEnabled={true} startInLoadingState={false} webviewDebuggingEnabled={true} onShouldStartLoadWithRequest={(request) => { return (request.url.startsWith("http://") || request.url.startsWith("https://")); }}/> </Animated.View> {/* Automation WebView */} <Animated.View style={automationWebViewStyle}> <View ref={imageRef} collapsable={false} style={{ flex: 1 }}> <WebView originWhitelist={["*"]} ref={automationWebViewRef} source={{ uri: automationUrl || "" }} {...(automationUserAgent && { userAgent: automationUserAgent })} onMessage={(event) => handleMessage(event, "automation")} onNavigationStateChange={(navState) => handleNavigationStateChange(navState, "automation")} injectedJavaScript={generateInjectedScript("automation")} injectedJavaScriptBeforeContentLoaded={generateGlobalJavaScript()} onLoadStart={() => { remoteControl.handleLoadStart("automation"); }} onLoadEnd={() => { remoteControl.handleLoadEnd("automation"); }} onError={(syntheticEvent) => { const { nativeEvent } = syntheticEvent; logger.error("Automation WebView error:", nativeEvent); remoteControl.handleLoadError(nativeEvent, "automation"); }} onHttpError={(syntheticEvent) => { const { nativeEvent } = syntheticEvent; logger.error("Automation WebView HTTP error:", nativeEvent); remoteControl.handleLoadError(nativeEvent, "automation"); }} javaScriptEnabled={true} domStorageEnabled={true} sharedCookiesEnabled={true} thirdPartyCookiesEnabled={true} startInLoadingState={false} webviewDebuggingEnabled={true} onShouldStartLoadWithRequest={(request) => { return (request.url.startsWith("http://") || request.url.startsWith("https://")); }}/> </View> </Animated.View> </View> </SafeAreaView> </Modal>); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#f5f5f5", }, header: { height: 44, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", paddingHorizontal: 16, backgroundColor: "#ffffff", borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#e0e0e0", }, closeButton: { padding: 8, }, closeButtonText: { fontSize: 24, color: "#333333", }, webViewContainer: { flex: 1, position: "relative", }, webViewWrapper: Object.assign({}, StyleSheet.absoluteFillObject), loadingContainer: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f5", }, });