e4b9cbf09d
新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。 接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。 让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。 同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
318 lines
8.9 KiB
TypeScript
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();
|
|
}
|