@getpassage/react-native
Version:
Passage React Native SDK for mobile authentication
441 lines (429 loc) • 18.5 kB
JavaScript
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",
},
});