Files
Genarrative/apps/mobile-shell/src/host-bridge/dispatch.ts
T
kdletters e4b9cbf09d 接入移动端原生扫码能力
新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。

接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。

让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。

同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
2026-06-19 05:13:21 +08:00

318 lines
8.9 KiB
TypeScript

import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Notifications from 'expo-notifications';
import {
Appearance,
Platform,
PushNotificationIOS,
} from 'react-native';
import {
type ClipboardReadTextResult,
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeRequest,
type NavigateNativePagePayload,
normalizeHostBridgeBadgeCount,
normalizeHostBridgeClipboardText,
normalizeHostBridgeColorScheme,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeHapticsImpactStyle,
normalizeHostBridgeLocalNotification,
type OpenExternalUrlPayload,
type SetBadgeCountPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { resolveMobileShellWebViewUrl } from '../shell/navigation';
import { getMobileNetworkStatus } from '../shell/network';
import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime';
import { resolveMobileHostCapabilities } from './capabilities';
import {
captureImageFile,
exportAudioFile,
exportImageFile,
exportTextFile,
importAudioFile,
importImageFile,
importTextFile,
} from './files';
import {
type MobileHostBridgeNavigation,
failure,
invalidRequest,
ok,
unsupported,
} from './protocol';
import { resetQrScannerForTest, scanQrCode } from './scanner';
import { openShare } from './share';
import { buildMobileShellUrl } from '../shell/url';
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
export function configureMobileHostBridgeNavigation(
nextNavigation: MobileHostBridgeNavigation | null,
) {
navigation = nextNavigation;
}
async function openExternalUrl(payload: unknown) {
const url = normalizeHostBridgeExternalUrl(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!url) {
throw invalidRequest('url must use an allowed external protocol');
}
if (!(await Linking.canOpenURL(url))) {
throw {
code: 'host_error',
message: 'external URL cannot be opened',
} satisfies HostBridgeError;
}
await Linking.openURL(url);
return true;
}
async function writeClipboard(payload: unknown) {
const text = (payload as ClipboardWriteTextPayload | undefined)?.text;
if (typeof text !== 'string') {
throw invalidRequest('text is required');
}
await Clipboard.setStringAsync(text);
return true;
}
async function readClipboard(): Promise<ClipboardReadTextResult> {
const result = normalizeHostBridgeClipboardText(
await Clipboard.getStringAsync(),
);
if (!result) {
throw {
code: 'host_error',
message: 'clipboard text unavailable',
} satisfies HostBridgeError;
}
return result;
}
async function runHaptics(payload: unknown) {
const style = normalizeHostBridgeHapticsImpactStyle(
(payload as HapticsImpactPayload | undefined)?.style,
);
if (!style) {
throw invalidRequest('haptics impact style must be light, medium, or heavy');
}
const impactStyle =
style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: style === 'medium'
? Haptics.ImpactFeedbackStyle.Medium
: Haptics.ImpactFeedbackStyle.Light;
await Haptics.impactAsync(impactStyle);
return true;
}
function setBadgeCount(payload: unknown) {
if (Platform.OS !== 'ios') {
throw {
code: 'unsupported_capability',
message: 'app badge count is only supported on iOS mobile shell',
} satisfies HostBridgeError;
}
const count = normalizeHostBridgeBadgeCount(
(payload as SetBadgeCountPayload | undefined)?.count,
);
if (count === null) {
throw invalidRequest('count must be an integer between 0 and 99999');
}
PushNotificationIOS.setApplicationIconBadgeNumber(count);
return true;
}
function hasNotificationPermission(
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
) {
return (
permission.granted ||
permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}
async function ensureNotificationPermission() {
const currentPermission = await Notifications.getPermissionsAsync();
if (hasNotificationPermission(currentPermission)) {
return;
}
const requestedPermission = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: false,
},
});
if (!hasNotificationPermission(requestedPermission)) {
throw {
code: 'host_error',
message: 'notification permission denied',
} satisfies HostBridgeError;
}
}
async function showLocalNotification(payload: unknown) {
const notification = normalizeHostBridgeLocalNotification(payload);
if (!notification) {
throw invalidRequest('title is required');
}
await ensureNotificationPermission();
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync(
LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
}
await Notifications.scheduleNotificationAsync({
content: notification,
trigger:
Platform.OS === 'android'
? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID }
: null,
});
return true;
}
function getColorScheme() {
return {
colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()),
};
}
function openNativePage(payload: unknown) {
if (!navigation) {
throw unsupported('navigation.openNativePage');
}
const url = (payload as NavigateNativePagePayload | undefined)?.url;
if (typeof url !== 'string') {
throw invalidRequest('url is required');
}
const webViewUrl = resolveMobileShellWebViewUrl(
url,
navigation.allowedOrigin,
);
if (!webViewUrl) {
throw invalidRequest('url must be an allowed same-origin web path');
}
navigation.openWebViewUrl(
buildMobileShellUrl(webViewUrl, navigation.urlOptions),
);
return true;
}
function reloadWebView() {
if (!navigation) {
throw unsupported('app.reloadWebView');
}
navigation.reloadWebView();
return true;
}
export async function dispatchMobileHostBridgeRequest(
request: HostBridgeRequest,
) {
switch (request.method) {
case 'host.getRuntime':
return ok(request, {
shell: 'expo_mobile',
platform: Platform.OS === 'ios' ? 'ios' : 'android',
hostVersion: MOBILE_SHELL_HOST_VERSION,
bridgeVersion: HOST_BRIDGE_VERSION,
capabilities: resolveMobileHostCapabilities(),
});
case 'appearance.getColorScheme':
return ok(request, getColorScheme());
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
case 'app.reloadWebView':
return ok(request, reloadWebView());
case 'network.status':
return ok(request, await getMobileNetworkStatus());
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
case 'clipboard.readText':
return ok(request, await readClipboard());
case 'file.exportText':
return ok(request, await exportTextFile(request.payload));
case 'file.importText':
return ok(request, await importTextFile());
case 'file.exportImage':
return ok(request, await exportImageFile(request.payload));
case 'file.importImage':
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':
return ok(request, await exportAudioFile(request.payload));
case 'haptics.impact':
return ok(request, await runHaptics(request.payload));
case 'notification.showLocal':
return ok(request, await showLocalNotification(request.payload));
case 'app.setBadgeCount':
return ok(request, setBadgeCount(request.payload));
case 'share.open':
return ok(request, await openShare(request.payload, currentShareTarget));
case 'share.setTarget':
currentShareTarget =
request.payload && typeof request.payload === 'object'
? (request.payload as { target?: unknown }).target
: null;
return ok(request, true);
case 'navigation.openNativePage':
return ok(request, openNativePage(request.payload));
case 'auth.requestLogin':
case 'payment.request':
return failure(request, unsupported(request.method));
default:
return failure(request, unsupported(request.method));
}
}
export function resetMobileHostBridgeDispatchForTest() {
currentShareTarget = null;
navigation = null;
resetQrScannerForTest();
}