Skip to content

Commit d618c10

Browse files
authored
Merge pull request #30 from bsv-blockchain/fix/camera-access-view
Added overrides that fix bug with camera access
2 parents c42aac2 + e95f80e commit d618c10

File tree

1 file changed

+224
-11
lines changed

1 file changed

+224
-11
lines changed

app/browser.tsx

Lines changed: 224 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)