1434 lines
49 KiB
JavaScript
1434 lines
49 KiB
JavaScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import WebView from 'react-native-webview';
|
|
import customUUID from '../app/functions/customUUID';
|
|
import EventEmitter from 'events';
|
|
import { sha256 } from '@noble/hashes/sha2';
|
|
import { hkdf } from '@noble/hashes/hkdf';
|
|
import { AppState, Platform } from 'react-native';
|
|
import { getSharedSecret, getPublicKey } from '@noble/secp256k1';
|
|
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
randomBytes,
|
|
} from 'react-native-quick-crypto';
|
|
import sha256Hash from '../app/functions/hash';
|
|
import { verifyAndPrepareWebView } from '../app/functions/webview/bundleVerification';
|
|
import DeviceInfo, {
|
|
getModel,
|
|
getSystemVersion,
|
|
} from 'react-native-device-info';
|
|
import { getLocalStorageItem, setLocalStorageItem } from '../app/functions';
|
|
import { useAppStatus } from './appStatus';
|
|
import { useActiveCustodyAccount } from './activeAccount';
|
|
import { useAuthContext } from './authContext';
|
|
import { navigationRef } from '../navigation/navigationService';
|
|
|
|
export const OPERATION_TYPES = {
|
|
// Spark
|
|
initWallet: 'initializeSparkWallet',
|
|
getIdentityKey: 'getSparkIdentityPubKey',
|
|
getBalance: 'getSparkBalance',
|
|
getL1Address: 'getSparkStaticBitcoinL1Address',
|
|
queryStaticL1Address: 'queryAllStaticDepositAddresses',
|
|
getUtxosForDepositAddress: 'getUtxosForDepositAddress',
|
|
getL1AddressQuote: 'getSparkStaticBitcoinL1AddressQuote',
|
|
claimStaticDepositAddress: 'claimnSparkStaticDepositAddress',
|
|
getSparkAddress: 'getSparkAddress',
|
|
sendSparkPayment: 'sendSparkPayment',
|
|
sendTokenPayment: 'sendSparkTokens',
|
|
getLightningFee: 'getSparkLightningPaymentFeeEstimate',
|
|
getBitcoinPaymentRequest: 'getSparkBitcoinPaymentRequest',
|
|
getBitcoinPaymentFee: 'getSparkBitcoinPaymentFeeEstimate',
|
|
getSparkPaymentFee: 'getSparkPaymentFeeEstimate',
|
|
receiveLightningPayment: 'receiveSparkLightningPayment',
|
|
getLightningSendRequest: 'getSparkLightningSendRequest',
|
|
getLightningPaymentStatus: 'getSparkLightningPaymentStatus',
|
|
sendLightningPayment: 'sendSparkLightningPayment',
|
|
sendBitcoinPayment: 'sendSparkBitcoinPayment',
|
|
getTransactions: 'getSparkTransactions',
|
|
getTokenTransactions: 'getSparkTokenTransactions',
|
|
addListeners: 'addWalletEventListener',
|
|
removeListeners: 'removeWalletEventListener',
|
|
setPrivacyEnabled: 'setPrivacyEnabled',
|
|
getSingleTxDetails: 'getSingleTxDetails',
|
|
createSatsInvoice: 'createSatsInvoice',
|
|
createTokensInvoice: 'createTokensInvoice',
|
|
claimSparkHodlLightningPayment: 'claimSparkHodlLightningPayment',
|
|
receiveSparkHodlLightningPayment: 'receiveSparkHodlLightningPayment',
|
|
querySparkHodlLightningPayments: 'querySparkHodlLightningPayments',
|
|
|
|
// Flashnet
|
|
initializeFlashnet: 'initializeFlashnet',
|
|
listFlashnetPools: 'listFlashnetPools',
|
|
findBestPool: 'findBestPool',
|
|
getPoolDetails: 'getPoolDetails',
|
|
listAllPools: 'listAllPools',
|
|
minFlashnetSwapAmounts: 'minFlashnetSwapAmounts',
|
|
simulateSwap: 'simulateSwap',
|
|
executeSwap: 'executeSwap',
|
|
swapBitcoinToToken: 'swapBitcoinToToken',
|
|
swapTokenToBitcoin: 'swapTokenToBitcoin',
|
|
getLightningPaymentQuote: 'getLightningPaymentQuote',
|
|
payLightningWithToken: 'payLightningWithToken',
|
|
getUserSwapHistory: 'getUserSwapHistory',
|
|
requestClawback: 'requestClawback',
|
|
checkClawbackEligibility: 'checkClawbackEligibility',
|
|
checkClawbackStatus: 'checkClawbackStatus',
|
|
requestBatchClawback: 'requestBatchClawback',
|
|
listClawbackableTransfers: 'listClawbackableTransfers',
|
|
|
|
// Wallet optimizations
|
|
abortOptimization: 'abortOptimization',
|
|
isOptimizationRunning: 'isOptimizationRunning',
|
|
checkIfOptimizationNeeded: 'checkIfOptimizationNeeded',
|
|
runLeafOptimization: 'runLeafOptimization',
|
|
runTokenOptimization: 'runTokenOptimization',
|
|
};
|
|
|
|
const longOperations = [
|
|
OPERATION_TYPES.claimStaticDepositAddress,
|
|
OPERATION_TYPES.sendSparkPayment,
|
|
OPERATION_TYPES.sendTokenPayment,
|
|
OPERATION_TYPES.getBitcoinPaymentRequest,
|
|
OPERATION_TYPES.getBitcoinPaymentFee,
|
|
OPERATION_TYPES.sendLightningPayment,
|
|
OPERATION_TYPES.sendBitcoinPayment,
|
|
OPERATION_TYPES.initWallet,
|
|
OPERATION_TYPES.initializeFlashnet,
|
|
OPERATION_TYPES.executeSwap,
|
|
OPERATION_TYPES.swapBitcoinToToken,
|
|
OPERATION_TYPES.swapTokenToBitcoin,
|
|
OPERATION_TYPES.payLightningWithToken,
|
|
OPERATION_TYPES.requestClawback,
|
|
OPERATION_TYPES.runLeafOptimization,
|
|
OPERATION_TYPES.runTokenOptimization,
|
|
OPERATION_TYPES.claimSparkHodlLightningPayment,
|
|
OPERATION_TYPES.receiveSparkHodlLightningPayment,
|
|
];
|
|
|
|
const mediumOperations = [
|
|
OPERATION_TYPES.getBalance,
|
|
OPERATION_TYPES.queryStaticL1Address,
|
|
OPERATION_TYPES.getUtxosForDepositAddress,
|
|
OPERATION_TYPES.getL1AddressQuote,
|
|
OPERATION_TYPES.getSparkAddress,
|
|
OPERATION_TYPES.getL1Address,
|
|
OPERATION_TYPES.receiveLightningPayment,
|
|
OPERATION_TYPES.getLightningSendRequest,
|
|
OPERATION_TYPES.getLightningPaymentStatus,
|
|
OPERATION_TYPES.getTransactions,
|
|
OPERATION_TYPES.getSingleTxDetails,
|
|
OPERATION_TYPES.getTokenTransactions,
|
|
OPERATION_TYPES.setPrivacyEnabled,
|
|
OPERATION_TYPES.simulateSwap,
|
|
OPERATION_TYPES.requestBatchClawback,
|
|
OPERATION_TYPES.checkIfOptimizationNeeded,
|
|
OPERATION_TYPES.listClawbackableTransfers,
|
|
];
|
|
|
|
const rejectIfNotConnectedToInternet = [
|
|
OPERATION_TYPES.claimStaticDepositAddress,
|
|
OPERATION_TYPES.sendSparkPayment,
|
|
OPERATION_TYPES.sendTokenPayment,
|
|
OPERATION_TYPES.getBitcoinPaymentRequest,
|
|
OPERATION_TYPES.getBitcoinPaymentFee,
|
|
OPERATION_TYPES.sendLightningPayment,
|
|
OPERATION_TYPES.sendBitcoinPayment,
|
|
OPERATION_TYPES.receiveLightningPayment,
|
|
OPERATION_TYPES.getL1Address,
|
|
OPERATION_TYPES.getSparkAddress,
|
|
];
|
|
|
|
export const INCOMING_SPARK_TX_NAME = 'RECEIVED_CONTACTS EVENT';
|
|
export const incomingSparkTransaction = new EventEmitter();
|
|
const WASM_ERRORS = [
|
|
'WASM',
|
|
'WebAssembly',
|
|
'WebAssembly.Compile is disallowed on the main thread',
|
|
"Cannot read properties of undefined (reading '__wbindgen_malloc')",
|
|
];
|
|
let handshakeComplete = false;
|
|
let forceReactNativeUse = null;
|
|
let globalSendWebViewRequest = null;
|
|
let webviewFailureCount = 0;
|
|
const MAX_WEBVIEW_FAILURES = 2;
|
|
|
|
const WV_STATES = {
|
|
UNLOADED: 'unloaded',
|
|
VERIFYING: 'verifying',
|
|
LOADING: 'loading',
|
|
LOADED: 'loaded',
|
|
HANDSHAKING: 'handshaking',
|
|
READY: 'ready',
|
|
ERROR: 'error',
|
|
};
|
|
|
|
const VALID_TRANSITIONS = {
|
|
[WV_STATES.UNLOADED]: [WV_STATES.VERIFYING],
|
|
[WV_STATES.VERIFYING]: [WV_STATES.LOADING, WV_STATES.ERROR],
|
|
[WV_STATES.LOADING]: [WV_STATES.LOADED, WV_STATES.ERROR, WV_STATES.UNLOADED],
|
|
[WV_STATES.LOADED]: [
|
|
WV_STATES.HANDSHAKING,
|
|
WV_STATES.ERROR,
|
|
WV_STATES.UNLOADED,
|
|
],
|
|
[WV_STATES.HANDSHAKING]: [
|
|
WV_STATES.READY,
|
|
WV_STATES.ERROR,
|
|
WV_STATES.UNLOADED,
|
|
],
|
|
[WV_STATES.READY]: [WV_STATES.ERROR, WV_STATES.UNLOADED],
|
|
[WV_STATES.ERROR]: [WV_STATES.UNLOADED],
|
|
};
|
|
|
|
const WebViewContext = createContext(null);
|
|
|
|
// Derive AES-256 key via HKDF-SHA256 from sharedX (32 bytes)
|
|
function deriveAesKeyFromSharedX(sharedX, randomNonce) {
|
|
// sharedX should be Uint8Array or Buffer
|
|
const ikm =
|
|
sharedX instanceof Uint8Array ? sharedX : Uint8Array.from(sharedX);
|
|
// no salt, info = 'ecdh-aes-key'
|
|
const keyBytes = hkdf(
|
|
sha256,
|
|
ikm,
|
|
new Uint8Array(0),
|
|
new TextEncoder().encode('ecdh-aes-key:' + randomNonce),
|
|
32,
|
|
);
|
|
return Buffer.from(keyBytes); // Buffer of length 32
|
|
}
|
|
|
|
const setHandshakeComplete = value => {
|
|
handshakeComplete = value;
|
|
};
|
|
|
|
export const setForceReactNative = (value, reason = 'unknown') => {
|
|
if (value === true) {
|
|
console.warn(`forceReactNativeUse set to true. Reason: ${reason}`);
|
|
}
|
|
forceReactNativeUse = value;
|
|
};
|
|
|
|
const forceNativeMode = reason => {
|
|
console.warn(`Forcing React Native mode: ${reason}`);
|
|
forceReactNativeUse = true;
|
|
};
|
|
|
|
export const sendWebViewRequestGlobal = async (
|
|
action,
|
|
args = {},
|
|
encrypt = true,
|
|
) => {
|
|
if (!globalSendWebViewRequest) {
|
|
throw new Error(
|
|
'WebView not initialized. Ensure WebViewProvider is mounted.',
|
|
);
|
|
}
|
|
return globalSendWebViewRequest(action, args, encrypt);
|
|
};
|
|
|
|
export const getHandshakeComplete = () => {
|
|
if (forceReactNativeUse !== null) {
|
|
return false;
|
|
}
|
|
return handshakeComplete;
|
|
};
|
|
|
|
export const WebViewProvider = ({ children }) => {
|
|
const { authResetkey } = useAuthContext();
|
|
const { currentWalletMnemoinc } = useActiveCustodyAccount();
|
|
const { appState, isConnectedToTheInternet, didGetToHomepage } =
|
|
useAppStatus();
|
|
const webViewRef = useRef(null);
|
|
const [isWebViewReady, setIsWebViewReady] = useState(false);
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const isResetting = useRef(false);
|
|
const queuedRequests = useRef([]);
|
|
const pendingRequests = useRef({});
|
|
const activeTimeoutsRef = useRef({});
|
|
const sessionKeyRef = useRef(null);
|
|
const aesKeyRef = useRef(null);
|
|
const expectedNonceRef = useRef(null);
|
|
const [verifiedPath, setVerifiedPath] = useState('');
|
|
const expectedSequenceRef = useRef(0);
|
|
const nonceVerified = useRef(false);
|
|
const previousAppState = useRef(appState);
|
|
const prevConnectionStatus = useRef(isConnectedToTheInternet);
|
|
const internetConnectionRef = useRef(isConnectedToTheInternet);
|
|
const walletInitialized = useRef(false);
|
|
const isInitialRender = useRef(true);
|
|
const currentWalletMnemoincRef = useRef(currentWalletMnemoinc);
|
|
const didRunInit = useRef(null);
|
|
const isWebviewReadyRef = useRef(null);
|
|
const didRunHandshakeRef = useRef(false);
|
|
const didGetToHomepageRef = useRef(didGetToHomepage);
|
|
const [changeSparkConnectionState, setChangeSparkConnectionState] = useState({
|
|
state: null,
|
|
count: 0,
|
|
});
|
|
const webViewLoadState = useRef('unloaded');
|
|
const onLoadEndCalledRef = useRef(false);
|
|
const lastLoadEndTimeRef = useRef(0);
|
|
const wvState = useRef(WV_STATES.UNLOADED);
|
|
|
|
const transitionWvState = useCallback((newState, reason = '') => {
|
|
const current = wvState.current;
|
|
const valid = VALID_TRANSITIONS[current];
|
|
if (!valid || !valid.includes(newState)) {
|
|
console.warn(
|
|
`Invalid WebView state transition: ${current} → ${newState} (reason: ${reason})`,
|
|
);
|
|
return false;
|
|
}
|
|
console.log(`WebView state: ${current} → ${newState} (${reason})`);
|
|
wvState.current = newState;
|
|
|
|
// Derive isWebViewReady from state machine
|
|
const ready =
|
|
newState === WV_STATES.LOADED ||
|
|
newState === WV_STATES.HANDSHAKING ||
|
|
newState === WV_STATES.READY;
|
|
setIsWebViewReady(ready);
|
|
return true;
|
|
}, []);
|
|
|
|
const fileHash = !!verifiedPath ? process.env.WEBVIEW_BUNDLE_HASH : '';
|
|
|
|
useEffect(() => {
|
|
currentWalletMnemoincRef.current = currentWalletMnemoinc;
|
|
}, [currentWalletMnemoinc]);
|
|
|
|
useEffect(() => {
|
|
isWebviewReadyRef.current = isWebViewReady;
|
|
}, [isWebViewReady]);
|
|
|
|
// reset webview when app is stale in background
|
|
useEffect(() => {
|
|
if (isInitialRender.current) {
|
|
isInitialRender.current = false;
|
|
return;
|
|
}
|
|
|
|
// clear any qued requests
|
|
if (queuedRequests.current.length) {
|
|
console.log('[auth reset webview] clearing qued requests');
|
|
queuedRequests.current.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Wallet initialization failed, using React Native',
|
|
});
|
|
});
|
|
queuedRequests.current = [];
|
|
}
|
|
blockAndResetWebview();
|
|
}, [authResetkey]);
|
|
|
|
useEffect(() => {
|
|
didGetToHomepageRef.current = didGetToHomepage;
|
|
}, [didGetToHomepage]);
|
|
|
|
useEffect(() => {
|
|
internetConnectionRef.current = isConnectedToTheInternet;
|
|
}, [isConnectedToTheInternet]);
|
|
|
|
const messageRateLimiter = useRef({
|
|
count: 0,
|
|
windowStart: Date.now(),
|
|
maxPerSecond: 20,
|
|
});
|
|
|
|
const blockAndResetWebview = useCallback(
|
|
shouldClearPending => {
|
|
didRunInit.current = true; // Block handshakes during reload
|
|
|
|
resetWebViewState(false, false, shouldClearPending);
|
|
reloadWebViewSecurely(); // Will allow handshake to complete after state variables change. We are preventing a race condition here with the app state.
|
|
},
|
|
[resetWebViewState, reloadWebViewSecurely],
|
|
);
|
|
|
|
const getNextSequence = useCallback(() => {
|
|
const current = expectedSequenceRef.current;
|
|
expectedSequenceRef.current = current + 1;
|
|
return current;
|
|
}, []);
|
|
|
|
const isDuplicate = (queue, action, args) => {
|
|
return queue.some(
|
|
req =>
|
|
![
|
|
OPERATION_TYPES.getSingleTxDetails,
|
|
OPERATION_TYPES.getUserSwapHistory,
|
|
].includes(req.action) &&
|
|
req.action === action &&
|
|
JSON.stringify(req.args) === JSON.stringify(args),
|
|
);
|
|
};
|
|
|
|
const resetWebViewState = useCallback(
|
|
(
|
|
clearHandshake = false,
|
|
sparkConnectionState,
|
|
shouldClearPending = true,
|
|
) => {
|
|
console.log('Resetting WebView state', {
|
|
clearHandshake,
|
|
sparkConnectionState,
|
|
shouldClearPending,
|
|
});
|
|
// Transition to UNLOADED — this also sets isWebViewReady(false) via derived state.
|
|
// Allow transition from any state during reset.
|
|
wvState.current = WV_STATES.UNLOADED;
|
|
isResetting.current = true;
|
|
setIsWebViewReady(false);
|
|
// setVerifiedPath('');
|
|
|
|
Object.entries(activeTimeoutsRef.current).forEach(([id, timeoutInfo]) => {
|
|
clearTimeout(timeoutInfo.timeoutId);
|
|
|
|
// Re-queue the request if it's not a handshake
|
|
// and if we're not clearing pending requests
|
|
if (!shouldClearPending && timeoutInfo.action !== 'handshake:init') {
|
|
// Find the pending request's resolve/reject functions
|
|
const originalResolve = pendingRequests.current[id];
|
|
if (originalResolve && timeoutInfo.originalRequest) {
|
|
const { action, args, encrypt } = timeoutInfo.originalRequest;
|
|
|
|
console.log(`Re-queueing interrupted request: ${action}`);
|
|
|
|
// Add to queue if not already there
|
|
// IMPORTANT: We pass the original resolve function so the promise chain is preserved
|
|
if (!isDuplicate(queuedRequests.current, action, args)) {
|
|
queuedRequests.current.push({
|
|
id, // Keep original ID for tracking
|
|
action,
|
|
args,
|
|
encrypt,
|
|
resolve: originalResolve,
|
|
reject: error => {
|
|
// Reject using the original resolve function (which handles both resolve/reject)
|
|
if (typeof originalResolve === 'function') {
|
|
originalResolve({ error: error.message || error });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// Remove from pendingRequests since it's now queued
|
|
delete pendingRequests.current[id];
|
|
}
|
|
}
|
|
});
|
|
activeTimeoutsRef.current = {};
|
|
|
|
// Only clear handshake if explicitly told to (actual failures)
|
|
// Don't clear it for normal app lifecycle resets
|
|
// Our app uses this to choose wheater to make calls to the webview or react native
|
|
if (clearHandshake) {
|
|
setHandshakeComplete(false);
|
|
}
|
|
|
|
// Always reset walletInitialized because WebView reload clears its internal state
|
|
// We'll need to reinitialize the wallet after handshake completes
|
|
walletInitialized.current = false;
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: sparkConnectionState,
|
|
count: prev.count + 1,
|
|
}));
|
|
|
|
if (shouldClearPending) {
|
|
Object.entries(pendingRequests.current).forEach(([id, resolve]) => {
|
|
// Call the resolve to trigger timeout cleanup
|
|
if (typeof resolve === 'function') {
|
|
resolve({
|
|
error: 'Unable to finish action, request got cleaned up.',
|
|
});
|
|
}
|
|
});
|
|
pendingRequests.current = {};
|
|
}
|
|
// Note: We already removed re-queued items from pendingRequests in the loop above,
|
|
// so no additional cleanup needed in the else branch
|
|
|
|
sessionKeyRef.current = null;
|
|
expectedSequenceRef.current = 0;
|
|
aesKeyRef.current = null;
|
|
nonceVerified.current = false;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const reloadWebViewSecurely = useCallback(async () => {
|
|
try {
|
|
console.log('Re-verifying WebView before reload...');
|
|
if (forceReactNativeUse) return;
|
|
|
|
transitionWvState(WV_STATES.VERIFYING, 'reload verification');
|
|
|
|
// Re-verify the file
|
|
const { htmlPath, nonceHex, hashHex } = await verifyAndPrepareWebView(
|
|
Platform.OS === 'ios'
|
|
? require('spark-web-context')
|
|
: 'file:///android_asset/sparkContext.html',
|
|
);
|
|
|
|
// File is verified, safe to reload
|
|
console.log('File integrity verified, reloading WebView');
|
|
didRunInit.current = false;
|
|
expectedNonceRef.current = nonceHex;
|
|
setVerifiedPath(htmlPath);
|
|
setReloadKey(prev => prev + 1);
|
|
} catch (err) {
|
|
console.error('WebView re-verification failed:', err);
|
|
|
|
// On verification failure, force React Native mode
|
|
forceNativeMode('bundle verification failed');
|
|
setHandshakeComplete(false);
|
|
const currentRoutes = navigationRef
|
|
.getRootState()
|
|
.routes?.map(r => r.name);
|
|
|
|
const blockReset =
|
|
currentRoutes?.includes('Splash') ||
|
|
currentRoutes?.includes('SplashReload') ||
|
|
currentRoutes?.includes('Home') ||
|
|
currentRoutes?.includes('ConnectingToNodeLoadingScreen');
|
|
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: blockReset ? null : true,
|
|
count: prev.count + 1,
|
|
}));
|
|
}
|
|
}, []);
|
|
|
|
// Handle app state changes
|
|
useEffect(() => {
|
|
const appStateChanged = previousAppState.current !== appState;
|
|
const connectionChanged =
|
|
prevConnectionStatus.current !== isConnectedToTheInternet;
|
|
|
|
if ((!appStateChanged && !connectionChanged) || forceReactNativeUse) {
|
|
return; // Nothing changed
|
|
}
|
|
|
|
if (appState === 'background') {
|
|
console.log(
|
|
'App going to background - clearing all timeouts and pending requests',
|
|
);
|
|
Object.values(activeTimeoutsRef.current).forEach(t =>
|
|
clearTimeout(t.timeoutId),
|
|
);
|
|
activeTimeoutsRef.current = {};
|
|
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
} else if (appState === 'active') {
|
|
console.log('App returned to foreground');
|
|
// clear any active timeouts to prevent timeout from switching to rn
|
|
Object.values(activeTimeoutsRef.current).forEach(t =>
|
|
clearTimeout(t.timeoutId),
|
|
);
|
|
|
|
// Wait for internet connection before proceeding
|
|
if (!isConnectedToTheInternet) {
|
|
console.log('Waiting for internet connection before processing...');
|
|
// Update refs so we can detect when connection comes back
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
|
|
return;
|
|
}
|
|
|
|
// Only execute if we actually transitioned to active OR connection just came back
|
|
const justBecameActive =
|
|
appStateChanged &&
|
|
(previousAppState.current === 'background' ||
|
|
previousAppState.current === 'inactive');
|
|
const connectionJustRestored =
|
|
connectionChanged && isConnectedToTheInternet;
|
|
|
|
if (justBecameActive || connectionJustRestored) {
|
|
if (!nonceVerified.current && !isResetting.current) {
|
|
console.log(
|
|
'App became active or connection restored and webview is not varified and not resetting - reloading WebView',
|
|
);
|
|
if (didGetToHomepageRef.current) {
|
|
blockAndResetWebview();
|
|
}
|
|
} else {
|
|
// sometimes the webview becomes stale, if internet connection goes away make sure to reset webview but dont clear pending events so they are handled once webview is active again
|
|
if (connectionJustRestored) {
|
|
blockAndResetWebview(false);
|
|
} else {
|
|
// Make sure to handle any events that happen during background and are within the three minute refresh timeout
|
|
if (!didGetToHomepageRef.current) {
|
|
// we need to make sure this doesn't double run before getting to the hompage otherwise we will send multiple init requests
|
|
console.log(
|
|
'Did not get to homepage yet, blocking duplicate request created by biometric login popup',
|
|
);
|
|
} else {
|
|
setTimeout(() => {
|
|
processQueuedRequests(connectionJustRestored);
|
|
}, 100);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
} else {
|
|
previousAppState.current = appState;
|
|
prevConnectionStatus.current = isConnectedToTheInternet;
|
|
}
|
|
}, [
|
|
appState,
|
|
isConnectedToTheInternet,
|
|
blockAndResetWebview,
|
|
processQueuedRequests,
|
|
]);
|
|
|
|
const encryptMessage = useCallback(plaintext => {
|
|
if (!aesKeyRef.current) throw new Error('AES key not initialized');
|
|
const iv = Buffer.from(randomBytes(12));
|
|
const cipher = createCipheriv('aes-256-gcm', aesKeyRef.current, iv);
|
|
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
|
encrypted += cipher.final('base64');
|
|
const authTag = cipher.getAuthTag().toString('base64'); // Get 16-byte auth tag
|
|
return `${encrypted}?iv=${iv.toString('base64')}&tag=${authTag}`;
|
|
}, []);
|
|
|
|
const decryptMessage = useCallback(encryptedText => {
|
|
if (!aesKeyRef.current) throw new Error('AES key not initialized');
|
|
if (!encryptedText.includes('?iv=') || !encryptedText.includes('&tag=')) {
|
|
throw new Error('Missing IV or auth tag');
|
|
}
|
|
const [ciphertext, params] = encryptedText.split('?iv=');
|
|
const [ivBase64, authTagBase64] = params.split('&tag=');
|
|
const iv = Buffer.from(ivBase64, 'base64');
|
|
const authTag = Buffer.from(authTagBase64, 'base64');
|
|
|
|
const decipher = createDecipheriv('aes-256-gcm', aesKeyRef.current, iv);
|
|
decipher.setAuthTag(authTag); // Set auth tag for verification
|
|
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
return decrypted;
|
|
}, []);
|
|
|
|
const handleWebViewResponse = useCallback(
|
|
event => {
|
|
try {
|
|
const now = Date.now();
|
|
const windowDuration = now - messageRateLimiter.current.windowStart;
|
|
if (windowDuration > 1000) {
|
|
// Reset window
|
|
messageRateLimiter.current.count = 0;
|
|
messageRateLimiter.current.windowStart = now;
|
|
}
|
|
messageRateLimiter.current.count++;
|
|
|
|
if (
|
|
messageRateLimiter.current.count >
|
|
messageRateLimiter.current.maxPerSecond
|
|
) {
|
|
console.error(
|
|
`SECURITY: Rate limit exceeded (${messageRateLimiter.current.count} msgs/sec)`,
|
|
);
|
|
|
|
resetWebViewState(true, true);
|
|
forceNativeMode('rate limit exceeded');
|
|
return;
|
|
}
|
|
|
|
const message = JSON.parse(event.nativeEvent.data);
|
|
|
|
if (message.type === 'handshake:reply' && message.pubW) {
|
|
const resolve = pendingRequests.current[message.id];
|
|
if (!resolve) {
|
|
// no need to handle anything here, will be handled with timeout
|
|
console.error('Timeout: backend is unresponsive');
|
|
return;
|
|
}
|
|
if (!sessionKeyRef.current) {
|
|
// no need to handle anything here, will be handled with timeout
|
|
console.error(
|
|
'SECURITY: Received handshake reply without active session key',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const shared = getSharedSecret(
|
|
Buffer.from(sessionKeyRef.current.privateKey),
|
|
Buffer.from(message.pubW, 'hex'),
|
|
true,
|
|
);
|
|
const sharedX = shared.slice(1, 33);
|
|
aesKeyRef.current = deriveAesKeyFromSharedX(
|
|
sharedX,
|
|
expectedNonceRef.current,
|
|
);
|
|
|
|
shared.fill(0);
|
|
sharedX.fill(0);
|
|
|
|
if (sessionKeyRef.current?.privateKey) {
|
|
sessionKeyRef.current.privateKey.fill(0);
|
|
}
|
|
sessionKeyRef.current = null;
|
|
|
|
const decodedNonce = decryptMessage(message.runtimeNonce);
|
|
if (expectedNonceRef.current !== decodedNonce) {
|
|
// no need to handle anything here, will be handled with timeout
|
|
console.log('Invalid runtime nonce, something went wrong');
|
|
return;
|
|
}
|
|
nonceVerified.current = true;
|
|
webviewFailureCount = 0;
|
|
console.log('Handshake complete. Got backend public key.');
|
|
transitionWvState(WV_STATES.READY, 'handshake complete');
|
|
setHandshakeComplete(true);
|
|
// resolve requset to avoid timeout
|
|
resolve({ didComplete: true });
|
|
delete pendingRequests.current[message.id];
|
|
|
|
setTimeout(() => {
|
|
processQueuedRequests();
|
|
}, 100);
|
|
return;
|
|
}
|
|
|
|
let content = message;
|
|
|
|
if (message.encrypted && aesKeyRef.current) {
|
|
const decrypted = decryptMessage(message.encrypted);
|
|
|
|
try {
|
|
content = JSON.parse(decrypted);
|
|
} catch (err) {
|
|
content = decrypted;
|
|
}
|
|
}
|
|
console.log('receiving message from webview', content);
|
|
|
|
if (content.type === 'security:csp-violation') {
|
|
console.error('CSP VIOLATION DETECTED:', content);
|
|
|
|
resetWebViewState(true, true);
|
|
forceNativeMode('CSP violation');
|
|
return;
|
|
}
|
|
|
|
if (content.error) throw new Error(content.error);
|
|
|
|
if (content.incomingPayment) {
|
|
const data = JSON.parse(content.result);
|
|
incomingSparkTransaction.emit(
|
|
INCOMING_SPARK_TX_NAME,
|
|
data.transferId,
|
|
data.balance,
|
|
);
|
|
}
|
|
if (content.isResponse && content.id) {
|
|
const resolve = pendingRequests.current[content.id];
|
|
if (resolve) {
|
|
const result = JSON.parse(content.result || null);
|
|
// Check for WASM errors
|
|
if (
|
|
result?.error &&
|
|
typeof result.error === 'string' &&
|
|
WASM_ERRORS.some(errMsg => result.error.includes(errMsg))
|
|
) {
|
|
console.warn(
|
|
'WASM failed, switching to React Native implementation:',
|
|
result.error,
|
|
);
|
|
|
|
resetWebViewState(true, true);
|
|
forceNativeMode('WASM error');
|
|
|
|
setLocalStorageItem('FORCE_REACT_NATIVE', 'true');
|
|
}
|
|
webviewFailureCount = 0; // Reset on successful response
|
|
resolve(result);
|
|
|
|
delete pendingRequests.current[content.id];
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error handling WebView message:', err);
|
|
if (
|
|
typeof err.message === 'string' &&
|
|
err.message === 'Rejected stale message'
|
|
)
|
|
return;
|
|
webviewFailureCount++;
|
|
if (webviewFailureCount >= MAX_WEBVIEW_FAILURES) {
|
|
forceNativeMode('repeated WebView errors');
|
|
}
|
|
resetWebViewState(true, true);
|
|
}
|
|
},
|
|
[decryptMessage, resetWebViewState],
|
|
);
|
|
|
|
const sendWebViewRequestInternal = useCallback(
|
|
(action, args = {}, encrypt = true) => {
|
|
let resolveFn, rejectFn;
|
|
const promise = new Promise((resolve, reject) => {
|
|
resolveFn = resolve;
|
|
rejectFn = reject;
|
|
});
|
|
|
|
const execute = async () => {
|
|
const resolve = resolveFn;
|
|
const reject = rejectFn;
|
|
let timeoutId = null;
|
|
const id = customUUID();
|
|
try {
|
|
// If forceReactNativeUse is set, reject immediately
|
|
if (forceReactNativeUse === true) {
|
|
console.log(
|
|
'Forced React Native mode, rejecting WebView request:',
|
|
action,
|
|
);
|
|
return resolve({
|
|
error: 'Wallet initialization failed, using React Native(1)',
|
|
});
|
|
}
|
|
|
|
// Queue messages during reset/background
|
|
if (
|
|
(isResetting.current || AppState.currentState === 'background') &&
|
|
action !== 'handshake:init' &&
|
|
action !== 'initializeSparkWallet'
|
|
) {
|
|
console.log(
|
|
'WebView is resetting or in the background, queueing message:',
|
|
action,
|
|
);
|
|
if (!isDuplicate(queuedRequests.current, action, args)) {
|
|
queuedRequests.current.push({
|
|
id,
|
|
action,
|
|
args,
|
|
encrypt,
|
|
resolve,
|
|
reject,
|
|
});
|
|
} else {
|
|
console.log('Duplicate request ignored:', action, args);
|
|
reject('Duplicate request ignored');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Reject importent messages if app is not connected to the internet
|
|
if (
|
|
!internetConnectionRef.current &&
|
|
action !== 'handshake:init' &&
|
|
action !== 'initializeSparkWallet'
|
|
) {
|
|
console.log(
|
|
'App is not connected to the internet, queueing message:',
|
|
action,
|
|
);
|
|
if (
|
|
rejectIfNotConnectedToInternet.some(op =>
|
|
action.toLowerCase().includes(op.toLowerCase()),
|
|
)
|
|
) {
|
|
reject(new Error(`App is not connected to the internet`));
|
|
} else if (action !== OPERATION_TYPES.initWallet) {
|
|
//don't add init wallet, this will be added during webview reset
|
|
if (!isDuplicate(queuedRequests.current, action, args)) {
|
|
queuedRequests.current.push({
|
|
id,
|
|
action,
|
|
args,
|
|
encrypt,
|
|
resolve,
|
|
reject,
|
|
});
|
|
} else {
|
|
console.log('Duplicate request ignored:', action, args);
|
|
reject('Duplicate request ignored');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!webViewRef.current || !isWebviewReadyRef.current) {
|
|
console.log(
|
|
'WebView not ready or internet is not connected, queueing message:',
|
|
action,
|
|
webViewRef.current,
|
|
isWebviewReadyRef.current,
|
|
);
|
|
if (!isDuplicate(queuedRequests.current, action, args)) {
|
|
queuedRequests.current.push({
|
|
id,
|
|
action,
|
|
args,
|
|
encrypt,
|
|
resolve,
|
|
reject,
|
|
});
|
|
} else {
|
|
console.log('Duplicate request ignored:', action, args);
|
|
reject('Duplicate request ignored');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const getTimeoutDuration = action => {
|
|
if (action === 'handshake:init') return 4000;
|
|
|
|
if (
|
|
longOperations.some(op =>
|
|
action.toLowerCase().includes(op.toLowerCase()),
|
|
)
|
|
) {
|
|
return 90000; // 90 seconds for payment operations
|
|
}
|
|
|
|
if (
|
|
mediumOperations.some(op =>
|
|
action.toLowerCase().includes(op.toLowerCase()),
|
|
)
|
|
) {
|
|
return 30000; // 30 seconds
|
|
}
|
|
|
|
return 10000; // 10 seconds
|
|
};
|
|
|
|
const timeoutDuration = getTimeoutDuration(action);
|
|
const startedAt = Date.now();
|
|
|
|
const handleTimeout = () => {
|
|
if (AppState.currentState !== 'active') {
|
|
console.log(
|
|
`Skipping timeout for ${action} because app is not active (${AppState.currentState})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!pendingRequests.current[id]) {
|
|
console.log(`Request ${id} already resolved, skipping timeout`);
|
|
delete activeTimeoutsRef.current[id];
|
|
return;
|
|
}
|
|
|
|
console.error(`WebView request timeout for action: ${action}`);
|
|
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
|
|
// If handshake is complete and this is not a handshake action,
|
|
// the WebView bridge is functional — this is a service timeout.
|
|
// Don't kill the WebView for a backend service being slow.
|
|
if (handshakeComplete && action !== 'handshake:init') {
|
|
console.warn(
|
|
`Service timeout for ${action} — WebView bridge is healthy, not forcing native mode`,
|
|
);
|
|
} else {
|
|
// WebView-level failure (handshake timeout or bridge issue)
|
|
webviewFailureCount++;
|
|
console.warn(
|
|
`WebView failure #${webviewFailureCount} for ${action}`,
|
|
);
|
|
if (webviewFailureCount >= MAX_WEBVIEW_FAILURES) {
|
|
forceNativeMode(
|
|
`${MAX_WEBVIEW_FAILURES} consecutive WebView failures`,
|
|
);
|
|
}
|
|
resetWebViewState(true, true);
|
|
}
|
|
|
|
reject(
|
|
new Error(
|
|
`Call unresponsive (timeout after ${timeoutDuration}ms)`,
|
|
),
|
|
);
|
|
};
|
|
|
|
timeoutId = setTimeout(handleTimeout, timeoutDuration);
|
|
|
|
activeTimeoutsRef.current[id] = {
|
|
timeoutId,
|
|
startedAt,
|
|
duration: timeoutDuration,
|
|
handler: handleTimeout,
|
|
remaining: timeoutDuration,
|
|
action,
|
|
originalRequest: { action, args, encrypt }, //Store request details
|
|
};
|
|
|
|
const originalResolve = resolve;
|
|
pendingRequests.current[id] = result => {
|
|
const t = activeTimeoutsRef.current[id];
|
|
if (t?.timeoutId) clearTimeout(t.timeoutId);
|
|
delete activeTimeoutsRef.current[id];
|
|
originalResolve(result);
|
|
};
|
|
|
|
if (args.mnemonic && action !== 'initializeSparkWallet') {
|
|
args.mnemonic = sha256Hash(args.mnemonic);
|
|
}
|
|
|
|
// Handle initializeSparkWallet specially
|
|
if (action === 'initializeSparkWallet') {
|
|
if (!getHandshakeComplete()) {
|
|
console.log('Handshake not complete, cannot initialize wallet');
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
forceNativeMode('handshake incomplete for wallet init');
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: true,
|
|
count: prev.count + 1,
|
|
}));
|
|
return resolve({ isConnected: false });
|
|
}
|
|
if (!nonceVerified.current) {
|
|
console.log('Nonce not verified, cannot initialize wallet');
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
forceNativeMode('nonce not verified for wallet init');
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: true,
|
|
count: prev.count + 1,
|
|
}));
|
|
return resolve({ isConnected: false });
|
|
}
|
|
|
|
// Wrap the resolve to check initialization result
|
|
const wrappedResolve = pendingRequests.current[id];
|
|
pendingRequests.current[id] = result => {
|
|
if (result?.error || result?.isConnected === false) {
|
|
console.warn(
|
|
'Wallet initialization failed, forcing React Native mode:',
|
|
result,
|
|
);
|
|
|
|
// forceReactNativeUse = true;
|
|
// setChangeSparkConnectionState(prev => ({
|
|
// state: true,
|
|
// count: prev.count + 1,
|
|
// }));
|
|
|
|
queuedRequests.current.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Wallet initialization failed, using React Native',
|
|
});
|
|
});
|
|
queuedRequests.current = [];
|
|
} else {
|
|
walletInitialized.current = true;
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: true,
|
|
count: prev.count + 1,
|
|
}));
|
|
console.log('Wallet initialized successfully');
|
|
processQueuedRequests();
|
|
}
|
|
wrappedResolve(result);
|
|
};
|
|
} else if (action !== 'handshake:init') {
|
|
// For non-init actions, check if wallet was initialized
|
|
if (handshakeComplete && !walletInitialized.current) {
|
|
console.log(
|
|
'Wallet initialization in progress, queueing request:',
|
|
action,
|
|
);
|
|
|
|
// Queue the request instead of blocking
|
|
if (!isDuplicate(queuedRequests.current, action, args)) {
|
|
queuedRequests.current.push({
|
|
id,
|
|
action,
|
|
args,
|
|
encrypt,
|
|
resolve,
|
|
reject,
|
|
});
|
|
|
|
// Clean up timeout since we're queueing
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete activeTimeoutsRef.current[id];
|
|
|
|
return;
|
|
} else {
|
|
console.log('Duplicate request ignored:', action);
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
return reject(new Error('Duplicate request ignored'));
|
|
}
|
|
}
|
|
}
|
|
|
|
const sequence = getNextSequence();
|
|
const timestamp = Date.now();
|
|
|
|
let payload = {
|
|
id,
|
|
action,
|
|
args,
|
|
sequence,
|
|
timestamp,
|
|
};
|
|
console.log('sending message to webview', action, payload);
|
|
|
|
try {
|
|
if (encrypt && aesKeyRef.current) {
|
|
const encrypted = encryptMessage(JSON.stringify(payload));
|
|
payload = { type: 'secure:msg', encrypted };
|
|
}
|
|
webViewRef.current.postMessage(JSON.stringify(payload));
|
|
} catch (err) {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
reject(err);
|
|
}
|
|
} catch (err) {
|
|
// Clean up timeout on error
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
if (id) {
|
|
delete pendingRequests.current[id];
|
|
delete activeTimeoutsRef.current[id];
|
|
}
|
|
console.log(
|
|
'Error sending webview request from internal function',
|
|
err,
|
|
);
|
|
reject(err);
|
|
}
|
|
};
|
|
execute();
|
|
return promise;
|
|
},
|
|
[
|
|
encryptMessage,
|
|
resetWebViewState,
|
|
getNextSequence,
|
|
appState,
|
|
isConnectedToTheInternet,
|
|
],
|
|
);
|
|
|
|
const initHandshake = useCallback(async () => {
|
|
try {
|
|
const privN = randomBytes(32);
|
|
const pubN = getPublicKey(privN, true); // compressed
|
|
const pubNHex = Buffer.from(pubN).toString('hex');
|
|
|
|
sessionKeyRef.current = {
|
|
privateKey: privN,
|
|
publicKey: pubNHex,
|
|
};
|
|
|
|
await sendWebViewRequestInternal('handshake:init', {
|
|
pubN: pubNHex,
|
|
});
|
|
} catch (error) {
|
|
console.warn('Handshake failed or timed out:', error.message);
|
|
forceNativeMode('handshake failed');
|
|
const currentRoutes = navigationRef
|
|
.getRootState()
|
|
.routes?.map(r => r.name);
|
|
|
|
const blockReset =
|
|
currentRoutes?.includes('Splash') ||
|
|
currentRoutes?.includes('SplashReload') ||
|
|
currentRoutes?.includes('Home') ||
|
|
currentRoutes?.includes('ConnectingToNodeLoadingScreen');
|
|
|
|
setChangeSparkConnectionState(prev => ({
|
|
state: blockReset ? null : true,
|
|
count: prev.count + 1,
|
|
}));
|
|
queuedRequests.current.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Failed to process method, try again',
|
|
});
|
|
});
|
|
}
|
|
}, [sendWebViewRequestInternal]);
|
|
|
|
useEffect(() => {
|
|
async function startHandshake() {
|
|
if (!webViewRef.current) return;
|
|
if (!isWebViewReady) return;
|
|
if (!verifiedPath) return;
|
|
// blocking background init event from firing
|
|
if (appState === 'background') return;
|
|
if (didRunInit.current) return;
|
|
didRunInit.current = true;
|
|
|
|
// const androidAPI = DeviceInfo.getApiLevelSync();
|
|
// if (androidAPI == 33 || androidAPI == 34) {
|
|
// console.warn(`Skipping handshake on Android API ${androidAPI}`);
|
|
// forceReactNativeUse = true;
|
|
// return;
|
|
// }
|
|
|
|
const savedVariable = await getLocalStorageItem('FORCE_REACT_NATIVE');
|
|
|
|
if (savedVariable === 'true') {
|
|
console.log('FORCE_REACT_NATIVE is set, skipping handshake');
|
|
forceNativeMode('FORCE_REACT_NATIVE localStorage flag');
|
|
didRunHandshakeRef.current = true;
|
|
return;
|
|
}
|
|
transitionWvState(WV_STATES.HANDSHAKING, 'handshake init');
|
|
await initHandshake();
|
|
didRunHandshakeRef.current = true;
|
|
}
|
|
|
|
const debouceID = setTimeout(() => {
|
|
// forceReactNativeUse = true;
|
|
// didRunHandshakeRef.current = true
|
|
startHandshake(); //remove this and app fully uses RN
|
|
}, 250);
|
|
|
|
return () => {
|
|
if (debouceID) {
|
|
clearTimeout(debouceID);
|
|
}
|
|
};
|
|
}, [isWebViewReady, verifiedPath, initHandshake, appState]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
transitionWvState(WV_STATES.VERIFYING, 'initial verification');
|
|
try {
|
|
const { htmlPath, nonceHex, hashHex } = await verifyAndPrepareWebView(
|
|
Platform.OS === 'ios'
|
|
? require('spark-web-context')
|
|
: 'file:///android_asset/sparkContext.html',
|
|
);
|
|
|
|
expectedNonceRef.current = nonceHex;
|
|
setVerifiedPath(htmlPath);
|
|
} catch (err) {
|
|
didRunHandshakeRef.current = true;
|
|
forceNativeMode('bundle verification failed');
|
|
console.log(
|
|
'WebView bundle verification failed. Using react-native bundle',
|
|
err,
|
|
);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
globalSendWebViewRequest = sendWebViewRequestInternal;
|
|
}, [sendWebViewRequestInternal]);
|
|
|
|
useEffect(() => {
|
|
webViewLoadState.current = 'unloaded';
|
|
onLoadEndCalledRef.current = false;
|
|
lastLoadEndTimeRef.current = 0;
|
|
}, [reloadKey, verifiedPath]);
|
|
|
|
const processQueuedRequests = useCallback(
|
|
async connectionJustRestored => {
|
|
// After a soft reset, the WebView's internal state is cleared
|
|
// We must reinitialize the wallet before processing any queued requests
|
|
if (
|
|
(handshakeComplete &&
|
|
!walletInitialized.current &&
|
|
currentWalletMnemoincRef.current) ||
|
|
(currentWalletMnemoincRef.current && connectionJustRestored)
|
|
) {
|
|
console.log('Re-initializing wallet before processing queue');
|
|
try {
|
|
// No need to handle any state changes here, handled inside of the promise. But this might be where the stale connection state comes from. if a request is sent to the webview but not responded to the change to react-native woudnt have happpened before leaving everything in "not connected to spark".
|
|
const response = await sendWebViewRequestInternal(
|
|
OPERATION_TYPES.initWallet,
|
|
{ mnemonic: currentWalletMnemoincRef.current },
|
|
true,
|
|
);
|
|
if (!response?.isConnected) throw new Error('Wallet init failed');
|
|
} catch (err) {
|
|
console.log('Error re-initializing wallet:', err);
|
|
// forceReactNativeUse = true;
|
|
// Reject all queued requests since WebView is now unusable
|
|
queuedRequests.current.forEach(({ reject }) => {
|
|
reject({
|
|
error: 'Wallet initialization failed, using React Native',
|
|
});
|
|
});
|
|
queuedRequests.current = [];
|
|
return;
|
|
}
|
|
}
|
|
isResetting.current = false;
|
|
|
|
console.log(
|
|
`Processing ${queuedRequests.current.length} queued requests`,
|
|
);
|
|
|
|
if (
|
|
queuedRequests.current.length === 0 ||
|
|
!currentWalletMnemoincRef.current
|
|
) {
|
|
isResetting.current = false;
|
|
return;
|
|
}
|
|
|
|
const requests = [...queuedRequests.current];
|
|
queuedRequests.current = [];
|
|
|
|
// Process sequentially to avoid triggering the rate limiter (20 msgs/sec).
|
|
// Parallel dispatch via Promise.allSettled could fire 21+ messages at once,
|
|
// permanently killing the WebView.
|
|
for (const { action, args, encrypt, resolve, reject } of requests) {
|
|
try {
|
|
const result = await sendWebViewRequestInternal(
|
|
action,
|
|
args,
|
|
encrypt,
|
|
);
|
|
if (typeof resolve === 'function') {
|
|
resolve(result);
|
|
}
|
|
} catch (error) {
|
|
if (typeof reject === 'function') {
|
|
reject(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
isResetting.current = false;
|
|
},
|
|
[sendWebViewRequestInternal],
|
|
);
|
|
|
|
const getCustomUserAgent = useCallback(() => {
|
|
const deviceModel = getModel();
|
|
const systemVersion = getSystemVersion();
|
|
|
|
// For Android
|
|
if (Platform.OS === 'android') {
|
|
return `Mozilla/5.0 (Linux; Android ${systemVersion}; ${deviceModel}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36`;
|
|
}
|
|
|
|
// For iOS
|
|
return `Mozilla/5.0 (iPhone; CPU iPhone OS ${systemVersion.replace(
|
|
/\./g,
|
|
'_',
|
|
)} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1`;
|
|
}, []);
|
|
|
|
const handleWebViewTermination = useCallback(
|
|
reason => {
|
|
transitionWvState(WV_STATES.ERROR, reason);
|
|
|
|
if (AppState.currentState !== 'active') {
|
|
// App is backgrounded — do NOT reload now (no events should fire in background).
|
|
// Invalidate session so the foreground app-state effect sees !nonceVerified
|
|
// and triggers blockAndResetWebview() cleanly when the user returns.
|
|
// Critically: do NOT set isResetting.current = true here, as that would
|
|
// block the foreground handler's !isResetting.current guard.
|
|
console.warn(
|
|
`[WebView] Crash in background (${reason}) — deferring reload to foreground`,
|
|
);
|
|
nonceVerified.current = false;
|
|
aesKeyRef.current = null;
|
|
sessionKeyRef.current = null;
|
|
walletInitialized.current = false;
|
|
return;
|
|
}
|
|
|
|
console.warn(`[WebView] Crash while active (${reason}) — reloading now`);
|
|
blockAndResetWebview();
|
|
},
|
|
[blockAndResetWebview],
|
|
);
|
|
|
|
return (
|
|
<WebViewContext.Provider
|
|
value={{
|
|
webViewRef,
|
|
sendWebViewRequest: sendWebViewRequestInternal,
|
|
fileHash,
|
|
changeSparkConnectionState,
|
|
didRunHandshakeRef,
|
|
}}
|
|
>
|
|
{children}
|
|
{verifiedPath && (
|
|
<WebView
|
|
key={reloadKey}
|
|
domStorageEnabled={true}
|
|
allowFileAccess={true}
|
|
allowFileAccessFromFileURLs={false}
|
|
allowUniversalAccessFromFileURLs={false}
|
|
thirdPartyCookiesEnabled={true}
|
|
sharedCookiesEnabled={true}
|
|
incognito={false}
|
|
userAgent={getCustomUserAgent()}
|
|
webviewDebuggingEnabled={false}
|
|
cacheEnabled={false}
|
|
mixedContentMode="never"
|
|
javaScriptEnabled
|
|
ref={webViewRef}
|
|
containerStyle={{ position: 'absolute', top: 1000, left: 1000 }}
|
|
source={{ uri: verifiedPath }}
|
|
originWhitelist={['file://']}
|
|
onShouldStartLoadWithRequest={request => {
|
|
return request.url === verifiedPath;
|
|
}}
|
|
onMessage={handleWebViewResponse}
|
|
onLoadStart={() => {
|
|
transitionWvState(WV_STATES.LOADING, 'onLoadStart');
|
|
webViewLoadState.current = 'loading';
|
|
onLoadEndCalledRef.current = false;
|
|
didRunHandshakeRef.current = false;
|
|
}}
|
|
onLoadProgress={({ nativeEvent }) => {
|
|
if (
|
|
nativeEvent.progress === 1 &&
|
|
wvState.current === WV_STATES.LOADING
|
|
) {
|
|
transitionWvState(WV_STATES.LOADED, 'progress 100%');
|
|
webViewLoadState.current = 'loaded';
|
|
}
|
|
}}
|
|
onLoadEnd={() => {
|
|
// Only transition if still in LOADING state
|
|
// (onLoadProgress might have already handled it)
|
|
if (wvState.current === WV_STATES.LOADING) {
|
|
transitionWvState(WV_STATES.LOADED, 'onLoadEnd');
|
|
webViewLoadState.current = 'loaded';
|
|
}
|
|
}}
|
|
onContentProcessDidTerminate={() =>
|
|
handleWebViewTermination('iOS process terminated')
|
|
}
|
|
onRenderProcessGone={({ nativeEvent }) =>
|
|
handleWebViewTermination(
|
|
`Android renderer gone (didCrash=${nativeEvent.didCrash})`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</WebViewContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useWebView = () => React.useContext(WebViewContext);
|