diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 4aaf10e5..0786d71a 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; import { supabase } from '@/lib/supabase'; import { User } from '@supabase/supabase-js'; +import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge'; // Session timeout configuration const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes @@ -159,7 +160,31 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, []); + // Setup native mobile wallet if running in mobile app + const setupMobileWallet = useCallback(() => { + if (isMobileApp()) { + const nativeAddress = getNativeWalletAddress(); + const nativeAccountName = getNativeAccountName(); + + if (nativeAddress) { + // Store native wallet address for admin checks and wallet operations + localStorage.setItem('selectedWallet', nativeAddress); + if (nativeAccountName) { + localStorage.setItem('selectedWalletName', nativeAccountName); + } + if (import.meta.env.DEV) { + console.log('[Mobile] Native wallet detected:', nativeAddress); + } + // Dispatch wallet change event + window.dispatchEvent(new Event('walletChanged')); + } + } + }, []); + useEffect(() => { + // Setup mobile wallet first + setupMobileWallet(); + // Check active sessions and sets the user supabase.auth.getSession().then(({ data: { session } }) => { setUser(session?.user ?? null); @@ -178,17 +203,28 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setLoading(false); }); - // Listen for wallet changes (from PezkuwiContext) + // Listen for wallet changes (from PezkuwiContext or native bridge) const handleWalletChange = () => { checkAdminStatus(); }; window.addEventListener('walletChanged', handleWalletChange); + // Listen for native bridge ready event (mobile app) + const handleNativeReady = () => { + if (import.meta.env.DEV) { + console.log('[Mobile] Native bridge ready'); + } + setupMobileWallet(); + checkAdminStatus(); + }; + window.addEventListener('pezkuwi-native-ready', handleNativeReady); + return () => { subscription.unsubscribe(); window.removeEventListener('walletChanged', handleWalletChange); + window.removeEventListener('pezkuwi-native-ready', handleNativeReady); }; - }, [checkAdminStatus]); + }, [checkAdminStatus, setupMobileWallet]); const signIn = async (email: string, password: string, rememberMe: boolean = false) => { try { diff --git a/web/src/contexts/PezkuwiContext.tsx b/web/src/contexts/PezkuwiContext.tsx index fb2aff9e..4651b3e0 100644 --- a/web/src/contexts/PezkuwiContext.tsx +++ b/web/src/contexts/PezkuwiContext.tsx @@ -3,6 +3,7 @@ import { ApiPromise, WsProvider } from '@pezkuwi/api'; import { web3Accounts, web3Enable } from '@pezkuwi/extension-dapp'; import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types'; import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/pezkuwi'; +import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge'; interface PezkuwiContextType { api: ApiPromise | null; @@ -127,9 +128,36 @@ export const PezkuwiProvider: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [endpoint]); - // Auto-restore wallet on page load + // Auto-restore wallet on page load (or setup mobile wallet) useEffect(() => { const restoreWallet = async () => { + // Check if running in mobile app + if (isMobileApp()) { + const nativeAddress = getNativeWalletAddress(); + const nativeAccountName = getNativeAccountName(); + + if (nativeAddress) { + // Create a virtual account for the mobile wallet + const mobileAccount: InjectedAccountWithMeta = { + address: nativeAddress, + meta: { + name: nativeAccountName || 'Mobile Wallet', + source: 'pezkuwi-mobile', + }, + type: 'sr25519', + }; + + setAccounts([mobileAccount]); + handleSetSelectedAccount(mobileAccount); + + if (import.meta.env.DEV) { + console.log('[Mobile] Native wallet connected:', nativeAddress.slice(0, 8) + '...'); + } + return; + } + } + + // Desktop: Try to restore from localStorage const savedAddress = localStorage.getItem('selectedWallet'); if (!savedAddress) return; @@ -148,25 +176,69 @@ export const PezkuwiProvider: React.FC = ({ setAccounts(allAccounts); handleSetSelectedAccount(savedAccount); if (import.meta.env.DEV) { - if (import.meta.env.DEV) console.log('✅ Wallet restored:', savedAddress.slice(0, 8) + '...'); + console.log('✅ Wallet restored:', savedAddress.slice(0, 8) + '...'); } } } catch (err) { if (import.meta.env.DEV) { - if (import.meta.env.DEV) console.error('Failed to restore wallet:', err); + console.error('Failed to restore wallet:', err); } } }; restoreWallet(); + + // Listen for native bridge ready event (mobile) + const handleNativeReady = () => { + if (import.meta.env.DEV) { + console.log('[Mobile] Native bridge ready, restoring wallet'); + } + restoreWallet(); + }; + + window.addEventListener('pezkuwi-native-ready', handleNativeReady); + + return () => { + window.removeEventListener('pezkuwi-native-ready', handleNativeReady); + }; }, []); - // Connect wallet (Pezkuwi.js extension) + // Connect wallet (Pezkuwi.js extension or native mobile) const connectWallet = async () => { try { setError(null); - // Enable extension + // Check if running in mobile app + if (isMobileApp()) { + const nativeAddress = getNativeWalletAddress(); + const nativeAccountName = getNativeAccountName(); + + if (nativeAddress) { + // Create a virtual account for the mobile wallet + const mobileAccount: InjectedAccountWithMeta = { + address: nativeAddress, + meta: { + name: nativeAccountName || 'Mobile Wallet', + source: 'pezkuwi-mobile', + }, + type: 'sr25519', + }; + + setAccounts([mobileAccount]); + handleSetSelectedAccount(mobileAccount); + + if (import.meta.env.DEV) { + console.log('[Mobile] Native wallet connected:', nativeAddress.slice(0, 8) + '...'); + } + return; + } else { + // Request wallet connection from native app + setError('Please connect your wallet in the app'); + return; + } + } + + // Desktop: Enable extension const extensions = await web3Enable('PezkuwiChain'); if (extensions.length === 0) { @@ -176,7 +248,7 @@ export const PezkuwiProvider: React.FC = ({ } if (import.meta.env.DEV) { - if (import.meta.env.DEV) console.log('✅ Pezkuwi.js extension enabled'); + console.log('✅ Pezkuwi.js extension enabled'); } // Get accounts @@ -199,12 +271,12 @@ export const PezkuwiProvider: React.FC = ({ handleSetSelectedAccount(accountToSelect); if (import.meta.env.DEV) { - if (import.meta.env.DEV) console.log(`✅ Found ${allAccounts.length} account(s)`); + console.log(`✅ Found ${allAccounts.length} account(s)`); } } catch (err) { if (import.meta.env.DEV) { - if (import.meta.env.DEV) console.error('❌ Wallet connection failed:', err); + console.error('❌ Wallet connection failed:', err); } setError('Failed to connect wallet'); } diff --git a/web/src/contexts/WalletContext.tsx b/web/src/contexts/WalletContext.tsx index f940062c..f3f14f0d 100644 --- a/web/src/contexts/WalletContext.tsx +++ b/web/src/contexts/WalletContext.tsx @@ -10,6 +10,7 @@ import { WALLET_ERRORS, formatBalance, ASSET_IDS } from '@pezkuwi/lib/wallet'; import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types'; import type { Signer } from '@pezkuwi/api/types'; import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { isMobileApp, getNativeWalletAddress, signTransactionNative } from '@/lib/mobile-bridge'; interface TokenBalances { HEZ: string; @@ -165,11 +166,28 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr } try { + // Check if running in mobile app - use native bridge for signing + if (isMobileApp()) { + if (import.meta.env.DEV) console.log('[Mobile] Using native bridge for transaction signing'); + + // Get extrinsic hex for native signing + const extrinsicHex = (tx as { toHex?: () => string }).toHex?.() || ''; + + // Sign via native bridge + const signature = await signTransactionNative(extrinsicHex); + + // Submit the signed transaction + // Note: The native app signs and may also submit, so we return the signature as hash + // In production, the mobile app handles the full submit flow + return signature; + } + + // Desktop: Use browser extension for signing const { web3FromAddress } = await import('@pezkuwi/extension-dapp'); const injector = await web3FromAddress(pezkuwi.selectedAccount.address); // Sign and send transaction - const hash = await tx.signAndSend( + const hash = await (tx as { signAndSend: (address: string, options: { signer: unknown }) => Promise<{ toHex: () => string }> }).signAndSend( pezkuwi.selectedAccount.address, { signer: injector.signer } ); diff --git a/web/src/hooks/useMobileBridge.ts b/web/src/hooks/useMobileBridge.ts new file mode 100644 index 00000000..505ce3fa --- /dev/null +++ b/web/src/hooks/useMobileBridge.ts @@ -0,0 +1,111 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + isMobileApp, + getPlatform, + getNativeWalletAddress, + getNativeAccountName, + isNativeWalletConnected, + requestNativeWalletConnection, + signTransactionNative, + navigateBackNative, + type MobileBridgeState, +} from '@/lib/mobile-bridge'; + +/** + * React hook for mobile bridge integration + * + * Provides reactive state and methods for interacting with the native mobile app. + * Automatically updates when the native bridge becomes ready. + */ +export function useMobileBridge() { + const [state, setState] = useState({ + isMobile: false, + platform: 'web', + walletAddress: null, + accountName: null, + isWalletConnected: false, + }); + + // Update state from native bridge + const updateState = useCallback(() => { + setState({ + isMobile: isMobileApp(), + platform: getPlatform(), + walletAddress: getNativeWalletAddress(), + accountName: getNativeAccountName(), + isWalletConnected: isNativeWalletConnected(), + }); + }, []); + + useEffect(() => { + // Initial state + updateState(); + + // Listen for native bridge ready event + const handleNativeReady = (event: CustomEvent) => { + if (import.meta.env.DEV) { + console.log('[MobileBridge] Native bridge ready:', event.detail); + } + updateState(); + }; + + // Listen for wallet changes from native + const handleWalletChange = () => { + updateState(); + }; + + window.addEventListener('pezkuwi-native-ready', handleNativeReady as EventListener); + window.addEventListener('walletChanged', handleWalletChange); + + // Check periodically in case bridge loads after initial render + const checkInterval = setInterval(() => { + if (isMobileApp() && !state.isMobile) { + updateState(); + } + }, 500); + + // Clear interval after 5 seconds (bridge should be ready by then) + const clearTimer = setTimeout(() => { + clearInterval(checkInterval); + }, 5000); + + return () => { + window.removeEventListener('pezkuwi-native-ready', handleNativeReady as EventListener); + window.removeEventListener('walletChanged', handleWalletChange); + clearInterval(checkInterval); + clearTimeout(clearTimer); + }; + }, [updateState, state.isMobile]); + + // Connect wallet via native app + const connectWallet = useCallback(() => { + if (state.isMobile) { + requestNativeWalletConnection(); + } + }, [state.isMobile]); + + // Sign transaction via native app + const signTransaction = useCallback(async (extrinsicHex: string): Promise => { + if (!state.isMobile) { + throw new Error('Not running in mobile app'); + } + return signTransactionNative(extrinsicHex); + }, [state.isMobile]); + + // Navigate back in native app + const goBack = useCallback(() => { + if (state.isMobile) { + navigateBackNative(); + } + }, [state.isMobile]); + + return { + ...state, + connectWallet, + signTransaction, + goBack, + updateState, + }; +} + +export default useMobileBridge; diff --git a/web/src/lib/mobile-bridge.ts b/web/src/lib/mobile-bridge.ts new file mode 100644 index 00000000..31744ad3 --- /dev/null +++ b/web/src/lib/mobile-bridge.ts @@ -0,0 +1,167 @@ +/** + * Mobile Bridge Utility + * + * Handles communication between the web app and native mobile app (React Native WebView). + * When running inside the Pezkuwi mobile app, this bridge enables: + * - Native wallet integration (address, signing) + * - Platform detection + * - Native navigation + */ + +// Type definitions for the native bridge +declare global { + interface Window { + PEZKUWI_MOBILE?: boolean; + PEZKUWI_PLATFORM?: 'ios' | 'android'; + PEZKUWI_ADDRESS?: string; + PEZKUWI_ACCOUNT_NAME?: string; + ReactNativeWebView?: { + postMessage: (message: string) => void; + }; + PezkuwiNativeBridge?: { + signTransaction: (extrinsicHex: string, callback: (signature: string | null, error: string | null) => void) => void; + connectWallet: () => void; + goBack: () => void; + isWalletConnected: () => boolean; + getAddress: () => string | null; + }; + } +} + +export interface MobileBridgeState { + isMobile: boolean; + platform: 'ios' | 'android' | 'web'; + walletAddress: string | null; + accountName: string | null; + isWalletConnected: boolean; +} + +/** + * Check if running inside mobile WebView + */ +export function isMobileApp(): boolean { + return typeof window !== 'undefined' && window.PEZKUWI_MOBILE === true; +} + +/** + * Get current platform + */ +export function getPlatform(): 'ios' | 'android' | 'web' { + if (!isMobileApp()) return 'web'; + return window.PEZKUWI_PLATFORM || 'android'; +} + +/** + * Get native wallet address (if connected in mobile app) + */ +export function getNativeWalletAddress(): string | null { + if (!isMobileApp()) return null; + return window.PEZKUWI_ADDRESS || window.PezkuwiNativeBridge?.getAddress() || null; +} + +/** + * Get native account name + */ +export function getNativeAccountName(): string | null { + if (!isMobileApp()) return null; + return window.PEZKUWI_ACCOUNT_NAME || null; +} + +/** + * Check if native wallet is connected + */ +export function isNativeWalletConnected(): boolean { + if (!isMobileApp()) return false; + return window.PezkuwiNativeBridge?.isWalletConnected() || !!window.PEZKUWI_ADDRESS; +} + +/** + * Request wallet connection from native app + */ +export function requestNativeWalletConnection(): void { + if (!isMobileApp()) return; + window.PezkuwiNativeBridge?.connectWallet(); +} + +/** + * Sign transaction using native wallet + * Returns a promise that resolves with the signature or rejects with error + */ +export function signTransactionNative(extrinsicHex: string): Promise { + return new Promise((resolve, reject) => { + if (!isMobileApp() || !window.PezkuwiNativeBridge) { + reject(new Error('Native bridge not available')); + return; + } + + window.PezkuwiNativeBridge.signTransaction(extrinsicHex, (signature, error) => { + if (error) { + reject(new Error(error)); + } else if (signature) { + resolve(signature); + } else { + reject(new Error('No signature returned')); + } + }); + }); +} + +/** + * Navigate back in native app + */ +export function navigateBackNative(): void { + if (!isMobileApp()) return; + window.PezkuwiNativeBridge?.goBack(); +} + +/** + * Send message to native app + */ +export function sendMessageToNative(type: string, payload?: unknown): void { + if (!isMobileApp() || !window.ReactNativeWebView) return; + + window.ReactNativeWebView.postMessage(JSON.stringify({ type, payload })); +} + +/** + * Get current mobile bridge state + */ +export function getMobileBridgeState(): MobileBridgeState { + const isMobile = isMobileApp(); + return { + isMobile, + platform: getPlatform(), + walletAddress: getNativeWalletAddress(), + accountName: getNativeAccountName(), + isWalletConnected: isNativeWalletConnected(), + }; +} + +/** + * Log to native console (for debugging) + */ +export function logToNative(message: string, data?: unknown): void { + if (!isMobileApp()) { + console.log(message, data); + return; + } + + sendMessageToNative('CONSOLE_LOG', { message, data }); +} + +// Export a singleton for easy access +export const mobileBridge = { + isMobileApp, + getPlatform, + getNativeWalletAddress, + getNativeAccountName, + isNativeWalletConnected, + requestNativeWalletConnection, + signTransactionNative, + navigateBackNative, + sendMessageToNative, + getMobileBridgeState, + logToNative, +}; + +export default mobileBridge;