接入移动端原生扫码能力
新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。 接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。 让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。 同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
This commit is contained in:
@@ -1080,7 +1080,11 @@ const desktopCapabilities = extractDesktopCapabilities(
|
||||
const desktopHandledMethods = extractDesktopHandledMethods(
|
||||
desktopHostBridgeDispatchSource,
|
||||
);
|
||||
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
|
||||
const hostBridgeAcceptedUnsupportedMethods = [
|
||||
'auth.requestLogin',
|
||||
'payment.request',
|
||||
'scanner.scanQrCode',
|
||||
];
|
||||
if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) {
|
||||
throw new Error(
|
||||
`desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`,
|
||||
@@ -1200,7 +1204,7 @@ assertSameList(
|
||||
'desktop shell HostBridge capability profile',
|
||||
);
|
||||
|
||||
for (const capability of sdkBackedCapabilities) {
|
||||
for (const capability of ['auth.requestLogin', 'payment.request']) {
|
||||
if (desktopCapabilities.includes(capability)) {
|
||||
throw new Error(
|
||||
`desktop shell must not declare ${capability} until a real SDK/channel flow is implemented`,
|
||||
@@ -1221,7 +1225,8 @@ if (missingDesktopMethodHandlers.length > 0) {
|
||||
|
||||
const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter(
|
||||
(method) =>
|
||||
!desktopCapabilities.includes(method) && !sdkBackedCapabilities.includes(method),
|
||||
!desktopCapabilities.includes(method) &&
|
||||
!hostBridgeAcceptedUnsupportedMethods.includes(method),
|
||||
);
|
||||
if (undeclaredDesktopMethodHandlers.length > 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -636,7 +636,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unsupported_method_is_explicit() {
|
||||
for method in ["auth.requestLogin", "payment.request"] {
|
||||
for method in ["auth.requestLogin", "payment.request", "scanner.scanQrCode"] {
|
||||
let response = resolve_host_bridge_request(request(method));
|
||||
|
||||
assert!(!response.ok);
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
|
||||
pub(crate) const HOST_BRIDGE_VERSION: u8 = 1;
|
||||
pub(crate) const HOST_BRIDGE_METHODS: [&str; 23] = [
|
||||
pub(crate) const HOST_BRIDGE_METHODS: [&str; 24] = [
|
||||
"host.getRuntime",
|
||||
"appearance.getColorScheme",
|
||||
"auth.requestLogin",
|
||||
@@ -25,6 +25,7 @@ pub(crate) const HOST_BRIDGE_METHODS: [&str; 23] = [
|
||||
"file.exportImage",
|
||||
"file.importImage",
|
||||
"file.captureImage",
|
||||
"scanner.scanQrCode",
|
||||
"file.importAudio",
|
||||
"file.exportAudio",
|
||||
"haptics.impact",
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
"enabled": false
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "允许 Genarrative 使用相机扫描二维码。"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "^56.0.15",
|
||||
"expo": "^56.0.12",
|
||||
"expo-camera": "56.0.8",
|
||||
"expo-clipboard": "^56.0.4",
|
||||
"expo-document-picker": "^56.0.4",
|
||||
"expo-file-system": "^56.0.8",
|
||||
|
||||
@@ -8,6 +8,8 @@ const appPath = new URL('../App.tsx', import.meta.url);
|
||||
const appSource = fs.readFileSync(appPath, 'utf8');
|
||||
const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
|
||||
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
|
||||
const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url);
|
||||
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
|
||||
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
|
||||
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
|
||||
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
|
||||
@@ -421,6 +423,7 @@ for (const [scriptName, expected] of Object.entries({
|
||||
for (const [dependency, expected] of Object.entries({
|
||||
'@expo/metro-runtime': '^56.0.15',
|
||||
expo: '^56.0.12',
|
||||
'expo-camera': '56.0.8',
|
||||
'expo-clipboard': '^56.0.4',
|
||||
'expo-document-picker': '^56.0.4',
|
||||
'expo-file-system': '^56.0.8',
|
||||
@@ -455,6 +458,7 @@ for (const [dependency, expected] of Object.entries({
|
||||
for (const [dependency, expected] of Object.entries({
|
||||
'@expo/metro-runtime': '56.0.15',
|
||||
expo: '56.0.12',
|
||||
'expo-camera': '56.0.8',
|
||||
'expo-clipboard': '56.0.4',
|
||||
'expo-document-picker': '56.0.4',
|
||||
'expo-file-system': '56.0.8',
|
||||
@@ -551,6 +555,7 @@ const sharedPayloadBoundaryImports = [
|
||||
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
|
||||
'normalizeHostBridgeQrCodeValue',
|
||||
'HOST_BRIDGE_TEXT_MIME_TYPES',
|
||||
];
|
||||
for (const boundaryImport of sharedPayloadBoundaryImports) {
|
||||
@@ -574,6 +579,7 @@ const forbiddenLocalPayloadBoundaryDeclarations = [
|
||||
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
|
||||
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
|
||||
'HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH',
|
||||
'HOST_BRIDGE_TEXT_MIME_TYPES',
|
||||
'HOST_BRIDGE_IMAGE_MIME_TYPES',
|
||||
'HOST_BRIDGE_AUDIO_MIME_TYPES',
|
||||
@@ -1119,6 +1125,7 @@ if (shellAppSource.includes('127.0.0.1:3000')) {
|
||||
}
|
||||
|
||||
for (const dependency of [
|
||||
'expo-camera',
|
||||
'expo-file-system',
|
||||
'expo-document-picker',
|
||||
'expo-image-picker',
|
||||
@@ -1152,6 +1159,20 @@ if (Array.isArray(imagePickerPlugin)) {
|
||||
}
|
||||
}
|
||||
|
||||
const cameraPlugin = appConfig.plugins?.find((plugin) =>
|
||||
Array.isArray(plugin) ? plugin[0] === 'expo-camera' : plugin === 'expo-camera',
|
||||
);
|
||||
if (!cameraPlugin) {
|
||||
throw new Error('mobile shell camera plugin is missing');
|
||||
}
|
||||
|
||||
if (Array.isArray(cameraPlugin)) {
|
||||
const pluginOptions = cameraPlugin[1] ?? {};
|
||||
if (typeof pluginOptions.cameraPermission !== 'string') {
|
||||
throw new Error('mobile shell camera permission text is missing');
|
||||
}
|
||||
}
|
||||
|
||||
const notificationsPlugin = appConfig.plugins?.find((plugin) =>
|
||||
Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications',
|
||||
);
|
||||
@@ -1210,6 +1231,7 @@ for (const snippet of [
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'file.captureImage',
|
||||
'scanner.scanQrCode',
|
||||
'file.importAudio',
|
||||
'file.exportAudio',
|
||||
'clipboard.readText',
|
||||
@@ -1228,6 +1250,12 @@ for (const snippet of [
|
||||
'ImagePicker.launchCameraAsync',
|
||||
'ImagePicker.requestMediaLibraryPermissionsAsync',
|
||||
'ImagePicker.requestCameraPermissionsAsync',
|
||||
'scanQrCode',
|
||||
'normalizeHostBridgeQrCodeValue',
|
||||
'completeQrCodeScan',
|
||||
'cancelQrCodeScan',
|
||||
'failQrCodeScan',
|
||||
'subscribeQrScannerState',
|
||||
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
|
||||
"'audio/*'",
|
||||
'File(asset.uri)',
|
||||
@@ -1257,6 +1285,27 @@ for (const snippet of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'CameraView',
|
||||
'Camera.requestCameraPermissionsAsync',
|
||||
'type BarcodeScanningResult',
|
||||
'onBarcodeScanned',
|
||||
'barcodeScannerSettings',
|
||||
"barcodeTypes: ['qr']",
|
||||
'completeQrCodeScan(result.data)',
|
||||
'failQrCodeScan',
|
||||
'cancelQrCodeScan',
|
||||
'subscribeQrScannerState',
|
||||
]) {
|
||||
if (!qrScannerOverlaySource.includes(snippet)) {
|
||||
throw new Error(`mobile shell QR scanner overlay missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shellAppSource.includes('<QrScannerOverlay />')) {
|
||||
throw new Error('mobile shell ShellApp must render the QR scanner overlay');
|
||||
}
|
||||
|
||||
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
|
||||
if (shellAppSource.includes(capabilityQuerySnippet)) {
|
||||
throw new Error('mobile shell URL must resolve platform-aware capabilities');
|
||||
@@ -1323,6 +1372,7 @@ for (const capability of [
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'file.captureImage',
|
||||
'scanner.scanQrCode',
|
||||
'file.importAudio',
|
||||
'file.exportAudio',
|
||||
'haptics.impact',
|
||||
|
||||
@@ -169,7 +169,11 @@ assertEqual(
|
||||
'resize',
|
||||
'Android software keyboard layout mode',
|
||||
);
|
||||
assertSameList(expoConfig.android?.permissions ?? [], [], 'Android explicit permissions');
|
||||
assertSameList(
|
||||
expoConfig.android?.permissions ?? [],
|
||||
['android.permission.CAMERA'],
|
||||
'Android explicit permissions',
|
||||
);
|
||||
assertIncludes(
|
||||
expoConfig.android?.blockedPermissions,
|
||||
'android.permission.MANAGE_EXTERNAL_STORAGE',
|
||||
@@ -262,6 +266,16 @@ assertEqual(
|
||||
'image picker microphone permission',
|
||||
);
|
||||
|
||||
const cameraPlugin = findPlugin('expo-camera');
|
||||
if (!Array.isArray(cameraPlugin)) {
|
||||
throw new Error('Expo config camera plugin is missing options');
|
||||
}
|
||||
assertEqual(
|
||||
typeof cameraPlugin[1]?.cameraPermission,
|
||||
'string',
|
||||
'camera permission text type',
|
||||
);
|
||||
|
||||
const notificationsPlugin = findPlugin('expo-notifications');
|
||||
if (!Array.isArray(notificationsPlugin)) {
|
||||
throw new Error('Expo config notifications plugin is missing options');
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
handleMobileHostBridgeMessage,
|
||||
resetMobileHostBridgeForTest,
|
||||
} from './bridge';
|
||||
import {
|
||||
cancelQrCodeScan,
|
||||
completeQrCodeScan,
|
||||
failQrCodeScan,
|
||||
} from './scanner';
|
||||
|
||||
type NotificationPermissionStatus =
|
||||
Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>;
|
||||
@@ -369,6 +374,9 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(
|
||||
(okResponse.result as { capabilities: string[] }).capabilities,
|
||||
).toContain('file.captureImage');
|
||||
expect(
|
||||
(okResponse.result as { capabilities: string[] }).capabilities,
|
||||
).toContain('scanner.scanQrCode');
|
||||
expect(
|
||||
(okResponse.result as { capabilities: string[] }).capabilities,
|
||||
).toContain('file.importAudio');
|
||||
@@ -1477,6 +1485,37 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(expectFailed(cancelled).error.code).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('scanner.scanQrCode 等待扫码 overlay 返回真实结果', async () => {
|
||||
const pendingResponse = send(request('scanner.scanQrCode'));
|
||||
|
||||
expect(completeQrCodeScan(' https://app.genarrative.world/w/PZ-1 ')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(expectOk(await pendingResponse).result).toEqual({
|
||||
value: 'https://app.genarrative.world/w/PZ-1',
|
||||
format: 'qr_code',
|
||||
});
|
||||
});
|
||||
|
||||
test('scanner.scanQrCode 取消和权限拒绝返回明确错误', async () => {
|
||||
const cancelledResponse = send(request('scanner.scanQrCode'));
|
||||
cancelQrCodeScan();
|
||||
|
||||
expect(expectFailed(await cancelledResponse).error).toEqual({
|
||||
code: 'cancelled',
|
||||
message: 'qr scan cancelled',
|
||||
});
|
||||
|
||||
const deniedResponse = send(request('scanner.scanQrCode'));
|
||||
failQrCodeScan('camera permission denied');
|
||||
|
||||
expect(expectFailed(await deniedResponse).error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'camera permission denied',
|
||||
});
|
||||
});
|
||||
|
||||
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
|
||||
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
|
||||
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ok,
|
||||
unsupported,
|
||||
} from './protocol';
|
||||
import { resetQrScannerForTest, scanQrCode } from './scanner';
|
||||
import { openShare } from './share';
|
||||
import { buildMobileShellUrl } from '../shell/url';
|
||||
|
||||
@@ -279,6 +280,8 @@ export async function dispatchMobileHostBridgeRequest(
|
||||
return ok(request, await importImageFile());
|
||||
case 'file.captureImage':
|
||||
return ok(request, await captureImageFile());
|
||||
case 'scanner.scanQrCode':
|
||||
return ok(request, await scanQrCode());
|
||||
case 'file.importAudio':
|
||||
return ok(request, await importAudioFile());
|
||||
case 'file.exportAudio':
|
||||
@@ -310,4 +313,5 @@ export async function dispatchMobileHostBridgeRequest(
|
||||
export function resetMobileHostBridgeDispatchForTest() {
|
||||
currentShareTarget = null;
|
||||
navigation = null;
|
||||
resetQrScannerForTest();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
type HostBridgeError,
|
||||
type ScannerScanQrCodeResult,
|
||||
normalizeHostBridgeQrCodeValue,
|
||||
} from '../../../../packages/shared/src/contracts/hostBridge';
|
||||
|
||||
type QrScannerState = {
|
||||
active: boolean;
|
||||
requestKey: number;
|
||||
};
|
||||
|
||||
type PendingQrScan = {
|
||||
requestKey: number;
|
||||
resolve: (result: ScannerScanQrCodeResult) => void;
|
||||
reject: (error: HostBridgeError) => void;
|
||||
};
|
||||
|
||||
const scannerListeners = new Set<(state: QrScannerState) => void>();
|
||||
|
||||
let pendingQrScan: PendingQrScan | null = null;
|
||||
let nextQrScanRequestKey = 0;
|
||||
|
||||
function hostBridgeError(
|
||||
code: HostBridgeError['code'],
|
||||
message: string,
|
||||
): HostBridgeError {
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
function currentQrScannerState(): QrScannerState {
|
||||
return {
|
||||
active: Boolean(pendingQrScan),
|
||||
requestKey: pendingQrScan?.requestKey ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function emitQrScannerState() {
|
||||
const state = currentQrScannerState();
|
||||
for (const listener of scannerListeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingQrScan() {
|
||||
pendingQrScan = null;
|
||||
emitQrScannerState();
|
||||
}
|
||||
|
||||
export function subscribeQrScannerState(
|
||||
listener: (state: QrScannerState) => void,
|
||||
) {
|
||||
scannerListeners.add(listener);
|
||||
listener(currentQrScannerState());
|
||||
|
||||
return () => {
|
||||
scannerListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function scanQrCode(): Promise<ScannerScanQrCodeResult> {
|
||||
if (pendingQrScan) {
|
||||
return Promise.reject(
|
||||
hostBridgeError('host_error', 'qr scanner already active'),
|
||||
);
|
||||
}
|
||||
|
||||
nextQrScanRequestKey += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingQrScan = {
|
||||
requestKey: nextQrScanRequestKey,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
emitQrScannerState();
|
||||
});
|
||||
}
|
||||
|
||||
export function completeQrCodeScan(rawValue: unknown) {
|
||||
const result = normalizeHostBridgeQrCodeValue(rawValue);
|
||||
if (!result || !pendingQrScan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pendingQrScan.resolve(result);
|
||||
clearPendingQrScan();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cancelQrCodeScan() {
|
||||
if (!pendingQrScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingQrScan.reject(hostBridgeError('cancelled', 'qr scan cancelled'));
|
||||
clearPendingQrScan();
|
||||
}
|
||||
|
||||
export function failQrCodeScan(message: string) {
|
||||
if (!pendingQrScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingQrScan.reject(hostBridgeError('host_error', message));
|
||||
clearPendingQrScan();
|
||||
}
|
||||
|
||||
export function resetQrScannerForTest() {
|
||||
pendingQrScan = null;
|
||||
nextQrScanRequestKey = 0;
|
||||
scannerListeners.clear();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Camera,
|
||||
CameraView,
|
||||
type BarcodeScanningResult,
|
||||
} from 'expo-camera';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import {
|
||||
cancelQrCodeScan,
|
||||
completeQrCodeScan,
|
||||
failQrCodeScan,
|
||||
subscribeQrScannerState,
|
||||
} from '../host-bridge/scanner';
|
||||
|
||||
export function QrScannerOverlay() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [requestKey, setRequestKey] = useState(0);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
const [scanCompleted, setScanCompleted] = useState(false);
|
||||
|
||||
useEffect(() => subscribeQrScannerState((state) => {
|
||||
setIsActive(state.active);
|
||||
setRequestKey(state.requestKey);
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHasPermission(false);
|
||||
setScanCompleted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setHasPermission(false);
|
||||
setScanCompleted(false);
|
||||
void Camera.requestCameraPermissionsAsync()
|
||||
.then((permission) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (!permission.granted) {
|
||||
failQrCodeScan('camera permission denied');
|
||||
return;
|
||||
}
|
||||
setHasPermission(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) {
|
||||
failQrCodeScan('camera permission unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [isActive, requestKey]);
|
||||
|
||||
const handleBarcodeScanned = useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (scanCompleted || result.type !== 'qr') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (completeQrCodeScan(result.data)) {
|
||||
setScanCompleted(true);
|
||||
}
|
||||
},
|
||||
[scanCompleted],
|
||||
);
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{hasPermission ? (
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
onBarcodeScanned={scanCompleted ? undefined : handleBarcodeScanned}
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ['qr'],
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<View pointerEvents="none" style={styles.frame} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="关闭扫码"
|
||||
onPress={cancelQrCodeScan}
|
||||
style={styles.closeButton}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>关闭</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: '#0c0a09',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
frame: {
|
||||
position: 'absolute',
|
||||
top: '22%',
|
||||
right: 42,
|
||||
bottom: '22%',
|
||||
left: 42,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fffdf9',
|
||||
borderRadius: 8,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
minWidth: 76,
|
||||
minHeight: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#fffdf9',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
closeButtonText: {
|
||||
color: '#211a16',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
shouldBlockMobileWebViewNavigationRequest,
|
||||
} from './webViewPolicy';
|
||||
import { parseMobileWebViewHistoryStateMessage } from './webViewHistory';
|
||||
import { QrScannerOverlay } from './QrScannerOverlay';
|
||||
|
||||
function buildHostBridgeMessageScript(message: unknown) {
|
||||
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
|
||||
@@ -371,6 +372,7 @@ export default function ShellApp() {
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
<QrScannerOverlay />
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user