@getpassage/react-native
Version:
Passage React Native SDK for mobile authentication
1,113 lines (1,109 loc) • 70.6 kB
JavaScript
import { io } from "socket.io-client";
import CookieManager from "@react-native-cookies/cookies";
import { logger } from "./logger";
import { analytics, ANALYTICS_EVENTS } from "./analytics";
import { extractRecordFlag, extractDebugFlag, extractClearCookiesFlag, } from "./jwt-utils";
import { DEFAULT_API_BASE_URL, DEFAULT_SOCKET_NAMESPACE, CONNECT_PATH, AUTOMATION_CONFIG_PATH, AUTOMATION_COMMAND_RESULT_PATH, AUTOMATION_BROWSER_STATE_PATH, DEFAULT_WEB_BASE_URL, } from "./config";
import packageJson from "../package.json";
const WEBVIEW_SWITCH_DELAY = 500; // delay for webview to be ready
export class RemoteControlManager {
constructor() {
this.socket = null;
this.isConnected = false;
this.commandQueue = [];
this.intentToken = null;
this.socketUrl = DEFAULT_API_BASE_URL;
this.apiUrl = DEFAULT_API_BASE_URL;
this.webUrl = DEFAULT_WEB_BASE_URL;
this.cookieDomains = [];
this.globalJavascript = "";
this.userAgent = "";
this.integrationUrl = null;
this.webViewRefs = null;
this.screenshotAccessors = null;
this.captureImageFunction = null;
this.sessionSuccess = false;
this.isNavigatingRef = {
ui: false,
automation: false,
};
this.currentCommand = null;
this.navigationPromiseResolve = null;
this.pageDataPromise = null;
this.currentWebViewType = "ui";
this.hasReceivedFirstCommand = false;
this.currentUserActionRequired = undefined;
this.lastUserActionCommand = null;
this.lastInjectScript = null;
this.lastInjectScriptCommandId = null;
this.lastInjectScriptCommand = null;
this.userActionThrottleTimer = null;
this.userActionThrottleStartTime = null;
this.pendingUserActionRequired = undefined;
this.currentImageUri = null;
}
static getInstance() {
if (!RemoteControlManager.instance) {
RemoteControlManager.instance = new RemoteControlManager();
}
return RemoteControlManager.instance;
}
setWebViewRefs(refs) {
this.webViewRefs = refs;
// Initialize both webviews with their scripts if they're set
if (refs) {
this.initializeWebViews();
}
}
setScreenshotAccessors(accessors) {
this.screenshotAccessors = accessors;
}
setCaptureImageFunction(captureImageFn) {
this.captureImageFunction = captureImageFn;
}
async captureImageAfterInteraction() {
if (this.captureImageFunction) {
try {
logger.debug("[REMOTE CONTROL] Capturing image after waitForInteraction");
const imageUri = await this.captureImageFunction();
if (imageUri) {
this.setCurrentImage(imageUri);
logger.debug("[REMOTE CONTROL] Image captured after waitForInteraction:", imageUri);
}
}
catch (error) {
logger.error("[REMOTE CONTROL] Error capturing image after waitForInteraction:", error);
}
}
else {
logger.debug("[REMOTE CONTROL] No captureImage function available");
}
}
setCurrentImage(imageUri) {
this.currentImageUri = imageUri;
}
async navigateToIntegrationUrl() {
// This method is no longer needed since automation webview starts with integration URL
// Keeping for potential future use
logger.debug("[REMOTE CONTROL] navigateToIntegrationUrl called but skipped (automation webview starts with correct URL)");
}
async initializeWebViews() {
logger.debug("[REMOTE CONTROL] Initializing webviews");
// Note: Global JavaScript is now automatically injected via
// injectedJavaScriptBeforeContentLoaded in the WebView component,
// so no manual injection is needed here. This ensures the global
// JavaScript persists across page navigations.
}
setWebViewSwitchCallback(callback) {
this.onWebViewSwitch = callback || undefined;
}
getUserAgent() {
return this.userAgent;
}
getGlobalJavascript() {
return this.globalJavascript;
}
getRecordFlag() {
if (!this.intentToken)
return false;
return extractRecordFlag(this.intentToken);
}
async completeRecording(data = {}) {
if (!this.currentCommand) {
logger.error("[REMOTE CONTROL] No current command available to complete");
return;
}
logger.debug("[REMOTE CONTROL] Completing recording for current command:", {
commandId: this.currentCommand.id,
commandType: this.currentCommand.type,
hasData: !!data,
});
try {
await this.sendDone(this.currentCommand.id, { data });
// Mark session as successful
this.sessionSuccess = true;
// Track recording completion
analytics.track(ANALYTICS_EVENTS.SDK_ON_SUCCESS, {
commandId: this.currentCommand.id,
recordingCompleted: true,
hasData: !!data,
});
// Show UI webview for final result display
try {
logger.debug("[REMOTE CONTROL] Recording complete - showing UI webview");
await this.switchToUIWebView();
}
catch (error) {
logger.error("Error showing UI webview:", error);
}
// Call success callback if available
if (this.onSuccess) {
const pageData = await this.getPageData();
this.onSuccess({
data,
pageData,
sessionInfo: {
cookies: pageData.cookies || [],
localStorage: pageData.localStorage || [],
sessionStorage: pageData.sessionStorage || [],
},
});
}
// Navigate to success URL in UI webview
await this.navigateToConnectUrl(true);
}
catch (error) {
logger.error("[REMOTE CONTROL] Error completing recording:", error);
// Track recording completion error
analytics.track(ANALYTICS_EVENTS.SDK_ON_ERROR, {
commandId: this.currentCommand.id,
error: error instanceof Error ? error.message : String(error),
recordingCompletionFailed: true,
});
}
}
async captureRecordingData(data = {}) {
if (!this.currentCommand) {
logger.error("[REMOTE CONTROL] No current command available to capture");
return;
}
logger.debug("[REMOTE CONTROL] Capturing recording data for current command:", {
commandId: this.currentCommand.id,
commandType: this.currentCommand.type,
hasData: !!data,
});
try {
// Capture a new screenshot before sending result
if (this.captureImageFunction) {
try {
logger.debug("[REMOTE CONTROL] Capturing new screenshot for recording data");
const imageUri = await this.captureImageFunction();
if (imageUri) {
this.setCurrentImage(imageUri);
logger.debug("[REMOTE CONTROL] New screenshot captured:", imageUri);
}
}
catch (error) {
logger.error("[REMOTE CONTROL] Error capturing screenshot:", error);
}
}
// Send success result instead of done
await this.sendSuccess(this.currentCommand.id, { data });
// Track recording data capture
analytics.track(ANALYTICS_EVENTS.SDK_ON_SUCCESS, {
commandId: this.currentCommand.id,
recordingDataCaptured: true,
hasData: !!data,
});
logger.debug("[REMOTE CONTROL] Recording data captured and sent successfully");
}
catch (error) {
logger.error("[REMOTE CONTROL] Error capturing recording data:", error);
// Track recording data capture error
analytics.track(ANALYTICS_EVENTS.SDK_ON_ERROR, {
commandId: this.currentCommand.id,
error: error instanceof Error ? error.message : String(error),
recordingDataCaptureFailed: true,
});
}
}
shouldReinjectScript() {
const isRecordMode = this.getRecordFlag();
const hasUserActionCommand = !!this.lastUserActionCommand;
// In record mode, reinject on all navigation changes
if (isRecordMode) {
return true;
}
// In non-record mode, only reinject for user action commands
if (hasUserActionCommand && this.currentCommand) {
return true;
}
return false;
}
async checkAndReinjectScript(url) {
var _a;
// Only reinject if record mode is enabled
if (!this.getRecordFlag()) {
return;
}
// Only reinject if we have a script to inject
if (!this.lastInjectScript || !this.lastInjectScriptCommandId) {
return;
}
// Inject the script
const automationWebView = (_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.automation;
if (automationWebView) {
logger.debug("[REMOTE CONTROL] Re-injecting script for record mode:", {
url: logger.truncateData(url, 100),
commandId: this.lastInjectScriptCommandId,
});
const wrappedScript = this.wrapScriptForExecution(this.lastInjectScript, this.lastInjectScriptCommandId);
automationWebView.injectJavaScript(wrappedScript);
}
}
async checkAndApplyJWTFlags(token) {
try {
const hasDebugFlag = extractDebugFlag(token);
const hasRecordFlag = extractRecordFlag(token);
const hasClearCookiesFlag = extractClearCookiesFlag(token);
if (hasDebugFlag) {
logger.debug("[REMOTE CONTROL] Debug flag found in JWT, enabling debug mode");
logger.setDebugMode(true);
}
else if (hasRecordFlag) {
logger.debug("[REMOTE CONTROL] Record flag found in JWT, enabling debug mode");
logger.setDebugMode(true);
}
if (hasClearCookiesFlag) {
logger.debug("[REMOTE CONTROL] Clear cookies flag found in JWT, clearing all cookies");
try {
await CookieManager.clearAll();
logger.debug("[REMOTE CONTROL] All cookies cleared successfully");
}
catch (error) {
logger.error("[REMOTE CONTROL] Error clearing cookies:", error);
}
}
}
catch (error) {
logger.debug("[REMOTE CONTROL] Error checking JWT flags:", error);
}
}
handleNavigationComplete(url) {
logger.debug("[REMOTE CONTROL] Navigation completed:", {
url: logger.truncateData(url, 100),
isRecordMode: this.getRecordFlag(),
hasInjectScriptCommand: !!this.lastInjectScriptCommand,
});
// Only reinject in record mode and if we have an injectScript command
if (!this.getRecordFlag() || !this.lastInjectScriptCommand) {
return;
}
// Only reinject injectScript commands specifically
if (this.lastInjectScriptCommand.type === "injectScript") {
logger.debug("[REMOTE CONTROL] Re-injecting injectScript command after navigation:", {
commandId: this.lastInjectScriptCommand.id,
url: logger.truncateData(url, 100),
});
// Add a delay to ensure page is fully loaded
setTimeout(() => {
var _a;
if (this.lastInjectScriptCommand && ((_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.automation)) {
this.handleScriptExecution(this.lastInjectScriptCommand);
}
}, 1000); // 1 second delay
}
}
getClientUserAgent() {
// Return a default React Native user agent string
// This identifies the client as the Passage React Native SDK
return `passage-react-native/${packageJson.version}`;
}
setConfigurationCallback(callback) {
this.onConfigurationUpdated = callback || undefined;
}
async switchToUIWebView() {
if (this.currentWebViewType === "ui") {
logger.debug("[REMOTE CONTROL] Already showing UI webview");
return;
}
logger.debug("[REMOTE CONTROL] Switching to UI webview");
this.currentWebViewType = "ui";
// Track webview switch
analytics.track(ANALYTICS_EVENTS.SDK_WEBVIEW_SWITCH, {
from: "automation",
to: "ui",
});
if (this.onWebViewSwitch) {
this.onWebViewSwitch("ui");
}
// Add delay for transition
await new Promise((resolve) => setTimeout(resolve, 300));
}
async switchToAutomationWebView() {
if (this.currentWebViewType === "automation") {
logger.debug("[REMOTE CONTROL] Already showing automation webview");
return;
}
logger.debug("[REMOTE CONTROL] Switching to automation webview");
this.currentWebViewType = "automation";
// Track webview switch
analytics.track(ANALYTICS_EVENTS.SDK_WEBVIEW_SWITCH, {
from: "ui",
to: "automation",
});
if (this.onWebViewSwitch) {
this.onWebViewSwitch("automation");
}
// Add delay for transition
await new Promise((resolve) => setTimeout(resolve, 300));
}
getActiveWebView() {
if (!this.webViewRefs)
return null;
return this.currentWebViewType === "ui"
? this.webViewRefs.ui
: this.webViewRefs.automation;
}
// Navigation state handlers
handleLoadStart(webViewType) {
logger.debug(`[REMOTE CONTROL] [${webViewType.toUpperCase()} WebView] navigation started`);
this.isNavigatingRef[webViewType] = true;
}
handleLoadEnd(webViewType) {
logger.debug(`[REMOTE CONTROL] [${webViewType.toUpperCase()} WebView] navigation ended`);
this.isNavigatingRef[webViewType] = false;
// Resolve any pending navigation promise
if (this.navigationPromiseResolve && webViewType === "automation") {
this.navigationPromiseResolve();
this.navigationPromiseResolve = null;
}
// Re-execute script if needed (for automation webview only)
if (webViewType === "automation") {
const shouldReinject = this.shouldReinjectScript();
if (shouldReinject &&
this.lastInjectScript &&
this.lastInjectScriptCommandId) {
logger.debug("[REMOTE CONTROL] Re-injecting script after navigation for command:", {
commandId: this.lastInjectScriptCommandId,
isRecordMode: this.getRecordFlag(),
hasUserActionCommand: !!this.lastUserActionCommand,
});
setTimeout(() => {
var _a;
if (this.lastInjectScript &&
this.lastInjectScriptCommandId &&
((_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.automation)) {
const wrappedScript = this.wrapScriptForExecution(this.lastInjectScript, this.lastInjectScriptCommandId);
this.webViewRefs.automation.injectJavaScript(wrappedScript);
}
}, 2000); // 2 second delay to let page load
}
}
}
handleLoadError(error, webViewType) {
logger.debug(`[REMOTE CONTROL] [${webViewType.toUpperCase()} WebView] navigation error:`, error);
this.isNavigatingRef[webViewType] = false;
// Resolve any pending navigation promise with error
if (this.navigationPromiseResolve && webViewType === "automation") {
this.navigationPromiseResolve();
this.navigationPromiseResolve = null;
}
}
async fetchConfiguration(apiUrl, intentToken) {
var _a;
logger.debug("[REMOTE CONTROL] Fetching configuration", {
apiUrl,
configPath: AUTOMATION_CONFIG_PATH,
hasIntentToken: !!intentToken,
});
// Track configuration request start
analytics.track(ANALYTICS_EVENTS.SDK_CONFIGURATION_REQUEST, {
apiUrl: logger.truncateData(apiUrl, 100),
});
try {
const response = await fetch(`${apiUrl}${AUTOMATION_CONFIG_PATH}`, {
method: "GET",
headers: {
"x-intent-token": intentToken,
},
});
logger.debug("[REMOTE CONTROL] Configuration response status:", response.status);
if (response.ok) {
const config = await response.json();
this.cookieDomains = config.cookieDomains || [];
this.globalJavascript = config.globalJavascript || "";
this.userAgent = config.automationUserAgent || "";
this.integrationUrl = ((_a = config.integration) === null || _a === void 0 ? void 0 : _a.url) || null;
logger.debug("[REMOTE CONTROL] Configuration received:", {
cookieDomainCount: this.cookieDomains.length,
hasGlobalJavascript: !!this.globalJavascript,
hasUserAgent: !!this.userAgent,
userAgent: this.userAgent,
hasBaseWebUrl: !!config.baseWebUrl,
integrationUrl: this.integrationUrl,
});
// Update webUrl if provided in configuration
if (config.baseWebUrl) {
logger.debug("[REMOTE CONTROL] Overriding webUrl from config:", config.baseWebUrl);
this.webUrl = config.baseWebUrl;
}
// Inject global JavaScript if provided
await this.initializeWebViews();
// Notify about configuration update
if (this.onConfigurationUpdated) {
this.onConfigurationUpdated({
userAgent: this.userAgent,
integrationUrl: this.integrationUrl || undefined,
});
}
// Track successful configuration
analytics.track(ANALYTICS_EVENTS.SDK_CONFIGURATION_SUCCESS, {
hasCookieDomains: this.cookieDomains.length > 0,
hasGlobalJavascript: !!this.globalJavascript,
hasBaseWebUrl: !!config.baseWebUrl,
hasUserAgent: !!this.userAgent,
});
return true;
}
else {
logger.error("Failed to fetch automation configuration:", response.status);
// Track configuration error
analytics.track(ANALYTICS_EVENTS.SDK_CONFIGURATION_ERROR, {
status: response.status,
error: "HTTP error response",
});
// Session is invalid - trigger error callback
if (this.onError) {
this.onError({
error: "Session is not valid anymore",
data: { statusCode: response.status },
});
}
return false;
}
}
catch (error) {
logger.error("Error fetching automation configuration:", error);
// Track configuration error
analytics.track(ANALYTICS_EVENTS.SDK_CONFIGURATION_ERROR, {
error: error instanceof Error ? error.message : String(error),
});
// Network or other error - trigger error callback
if (this.onError) {
this.onError({
error: "Failed to validate session",
data: { originalError: error },
});
}
return false;
}
}
async connect(socketUrl, socketNamespace = DEFAULT_SOCKET_NAMESPACE, intentToken, callbacks, apiUrl = DEFAULT_API_BASE_URL, webUrl = DEFAULT_WEB_BASE_URL) {
var _a;
// Track remote control connect start
analytics.track(ANALYTICS_EVENTS.SDK_REMOTE_CONTROL_CONNECT_START, {
socketUrl: logger.truncateData(socketUrl, 100),
namespace: socketNamespace,
hasIntentToken: !!intentToken,
hasCallbacks: !!callbacks,
hasBaseWebUrl: !!webUrl,
});
if ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.connected) {
logger.debug("[REMOTE CONTROL] Already connected to remote control server");
return;
}
if (!intentToken) {
throw new Error("Intent token is required for remote control connection");
}
logger.debug("[REMOTE CONTROL] Starting connection", {
socketUrl,
socketNamespace,
apiUrl,
webUrl,
hasSuccessCallback: !!(callbacks === null || callbacks === void 0 ? void 0 : callbacks.onSuccess),
hasErrorCallback: !!(callbacks === null || callbacks === void 0 ? void 0 : callbacks.onError),
hasDataCompleteCallback: !!(callbacks === null || callbacks === void 0 ? void 0 : callbacks.onDataComplete),
hasPromptCompleteCallback: !!(callbacks === null || callbacks === void 0 ? void 0 : callbacks.onPromptComplete),
});
this.socketUrl = socketUrl;
this.apiUrl = apiUrl;
this.webUrl = webUrl;
this.sessionSuccess = false;
// Reset state
this.hasReceivedFirstCommand = false;
this.currentUserActionRequired = undefined;
this.currentWebViewType = "ui";
this.lastUserActionCommand = null;
this.lastInjectScript = null;
this.lastInjectScriptCommandId = null;
this.lastInjectScriptCommand = null;
logger.debug("[REMOTE CONTROL] State reset, showing UI webview");
// Show UI webview by default
await this.switchToUIWebView();
if (callbacks) {
this.onSuccess = callbacks.onSuccess;
this.onError = callbacks.onError;
this.onDataComplete = callbacks.onDataComplete;
this.onPromptComplete = callbacks.onPromptComplete;
}
this.intentToken = intentToken;
// Check for JWT flags and apply them (debug mode, clear cookies, etc.)
await this.checkAndApplyJWTFlags(intentToken);
// Check if configuration fetch is successful (validates session)
const configSuccess = await this.fetchConfiguration(this.apiUrl, this.intentToken);
if (!configSuccess) {
// Session is invalid, don't proceed with connection
logger.debug("[REMOTE CONTROL] Configuration fetch failed, aborting connection");
// Track remote control connect error
analytics.track(ANALYTICS_EVENTS.SDK_REMOTE_CONTROL_CONNECT_ERROR, {
error: "Configuration fetch failed",
socketUrl: logger.truncateData(socketUrl, 100),
});
return;
}
logger.debug(`[REMOTE CONTROL] Connecting to socket server at ${socketUrl}${socketNamespace}`);
this.socket = io(`${socketUrl}${socketNamespace}`, {
transports: ["websocket", "polling"],
timeout: 10000,
forceNew: true,
query: {
intentToken: this.intentToken,
userAgent: this.getClientUserAgent(),
},
});
this.setupEventHandlers();
}
async connectHeadless(socketUrl, socketNamespace = DEFAULT_SOCKET_NAMESPACE, intentToken, callbacks, apiUrl = DEFAULT_API_BASE_URL, webUrl = DEFAULT_WEB_BASE_URL) {
var _a;
if ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.connected) {
logger.log("Already connected to remote control server");
return;
}
this.socketUrl = socketUrl;
this.apiUrl = apiUrl;
this.webUrl = webUrl;
this.intentToken = intentToken;
// Check for JWT flags and apply them (debug mode, clear cookies, etc.)
await this.checkAndApplyJWTFlags(intentToken);
// Check if configuration fetch is successful (validates session)
const configSuccess = await this.fetchConfiguration(this.apiUrl, this.intentToken);
if (!configSuccess) {
// Session is invalid, don't proceed with connection
logger.debug("[REMOTE CONTROL] Configuration fetch failed, aborting connection (headless)");
return;
}
logger.debug(`[REMOTE CONTROL] Connecting to socket server (headless) at ${socketUrl}${socketNamespace}`);
this.socket = io(`${socketUrl}${socketNamespace}`, {
transports: ["websocket", "polling"],
timeout: 10000,
forceNew: true,
query: {
intentToken: this.intentToken,
userAgent: this.getClientUserAgent(),
},
});
// Setup headless event handlers
if (!this.socket)
return;
this.socket.on("connect", () => {
logger.debug("[REMOTE CONTROL] Connected to socket server (headless)");
this.isConnected = true;
});
this.socket.on("disconnect", (reason) => {
var _a;
logger.debug("[REMOTE CONTROL] Disconnected from socket server (headless):", reason);
this.isConnected = false;
(_a = callbacks === null || callbacks === void 0 ? void 0 : callbacks.onClose) === null || _a === void 0 ? void 0 : _a.call(callbacks);
});
this.socket.on("connect_error", (error) => {
var _a;
logger.error("[REMOTE CONTROL] Socket connection error (headless):", error.message);
(_a = callbacks === null || callbacks === void 0 ? void 0 : callbacks.onError) === null || _a === void 0 ? void 0 : _a.call(callbacks, {
error: error.message,
data: error,
});
});
this.socket.on("message", (data) => {
var _a;
logger.debug("[REMOTE CONTROL] Received message (headless):", data);
(_a = callbacks === null || callbacks === void 0 ? void 0 : callbacks.onMessage) === null || _a === void 0 ? void 0 : _a.call(callbacks, data);
});
this.socket.on("error", (error) => {
var _a;
logger.error("Socket error (headless):", error);
(_a = callbacks === null || callbacks === void 0 ? void 0 : callbacks.onError) === null || _a === void 0 ? void 0 : _a.call(callbacks, {
error: error.message || "Socket error",
data: error,
});
});
}
throttleUserActionChange(userActionRequired) {
// Store the pending value
this.pendingUserActionRequired = userActionRequired;
// Initialize the start time if this is the first event in the burst
if (this.userActionThrottleStartTime === null) {
this.userActionThrottleStartTime = Date.now();
}
// Clear existing timer if any so we can reschedule based on remaining time
if (this.userActionThrottleTimer) {
clearTimeout(this.userActionThrottleTimer);
}
// Compute remaining time to reach a total of 100ms since the first event
const elapsedMs = Date.now() - (this.userActionThrottleStartTime || Date.now());
const remainingMs = Math.max(0, 100 - elapsedMs);
this.userActionThrottleTimer = setTimeout(() => {
this.applyUserActionChange();
}, remainingMs);
logger.debug("[REMOTE CONTROL] Throttling userActionRequired change:", {
newValue: userActionRequired,
currentValue: this.currentUserActionRequired,
});
}
async applyUserActionChange() {
const newUserActionRequired = this.pendingUserActionRequired;
const previousUserActionRequired = this.currentUserActionRequired;
// Clear the timer and pending value
this.userActionThrottleTimer = null;
this.userActionThrottleStartTime = null;
this.pendingUserActionRequired = undefined;
if (newUserActionRequired === undefined) {
logger.debug("[REMOTE CONTROL] No pending userActionRequired change to apply");
return;
}
// Only update if the value actually changed
if (previousUserActionRequired === newUserActionRequired) {
logger.debug("[REMOTE CONTROL] userActionRequired unchanged after throttle, skipping webview switch");
return;
}
this.currentUserActionRequired = newUserActionRequired;
logger.debug("[REMOTE CONTROL] Applying throttled userActionRequired change:", {
userActionRequired: newUserActionRequired,
previousUserActionRequired,
currentWebViewType: this.currentWebViewType,
});
// Show/hide webviews based on userActionRequired
if (newUserActionRequired) {
// User action required - show automation webview for user interaction
logger.debug("[REMOTE CONTROL] User action required - showing automation webview");
await this.switchToAutomationWebView();
}
else {
// No user action required - show UI webview (scripts run in background automation webview)
logger.debug("[REMOTE CONTROL] No user action required - showing UI webview");
await this.switchToUIWebView();
// Add a small delay to ensure webview switch completes
if (previousUserActionRequired === true) {
logger.debug("[REMOTE CONTROL] Waiting for webview switch to complete");
await new Promise((resolve) => setTimeout(resolve, 300));
}
}
}
setupEventHandlers() {
if (!this.socket)
return;
this.socket.on("connect", () => {
logger.debug("[REMOTE CONTROL] Connected to socket server");
this.isConnected = true;
// Track successful remote control connect
analytics.track(ANALYTICS_EVENTS.SDK_REMOTE_CONTROL_CONNECT_SUCCESS, {
socketUrl: logger.truncateData(this.socketUrl, 100),
});
this.processCommandQueue();
});
this.socket.on("disconnect", (reason) => {
logger.debug("[REMOTE CONTROL] Disconnected from socket server:", reason);
this.isConnected = false;
});
this.socket.on("connect_error", (error) => {
logger.error("[REMOTE CONTROL] Socket connection error:", error.message);
// Track remote control connect error
analytics.track(ANALYTICS_EVENTS.SDK_REMOTE_CONTROL_CONNECT_ERROR, {
error: error.message || "Socket connection error",
socketUrl: logger.truncateData(this.socketUrl, 100),
});
});
this.socket.on("command", async (cmd) => {
const logData = Object.assign(Object.assign({}, cmd), { args: Object.assign(Object.assign({}, cmd.args), { injectScript: undefined }) });
logger.debug("[REMOTE CONTROL] Received command:", logData);
await this.handleCommand(cmd);
});
this.socket.on("welcome", (data) => {
logger.debug("[REMOTE CONTROL] Welcome message from server:", data);
});
this.socket.on("DATA_COMPLETE", (data) => {
logger.debug("[REMOTE CONTROL] Received DATA_COMPLETE event:", data);
if (this.onDataComplete) {
this.onDataComplete(data);
}
});
this.socket.on("PROMPT_COMPLETE", (prompt) => {
logger.debug("[REMOTE CONTROL] Received PROMPT_COMPLETE event:", prompt);
if (this.onPromptComplete) {
this.onPromptComplete(prompt);
}
});
this.socket.on("connection", (data) => {
logger.debug("[REMOTE CONTROL] Received connection event:", data);
if (data.userActionRequired !== undefined) {
this.throttleUserActionChange(data.userActionRequired);
}
});
}
async handleCommand(cmd) {
// Track command received
analytics.track(ANALYTICS_EVENTS.SDK_COMMAND_RECEIVED, {
commandType: cmd.type,
commandId: cmd.id,
userActionRequired: cmd.userActionRequired,
hasInjectScript: !!cmd.injectScript,
});
// Don't execute any more commands if success has been achieved
if (this.sessionSuccess) {
logger.debug("[REMOTE CONTROL] Session already successful, ignoring command:", cmd.type);
await this.sendSuccess(cmd.id, {
message: "Session already completed successfully",
});
return;
}
try {
logger.debug("[REMOTE CONTROL] Handling command:", {
type: cmd.type,
userActionRequired: cmd.userActionRequired,
hasFirstCommand: this.hasReceivedFirstCommand,
currentUserActionRequired: this.currentUserActionRequired,
currentWebViewType: this.currentWebViewType,
});
// Mark that we've received the first command
if (!this.hasReceivedFirstCommand) {
this.hasReceivedFirstCommand = true;
}
// Store user action commands for potential re-execution after navigation
if (cmd.userActionRequired &&
(cmd.type === "wait" || cmd.type === "click" || cmd.type === "input")) {
this.lastUserActionCommand = cmd;
logger.debug("[REMOTE CONTROL] Stored user action command for potential re-execution:", { type: cmd.type, id: cmd.id });
}
// Store inject script for all commands that have one (for record mode reinjection)
if (cmd.injectScript) {
this.lastInjectScript = cmd.injectScript;
this.lastInjectScriptCommandId = cmd.id;
logger.debug("[REMOTE CONTROL] Stored inject script for potential re-execution:", { type: cmd.type, id: cmd.id, isRecordMode: this.getRecordFlag() });
}
// Store injectScript commands specifically for record mode reinjection on navigation
if (cmd.type === "injectScript") {
this.lastInjectScriptCommand = cmd;
logger.debug("[REMOTE CONTROL] Stored injectScript command for record mode reinjection:", { id: cmd.id, isRecordMode: this.getRecordFlag() });
}
// Store current command
this.currentCommand = cmd;
// Execute the command
logger.debug(`[REMOTE CONTROL] Executing ${cmd.type} command`);
switch (cmd.type) {
case "navigate":
await this.handleNavigate(cmd);
break;
case "click":
case "input":
case "wait":
case "injectScript":
await this.handleScriptExecution(cmd);
break;
case "done":
await this.handleDone(cmd);
break;
default:
await this.sendError(cmd.id, `Unknown command type: ${cmd.type}`);
}
// Track successful command completion
analytics.track(ANALYTICS_EVENTS.SDK_COMMAND_SUCCESS, {
commandType: cmd.type,
commandId: cmd.id,
});
}
catch (error) {
logger.error("Error handling command:", error);
// Track command error
analytics.track(ANALYTICS_EVENTS.SDK_COMMAND_ERROR, {
commandType: cmd.type,
commandId: cmd.id,
error: error instanceof Error ? error.message : String(error),
});
await this.sendError(cmd.id, error instanceof Error ? error.message : String(error));
}
}
async handleNavigate(cmd) {
var _a, _b;
const url = (_a = cmd.args) === null || _a === void 0 ? void 0 : _a.url;
if (!url) {
await this.sendError(cmd.id, "No URL provided for navigation");
return;
}
// Track navigation start
analytics.track(ANALYTICS_EVENTS.SDK_NAVIGATION_START, {
commandId: cmd.id,
url: logger.truncateData(url, 100),
});
try {
const automationWebView = (_b = this.webViewRefs) === null || _b === void 0 ? void 0 : _b.automation;
if (automationWebView) {
logger.debug(`[REMOTE CONTROL] Starting navigation to: ${url}`);
// Navigate by injecting JavaScript in automation webview
automationWebView.injectJavaScript(`window.location.href = '${url}';`);
// Poll for navigation completion
const timeout = Date.now() + 30000; // 30 second timeout
const checkInterval = 1000; // Check every second
const pollForCompletion = () => {
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
if (!this.isNavigatingRef.automation) {
// Navigation completed
clearInterval(interval);
// Get page data after navigation
const pageData = await this.getPageData();
// Track successful navigation
analytics.track(ANALYTICS_EVENTS.SDK_NAVIGATION_SUCCESS, {
commandId: cmd.id,
url: logger.truncateData(url, 100),
});
await this.sendSuccess(cmd.id, {
pageData: pageData,
});
resolve();
}
else if (Date.now() > timeout) {
// Timeout
clearInterval(interval);
// Track navigation error
analytics.track(ANALYTICS_EVENTS.SDK_NAVIGATION_ERROR, {
commandId: cmd.id,
url: logger.truncateData(url, 100),
error: "Navigation timed out",
});
await this.sendError(cmd.id, "Navigation timed out");
reject(new Error("Navigation timed out"));
}
}, checkInterval);
});
};
await pollForCompletion();
}
else {
// Track navigation error
analytics.track(ANALYTICS_EVENTS.SDK_NAVIGATION_ERROR, {
commandId: cmd.id,
url: logger.truncateData(url, 100),
error: "Automation WebView reference not available",
});
await this.sendError(cmd.id, "Automation WebView reference not available");
}
}
catch (error) {
// Track navigation error
analytics.track(ANALYTICS_EVENTS.SDK_NAVIGATION_ERROR, {
commandId: cmd.id,
url: logger.truncateData(url, 100),
error: error instanceof Error ? error.message : String(error),
});
await this.sendError(cmd.id, `Navigation failed: ${error}`);
}
}
wrapScriptForExecution(script, commandId) {
return `
(function() {
try {
${script}
// Post result back
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'commandResult',
commandId: '${commandId}',
status: 'success',
data: typeof result !== 'undefined' ? result : null
}));
} catch (error) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'commandResult',
commandId: '${commandId}',
status: 'error',
error: error.toString()
}));
}
})();
`;
}
async handleScriptExecution(cmd) {
var _a;
if (!cmd.injectScript) {
await this.sendError(cmd.id, "No script provided for execution");
return;
}
try {
const automationWebView = (_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.automation;
if (automationWebView) {
// Check if this is a wait command that uses window.passage.postMessage
const isAsyncScript = cmd.injectScript.includes("async function") || cmd.type === "wait";
const usesWindowPassage = cmd.injectScript.includes("window.passage.postMessage");
if (isAsyncScript && usesWindowPassage) {
logger.debug("[REMOTE CONTROL] Executing async script with window.passage");
// For async scripts, inject and let them post their own results
automationWebView.injectJavaScript(cmd.injectScript + "; undefined;");
// Don't send immediate success - the script will post its own result
logger.debug("[REMOTE CONTROL] Async script started, waiting for result...");
}
else {
// For synchronous scripts, wrap and execute
const wrappedScript = this.wrapScriptForExecution(cmd.injectScript, cmd.id);
automationWebView.injectJavaScript(wrappedScript);
// For immediate response, send success
await this.sendSuccess(cmd.id, {});
}
}
else {
await this.sendError(cmd.id, "Automation WebView reference not available");
}
}
catch (error) {
await this.sendError(cmd.id, `Script execution failed: ${error}`);
}
}
async handleDone(cmd) {
var _a, _b, _c;
try {
const success = (_b = (_a = cmd.args) === null || _a === void 0 ? void 0 : _a.success) !== null && _b !== void 0 ? _b : true;
const data = (_c = cmd.args) === null || _c === void 0 ? void 0 : _c.data;
logger.debug("[REMOTE CONTROL] Handling done command:", {
success,
data,
});
// Show UI webview for final result display
try {
logger.debug("[REMOTE CONTROL] Process complete - showing UI webview");
await this.switchToUIWebView();
}
catch (error) {
logger.error("Error showing UI webview:", error);
}
if (success) {
await this.sendSuccess(cmd.id, { data });
this.sessionSuccess = true; // Mark session as successful
// Track SDK success
analytics.track(ANALYTICS_EVENTS.SDK_ON_SUCCESS, {
commandId: cmd.id,
hasData: !!data,
});
if (this.onSuccess) {
const pageData = await this.getPageData();
this.onSuccess({
data,
pageData,
sessionInfo: {
cookies: pageData.cookies || [],
localStorage: pageData.localStorage || [],
sessionStorage: pageData.sessionStorage || [],
},
});
}
// Navigate to success URL in UI webview
await this.navigateToConnectUrl(true);
}
else {
const errorMessage = (data === null || data === void 0 ? void 0 : data.error) || "Done command indicates failure";
await this.sendError(cmd.id, errorMessage);
// Track SDK error
analytics.track(ANALYTICS_EVENTS.SDK_ON_ERROR, {
commandId: cmd.id,
error: errorMessage,
});
if (this.onError) {
this.onError({ error: errorMessage, data });
}
// Navigate to error URL in UI webview
await this.navigateToConnectUrl(false, errorMessage);
}
}
catch (error) {
const errorMessage = `Done command failed: ${error}`;
await this.sendError(cmd.id, errorMessage);
// Track SDK error
analytics.track(ANALYTICS_EVENTS.SDK_ON_ERROR, {
commandId: cmd.id,
error: errorMessage,
});
if (this.onError) {
this.onError({ error: errorMessage });
}
}
}
async navigateToConnectUrl(success, error) {
try {
const webUrl = new URL(`${this.webUrl}${CONNECT_PATH}`);
webUrl.searchParams.set("intentToken", this.intentToken || "");
webUrl.searchParams.set("success", success.toString());
if (error) {
webUrl.searchParams.set("error", error);
}
const finalUrl = webUrl.toString();
logger.debug("[REMOTE CONTROL] Navigating to connect URL:", finalUrl);
// Navigate in UI webview with delay
setTimeout(() => {
var _a;
const uiWebView = (_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.ui;
if (uiWebView) {
uiWebView.injectJavaScript(`window.location.href = '${finalUrl}';`);
}
}, WEBVIEW_SWITCH_DELAY);
}
catch (error) {
logger.error("Error constructing/navigating to connect URL:", error);
}
}
async getCurrentUrl() {
return new Promise((resolve) => {
var _a;
const automationWebView = (_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.automation;
if (automationWebView) {
automationWebView.injectJavaScript(`
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'currentUrl',
url: window.location.href
}));
`);
// In practice, you'd handle this via onMessage
setTimeout(() => resolve(null), 1000);
}
else {
resolve(null);
}
});
}
async getPageData() {
var _a, _b, _c;
try {
// Create promise for page data
const pageDataPromise = new Promise((resolve, reject) => {
this.pageDataPromise = { resolve, reject };
// Timeout after 5 seconds
setTimeout(() => {
if (this.pageDataPromise) {
this.pageDataPromise = null;
reject(new Error("Page data timeout"));
}
}, 5000);
});
const pageDataScript = `
(function() {
const localStorageItems = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
localStorageItems.push({ name: key, value: localStorage.getItem(key) });
}
const sessionStorageItems = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
sessionStorageItems.push({ name: key, value: sessionStorage.getItem(key) });
}
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'pageData',
data: {
url: window.location.href,
html: document.documentElement.outerHTML,
localStorage: localStorageItems,
sessionStorage: sessionStorageItems
}
}));
})();
`;
const automationWebView = (_a = this.webViewRefs) === null || _a === void 0 ? void 0 : _a.automation;
if (automationWebView) {
automationWebView.injectJavaScript(pageDataScript);
}
// Wait for page data
const webViewData = await pageDataPromise.catch(() => ({
url: null,
html: null,
localStorage: [],
sessionStorage: [],
}));
// Get cookies
const allCookies = [];
if (this.cookieDomains && this.cookieDomains.length > 0) {