@@ -347,6 +347,7 @@ function Browser() {
347347 const { manifest, fetchManifest, getStartUrl, shouldRedirectToStartUrl } = useWebAppManifest ( )
348348 const [ showBalance , setShowBalance ] = useState ( false )
349349 const [ isFullscreen , setIsFullscreen ] = useState ( false )
350+ const activeCameraStreams = useRef < Set < string > > ( new Set ( ) )
350351
351352 // Safety check - if somehow activeTab is null, force create a new tab
352353 // This is done after all hooks to avoid violating Rules of Hooks
@@ -762,6 +763,59 @@ function Browser() {
762763 // === 1. Injected JS ============================================
763764 const injectedJavaScript = useMemo (
764765 ( ) => `
766+ // Camera access polyfill - provides mock streams to prevent WKWebView camera access
767+ (function() {
768+ if (!navigator.mediaDevices) return;
769+
770+ const originalGetUserMedia = navigator.mediaDevices.getUserMedia?.bind(navigator.mediaDevices);
771+
772+ function createMockMediaStream() {
773+ const mockTrack = {
774+ id: 'mock-video-' + Date.now(),
775+ kind: 'video',
776+ label: 'React Native Camera',
777+ enabled: true,
778+ muted: false,
779+ readyState: 'live',
780+ stop() { this.readyState = 'ended'; },
781+ addEventListener() {},
782+ removeEventListener() {},
783+ getSettings: () => ({ width: 640, height: 480, frameRate: 30 })
784+ };
785+
786+ return {
787+ id: 'mock-stream-' + Date.now(),
788+ active: true,
789+ getTracks: () => [mockTrack],
790+ getVideoTracks: () => [mockTrack],
791+ getAudioTracks: () => [],
792+ addEventListener() {},
793+ removeEventListener() {}
794+ };
795+ }
796+
797+ navigator.mediaDevices.getUserMedia = function(constraints) {
798+ const hasVideo = constraints?.video === true ||
799+ (typeof constraints?.video === 'object' && constraints.video);
800+
801+ if (hasVideo) {
802+ // Notify React Native of camera request
803+ window.ReactNativeWebView?.postMessage(JSON.stringify({
804+ type: 'CAMERA_REQUEST',
805+ constraints
806+ }));
807+
808+ // Return mock stream immediately to prevent native camera
809+ return Promise.resolve(createMockMediaStream());
810+ }
811+
812+ // Allow audio-only requests through original implementation
813+ return originalGetUserMedia ?
814+ originalGetUserMedia(constraints) :
815+ Promise.reject(new Error('Media not supported'));
816+ };
817+ })();
818+
765819 // Push Notification API polyfill
766820 (function() {
767821 // Check if Notification API already exists
@@ -940,6 +994,103 @@ function Browser() {
940994 } catch (e) {}
941995 });
942996
997+ // Completely replace getUserMedia to prevent WKWebView camera access
998+ if (navigator.mediaDevices) {
999+ // Store original for potential fallback, but never use it for video
1000+ const originalGetUserMedia = navigator.mediaDevices.getUserMedia?.bind(navigator.mediaDevices);
1001+
1002+ // Completely override getUserMedia - never call original for video constraints
1003+ navigator.mediaDevices.getUserMedia = function(constraints) {
1004+ console.log('[WebView] getUserMedia intercepted:', constraints);
1005+
1006+ // Check if requesting video - if so, handle in React Native
1007+ const hasVideo = constraints && (constraints.video === true ||
1008+ (typeof constraints.video === 'object' && constraints.video !== false));
1009+
1010+ if (hasVideo) {
1011+ console.log('[WebView] Video requested - handling in React Native');
1012+ // Send request to native - handle camera completely in React Native
1013+ window.ReactNativeWebView?.postMessage(JSON.stringify({
1014+ type: 'CAMERA_REQUEST',
1015+ constraints: constraints
1016+ }));
1017+
1018+ return new Promise((resolve, reject) => {
1019+ const handler = (event) => {
1020+ try {
1021+ const data = JSON.parse(event.data);
1022+ if (data.type === 'CAMERA_RESPONSE') {
1023+ window.removeEventListener('message', handler);
1024+ if (data.success) {
1025+ // Create a more complete mock MediaStream
1026+ const mockVideoTrack = {
1027+ id: 'mock-video-track-' + Date.now(),
1028+ kind: 'video',
1029+ label: 'React Native Camera',
1030+ enabled: true,
1031+ muted: false,
1032+ readyState: 'live',
1033+ stop: () => console.log('[WebView] Mock video track stopped'),
1034+ addEventListener: () => {},
1035+ removeEventListener: () => {},
1036+ dispatchEvent: () => false,
1037+ getSettings: () => ({ width: 640, height: 480, frameRate: 30 }),
1038+ getCapabilities: () => ({ width: { min: 320, max: 1920 }, height: { min: 240, max: 1080 } }),
1039+ getConstraints: () => constraints.video || {}
1040+ };
1041+
1042+ const mockStream = {
1043+ id: 'mock-stream-' + Date.now(),
1044+ active: true,
1045+ getTracks: () => [mockVideoTrack],
1046+ getVideoTracks: () => [mockVideoTrack],
1047+ getAudioTracks: () => [],
1048+ addEventListener: () => {},
1049+ removeEventListener: () => {},
1050+ dispatchEvent: () => false,
1051+ addTrack: () => {},
1052+ removeTrack: () => {},
1053+ clone: () => mockStream
1054+ };
1055+ console.log('[WebView] Resolving with mock stream:', mockStream);
1056+ resolve(mockStream);
1057+ } else {
1058+ reject(new Error(data.error || 'Camera access denied'));
1059+ }
1060+ }
1061+ } catch (e) {
1062+ reject(e);
1063+ }
1064+ };
1065+ window.addEventListener('message', handler);
1066+
1067+ // Timeout after 10 seconds
1068+ setTimeout(() => {
1069+ window.removeEventListener('message', handler);
1070+ reject(new Error('Camera request timeout'));
1071+ }, 10000);
1072+ });
1073+ } else if (constraints && constraints.audio && !hasVideo) {
1074+ // Audio-only requests can use original implementation
1075+ console.log('[WebView] Audio-only request - using original getUserMedia');
1076+ return originalGetUserMedia ? originalGetUserMedia(constraints) :
1077+ Promise.reject(new Error('Audio not supported'));
1078+ } else {
1079+ // No media requested
1080+ return Promise.reject(new Error('No media constraints specified'));
1081+ }
1082+ };
1083+
1084+ // Also override the deprecated navigator.getUserMedia if it exists
1085+ if (navigator.getUserMedia) {
1086+ navigator.getUserMedia = function(constraints, success, error) {
1087+ navigator.mediaDevices.getUserMedia(constraints)
1088+ .then(success)
1089+ .catch(error);
1090+ };
1091+ }
1092+ }
1093+
9431094 // Console logging
9441095 const originalLog = console.log;
9451096 const originalWarn = console.warn;
@@ -1141,6 +1292,47 @@ function Browser() {
11411292 return
11421293 }
11431294
1295+ // Handle camera requests
1296+ if ( msg . type === 'CAMERA_REQUEST' ) {
1297+ console . log ( 'Camera access requested by website:' , msg . constraints )
1298+
1299+ // Track active camera stream for this tab
1300+ activeCameraStreams . current . add ( activeTab . id . toString ( ) )
1301+
1302+ // Handle camera request entirely in React Native to avoid WKWebView camera issues
1303+ // For now, just grant permission - actual camera implementation would go here
1304+ try {
1305+ // Here you would implement actual React Native camera handling
1306+ // For now, we'll just grant permission and return mock stream
1307+ console . log ( 'Granting camera permission - camera handled by React Native' )
1308+
1309+ if ( activeTab . webviewRef ?. current ) {
1310+ activeTab . webviewRef . current . injectJavaScript ( `
1311+ window.dispatchEvent(new MessageEvent('message', {
1312+ data: JSON.stringify({
1313+ type: 'CAMERA_RESPONSE',
1314+ success: true
1315+ })
1316+ }));
1317+ ` )
1318+ }
1319+ } catch ( error ) {
1320+ console . error ( 'Camera permission error:' , error )
1321+ if ( activeTab . webviewRef ?. current ) {
1322+ activeTab . webviewRef . current . injectJavaScript ( `
1323+ window.dispatchEvent(new MessageEvent('message', {
1324+ data: JSON.stringify({
1325+ type: 'CAMERA_RESPONSE',
1326+ success: false,
1327+ error: 'Camera permission denied'
1328+ })
1329+ }));
1330+ ` )
1331+ }
1332+ }
1333+ return
1334+ }
1335+
11441336 // Handle notification permission request
11451337 if ( msg . type === 'REQUEST_NOTIFICATION_PERMISSION' ) {
11461338 const permission = await handleNotificationPermissionRequest ( activeTab . url )
@@ -1305,6 +1497,24 @@ function Browser() {
13051497 return
13061498 }
13071499
1500+ // Clean up camera streams when navigating away from a page
1501+ if ( navState . url !== activeTab . url && activeCameraStreams . current . has ( activeTab . id . toString ( ) ) ) {
1502+ console . log ( 'Cleaning up camera streams for tab navigation' )
1503+ activeCameraStreams . current . delete ( activeTab . id . toString ( ) )
1504+
1505+ // Inject script to stop any active media streams
1506+ activeTab . webviewRef ?. current ?. injectJavaScript ( `
1507+ (function() {
1508+ if (window.__activeMediaStreams) {
1509+ window.__activeMediaStreams.forEach(stream => {
1510+ stream.getTracks().forEach(track => track.stop());
1511+ });
1512+ window.__activeMediaStreams = [];
1513+ }
1514+ })();
1515+ ` )
1516+ }
1517+
13081518 // Log navigation state changes with back/forward capabilities
13091519 console . log ( '🌐 Navigation State Change:' , {
13101520 url : navState . url ,
@@ -1794,9 +2004,12 @@ function Browser() {
17942004 injectedJavaScript = { injectedJavaScript }
17952005 onNavigationStateChange = { handleNavStateChange }
17962006 userAgent = { isDesktopView ? desktopUserAgent : mobileUserAgent }
2007+ mediaPlaybackRequiresUserAction = { false }
2008+ allowsInlineMediaPlayback = { true }
2009+ // Deny all WebView permissions to prevent native camera access
2010+ onPermissionRequest = { ( ) => false }
17972011 onError = { ( syntheticEvent : any ) => {
17982012 const { nativeEvent } = syntheticEvent
1799- // Ignore favicon errors for about:blank
18002013 if ( nativeEvent . url ?. includes ( 'favicon.ico' ) && activeTab ?. url === kNEW_TAB_URL ) {
18012014 return
18022015 }
@@ -1839,16 +2052,16 @@ function Browser() {
18392052 >
18402053 { /* deggen: Back Button unless address bar is active, in which case it's the share button */ }
18412054 { addressFocused ? null
1842- : < TouchableOpacity
1843- style = { activeTab ?. canGoForward ? styles . addressBarBackButton : styles . addressBarIcon }
1844- disabled = { isBackDisabled }
1845- onPress = { navBack }
1846- activeOpacity = { 0.6 }
1847- delayPressIn = { 0.1 }
1848- >
1849- < Ionicons name = "arrow-back" size = { 26 } color = { ! isBackDisabled ? colors . textPrimary : '#cccccc' } />
1850- </ TouchableOpacity > }
1851-
2055+ : < TouchableOpacity
2056+ style = { activeTab ?. canGoForward ? styles . addressBarBackButton : styles . addressBarIcon }
2057+ disabled = { isBackDisabled }
2058+ onPress = { navBack }
2059+ activeOpacity = { 0.6 }
2060+ delayPressIn = { 0.1 }
2061+ >
2062+ < Ionicons name = "arrow-back" size = { 26 } color = { ! isBackDisabled ? colors . textPrimary : '#cccccc' } />
2063+ </ TouchableOpacity > }
2064+
18522065 { activeTab ?. canGoForward && < TouchableOpacity
18532066 style = { styles . addressBarForwardButton }
18542067 disabled = { isForwardDisabled }
0 commit comments