UNPKG

@getpassage/react-native

Version:

Passage React Native SDK for mobile authentication

1,113 lines (1,109 loc) 70.6 kB
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) {