新增 app.reloadWebView HostBridge 契约和 H5 facade 移动端通过 react-native-webview reload 刷新当前 WebView 桌面端通过 Tauri WebviewWindow reload 刷新主窗口 更新壳能力检查、测试、方案文档和共享决策记录
802 lines
21 KiB
TypeScript
802 lines
21 KiB
TypeScript
import * as Clipboard from 'expo-clipboard';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
import { File, Paths } from 'expo-file-system';
|
|
import * as Haptics from 'expo-haptics';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import * as Linking from 'expo-linking';
|
|
import * as Notifications from 'expo-notifications';
|
|
import * as Sharing from 'expo-sharing';
|
|
import {
|
|
Appearance,
|
|
Platform,
|
|
PushNotificationIOS,
|
|
Share,
|
|
} from 'react-native';
|
|
|
|
import {
|
|
type ClipboardReadTextResult,
|
|
type ClipboardWriteTextPayload,
|
|
type FileExportImagePayload,
|
|
type FileExportImageResult,
|
|
type FileExportTextPayload,
|
|
type FileExportTextResult,
|
|
type FileImportImageResult,
|
|
type FileImportTextResult,
|
|
type HapticsImpactPayload,
|
|
HOST_BRIDGE_PROTOCOL,
|
|
HOST_BRIDGE_VERSION,
|
|
type HostBridgeCapability,
|
|
type HostBridgeError,
|
|
type HostBridgeImageMimeType,
|
|
type HostBridgeMethod,
|
|
type HostBridgeRequest,
|
|
type HostBridgeResponse,
|
|
type HostBridgeTextMimeType,
|
|
type NavigateNativePagePayload,
|
|
normalizeHostBridgeBadgeCount,
|
|
normalizeHostBridgeClipboardText,
|
|
normalizeHostBridgeColorScheme,
|
|
normalizeHostBridgeExportFileName,
|
|
normalizeHostBridgeExternalUrl,
|
|
normalizeHostBridgeLocalNotification,
|
|
type OpenExternalUrlPayload,
|
|
type SetBadgeCountPayload,
|
|
type ShareOpenPayload,
|
|
} from '../../../packages/shared/src/contracts/hostBridge';
|
|
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
|
|
import { getMobileNetworkStatus } from './mobileShellNetwork';
|
|
|
|
const WEB_APP_ORIGIN = 'https://app.genarrative.world';
|
|
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
|
const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
|
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
|
|
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
|
|
'text/plain',
|
|
'text/markdown',
|
|
'text/csv',
|
|
'application/json',
|
|
]);
|
|
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/webp',
|
|
]);
|
|
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: async () => ({
|
|
shouldShowBanner: true,
|
|
shouldShowList: true,
|
|
shouldPlaySound: false,
|
|
shouldSetBadge: false,
|
|
}),
|
|
});
|
|
|
|
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
|
'host.getRuntime',
|
|
'appearance.getColorScheme',
|
|
'host.events',
|
|
'app.lifecycle',
|
|
'share.open',
|
|
'share.setTarget',
|
|
'navigation.openNativePage',
|
|
'navigation.canGoBack',
|
|
'app.reloadWebView',
|
|
'app.openExternalUrl',
|
|
'network.status',
|
|
'network.statusChanged',
|
|
'clipboard.writeText',
|
|
'clipboard.readText',
|
|
'file.exportText',
|
|
'file.importText',
|
|
'file.exportImage',
|
|
'file.importImage',
|
|
'haptics.impact',
|
|
'notification.showLocal',
|
|
];
|
|
|
|
export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
|
...MOBILE_HOST_CAPABILITIES,
|
|
'app.setBadgeCount',
|
|
];
|
|
|
|
export function resolveMobileHostCapabilities(platform = Platform.OS) {
|
|
return platform === 'ios'
|
|
? IOS_MOBILE_HOST_CAPABILITIES
|
|
: MOBILE_HOST_CAPABILITIES;
|
|
}
|
|
|
|
export type MobileHostBridgeNavigation = {
|
|
allowedOrigin: string;
|
|
openWebViewUrl: (url: string) => void;
|
|
reloadWebView: () => void;
|
|
};
|
|
|
|
let currentShareTarget: unknown = null;
|
|
let navigation: MobileHostBridgeNavigation | null = null;
|
|
|
|
export function configureMobileHostBridgeNavigation(
|
|
nextNavigation: MobileHostBridgeNavigation | null,
|
|
) {
|
|
navigation = nextNavigation;
|
|
}
|
|
|
|
function unsupported(method: HostBridgeMethod): HostBridgeError {
|
|
return {
|
|
code: 'unsupported_method',
|
|
message: `${method} unsupported in mobile shell`,
|
|
};
|
|
}
|
|
|
|
function invalidRequest(message: string): HostBridgeError {
|
|
return {
|
|
code: 'invalid_request',
|
|
message,
|
|
};
|
|
}
|
|
|
|
function utf8ByteLength(value: string) {
|
|
let bytes = 0;
|
|
for (const character of value) {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
if (codePoint <= 0x7f) {
|
|
bytes += 1;
|
|
} else if (codePoint <= 0x7ff) {
|
|
bytes += 2;
|
|
} else if (codePoint <= 0xffff) {
|
|
bytes += 3;
|
|
} else {
|
|
bytes += 4;
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function normalizedBase64Data(value: unknown) {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const normalizedValue = value.trim();
|
|
if (
|
|
!normalizedValue ||
|
|
normalizedValue.length % 4 !== 0 ||
|
|
!/^[A-Za-z0-9+/]+={0,2}$/u.test(normalizedValue)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return normalizedValue;
|
|
}
|
|
|
|
function base64DecodedByteLength(value: string) {
|
|
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
|
|
return Math.floor((value.length * 3) / 4) - padding;
|
|
}
|
|
|
|
function isHostBridgeRequest(value: unknown): value is HostBridgeRequest {
|
|
if (!value || typeof value !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const candidate = value as Partial<HostBridgeRequest>;
|
|
return (
|
|
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
|
candidate.version === HOST_BRIDGE_VERSION &&
|
|
typeof candidate.id === 'string' &&
|
|
typeof candidate.method === 'string'
|
|
);
|
|
}
|
|
|
|
function parseRequest(raw: string) {
|
|
try {
|
|
return JSON.parse(raw) as unknown;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function ok<Result>(
|
|
request: HostBridgeRequest,
|
|
result?: Result,
|
|
): HostBridgeResponse<Result> {
|
|
return {
|
|
bridge: HOST_BRIDGE_PROTOCOL,
|
|
version: HOST_BRIDGE_VERSION,
|
|
id: request.id,
|
|
ok: true,
|
|
result,
|
|
};
|
|
}
|
|
|
|
function failure(
|
|
request: Pick<HostBridgeRequest, 'id'>,
|
|
error: HostBridgeError,
|
|
): HostBridgeResponse {
|
|
return {
|
|
bridge: HOST_BRIDGE_PROTOCOL,
|
|
version: HOST_BRIDGE_VERSION,
|
|
id: request.id,
|
|
ok: false,
|
|
error,
|
|
};
|
|
}
|
|
|
|
async function openExternalUrl(payload: unknown) {
|
|
const url = normalizeHostBridgeExternalUrl(
|
|
(payload as OpenExternalUrlPayload | undefined)?.url,
|
|
);
|
|
if (!url) {
|
|
throw invalidRequest('url must use an allowed external protocol');
|
|
}
|
|
|
|
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 exportTextFile(payload: unknown): Promise<FileExportTextResult> {
|
|
const exportPayload = payload as FileExportTextPayload | undefined;
|
|
const content = exportPayload?.content;
|
|
if (typeof content !== 'string') {
|
|
throw invalidRequest('content is required');
|
|
}
|
|
|
|
const bytes = utf8ByteLength(content);
|
|
if (bytes > EXPORT_TEXT_MAX_BYTES) {
|
|
throw invalidRequest('content exceeds file export size limit');
|
|
}
|
|
|
|
const isSharingAvailable = await Sharing.isAvailableAsync();
|
|
if (!isSharingAvailable) {
|
|
throw {
|
|
code: 'unsupported_capability',
|
|
message: 'file sharing is unavailable in mobile shell',
|
|
} satisfies HostBridgeError;
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName);
|
|
const mimeType = exportPayload?.mimeType || 'text/plain';
|
|
const file = new File(Paths.cache, fileName);
|
|
file.write(content);
|
|
await Sharing.shareAsync(file.uri, {
|
|
mimeType,
|
|
UTI: 'public.plain-text',
|
|
dialogTitle: fileName,
|
|
});
|
|
|
|
return {
|
|
action: 'saved',
|
|
fileName,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
function normalizeImportedTextMimeType(
|
|
value: unknown,
|
|
fileName: string,
|
|
): HostBridgeTextMimeType | null {
|
|
if (typeof value === 'string') {
|
|
const mimeType = value.toLowerCase();
|
|
if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) {
|
|
return mimeType as HostBridgeTextMimeType;
|
|
}
|
|
}
|
|
|
|
const normalizedName = fileName.toLowerCase();
|
|
if (normalizedName.endsWith('.json')) {
|
|
return 'application/json';
|
|
}
|
|
if (normalizedName.endsWith('.md') || normalizedName.endsWith('.markdown')) {
|
|
return 'text/markdown';
|
|
}
|
|
if (normalizedName.endsWith('.csv')) {
|
|
return 'text/csv';
|
|
}
|
|
if (normalizedName.endsWith('.txt')) {
|
|
return 'text/plain';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function importTextFile(): Promise<FileImportTextResult> {
|
|
const result = await DocumentPicker.getDocumentAsync({
|
|
copyToCacheDirectory: true,
|
|
multiple: false,
|
|
type: ['text/*', 'application/json'],
|
|
});
|
|
if (result.canceled) {
|
|
throw {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
} satisfies HostBridgeError;
|
|
}
|
|
|
|
const asset = result.assets[0];
|
|
if (!asset?.uri) {
|
|
throw invalidRequest('text file is required');
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeExportFileName(
|
|
asset.name || 'genarrative-import.txt',
|
|
);
|
|
const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName);
|
|
if (!mimeType) {
|
|
throw invalidRequest('mimeType must be an allowed text type');
|
|
}
|
|
if (
|
|
typeof asset.size === 'number' &&
|
|
(asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES)
|
|
) {
|
|
throw invalidRequest('text exceeds file import size limit');
|
|
}
|
|
|
|
const file = new File(asset.uri);
|
|
const content = await file.text();
|
|
const bytes = utf8ByteLength(content);
|
|
if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) {
|
|
throw invalidRequest('text exceeds file import size limit');
|
|
}
|
|
|
|
return {
|
|
action: 'selected',
|
|
fileName,
|
|
content,
|
|
mimeType,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
async function exportImageFile(payload: unknown): Promise<FileExportImageResult> {
|
|
const exportPayload = payload as FileExportImagePayload | undefined;
|
|
const mimeType = exportPayload?.mimeType;
|
|
if (
|
|
typeof mimeType !== 'string' ||
|
|
!HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType)
|
|
) {
|
|
throw invalidRequest('mimeType must be an allowed image type');
|
|
}
|
|
|
|
const base64Data = normalizedBase64Data(exportPayload?.base64Data);
|
|
if (!base64Data) {
|
|
throw invalidRequest('base64Data is required');
|
|
}
|
|
const bytes = base64DecodedByteLength(base64Data);
|
|
if (bytes > EXPORT_IMAGE_MAX_BYTES) {
|
|
throw invalidRequest('image exceeds file export size limit');
|
|
}
|
|
|
|
const isSharingAvailable = await Sharing.isAvailableAsync();
|
|
if (!isSharingAvailable) {
|
|
throw {
|
|
code: 'unsupported_capability',
|
|
message: 'file sharing is unavailable in mobile shell',
|
|
} satisfies HostBridgeError;
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName);
|
|
const file = new File(Paths.cache, fileName);
|
|
file.write(base64Data, { encoding: 'base64' });
|
|
await Sharing.shareAsync(file.uri, {
|
|
mimeType,
|
|
UTI: mimeType === 'image/png' ? 'public.png' : 'public.image',
|
|
dialogTitle: fileName,
|
|
});
|
|
|
|
return {
|
|
action: 'saved',
|
|
fileName,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
function normalizeImportedImageMimeType(
|
|
value: unknown,
|
|
): HostBridgeImageMimeType | null {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const mimeType = value.toLowerCase();
|
|
return HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType)
|
|
? (mimeType as HostBridgeImageMimeType)
|
|
: null;
|
|
}
|
|
|
|
function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) {
|
|
if (mimeType === 'image/jpeg') {
|
|
return 'genarrative-import.jpg';
|
|
}
|
|
if (mimeType === 'image/webp') {
|
|
return 'genarrative-import.webp';
|
|
}
|
|
return 'genarrative-import.png';
|
|
}
|
|
|
|
async function importImageFile(): Promise<FileImportImageResult> {
|
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
|
|
throw {
|
|
code: 'host_error',
|
|
message: 'photo library permission denied',
|
|
} satisfies HostBridgeError;
|
|
}
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
allowsEditing: false,
|
|
allowsMultipleSelection: false,
|
|
base64: true,
|
|
exif: false,
|
|
mediaTypes: ['images'],
|
|
quality: 1,
|
|
});
|
|
if (result.canceled) {
|
|
throw {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
} satisfies HostBridgeError;
|
|
}
|
|
|
|
const asset = result.assets[0];
|
|
if (!asset || asset.type !== 'image') {
|
|
throw invalidRequest('image asset is required');
|
|
}
|
|
|
|
const mimeType = normalizeImportedImageMimeType(asset.mimeType);
|
|
if (!mimeType) {
|
|
throw invalidRequest('mimeType must be an allowed image type');
|
|
}
|
|
|
|
const base64Data = normalizedBase64Data(asset.base64);
|
|
if (!base64Data) {
|
|
throw invalidRequest('base64Data is required');
|
|
}
|
|
|
|
const bytes = base64DecodedByteLength(base64Data);
|
|
if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) {
|
|
throw invalidRequest('image exceeds file import size limit');
|
|
}
|
|
if (
|
|
typeof asset.fileSize === 'number' &&
|
|
asset.fileSize > IMPORT_IMAGE_MAX_BYTES
|
|
) {
|
|
throw invalidRequest('image exceeds file import size limit');
|
|
}
|
|
|
|
return {
|
|
action: 'selected',
|
|
fileName: normalizeHostBridgeExportFileName(
|
|
asset.fileName || fallbackImportedImageFileName(mimeType),
|
|
),
|
|
base64Data,
|
|
mimeType,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
async function runHaptics(payload: unknown) {
|
|
const style = (payload as HapticsImpactPayload | undefined)?.style;
|
|
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 stringField(value: unknown, field: string) {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
|
|
const fieldValue = (value as Record<string, unknown>)[field];
|
|
if (typeof fieldValue !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const text = fieldValue.trim();
|
|
return text || undefined;
|
|
}
|
|
|
|
function shareTargetPayload(value: unknown) {
|
|
if (!value || typeof value !== 'object') {
|
|
return value;
|
|
}
|
|
|
|
const target = value as Record<string, unknown>;
|
|
return target.target ?? value;
|
|
}
|
|
|
|
function workDetailUrl(work: string) {
|
|
return `${WEB_APP_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`;
|
|
}
|
|
|
|
function webAppPathUrl(path: string) {
|
|
return new URL(path, WEB_APP_ORIGIN).toString();
|
|
}
|
|
|
|
function normalizeSharePayload(value: unknown): ShareOpenPayload | null {
|
|
const target = shareTargetPayload(value);
|
|
const payload =
|
|
target && typeof target === 'object'
|
|
? (target as Record<string, unknown>).payload ?? target
|
|
: target;
|
|
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const title = stringField(payload, 'title');
|
|
const message = stringField(payload, 'message');
|
|
const directUrl = stringField(payload, 'url') ?? stringField(payload, 'href');
|
|
const work = stringField(payload, 'work');
|
|
const path = stringField(payload, 'path') ?? stringField(payload, 'targetPath');
|
|
const url = directUrl ?? (work ? workDetailUrl(work) : undefined) ?? (path ? webAppPathUrl(path) : undefined);
|
|
|
|
if (!title && !message && !url) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...(title ? { title } : {}),
|
|
...(message ? { message } : {}),
|
|
...(url ? { url } : {}),
|
|
};
|
|
}
|
|
|
|
async function openShare(payload: unknown) {
|
|
const sharePayload =
|
|
normalizeSharePayload(payload) ?? normalizeSharePayload(currentShareTarget);
|
|
if (!sharePayload) {
|
|
throw invalidRequest('share target is required');
|
|
}
|
|
|
|
const url = sharePayload?.url;
|
|
const message = [sharePayload?.message, url].filter(Boolean).join('\n');
|
|
|
|
await Share.share({
|
|
title: sharePayload?.title,
|
|
message: message || url || sharePayload?.title || '',
|
|
url,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
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(webViewUrl);
|
|
return true;
|
|
}
|
|
|
|
function reloadWebView() {
|
|
if (!navigation) {
|
|
throw unsupported('app.reloadWebView');
|
|
}
|
|
|
|
navigation.reloadWebView();
|
|
return true;
|
|
}
|
|
|
|
async function handleRequest(request: HostBridgeRequest) {
|
|
switch (request.method) {
|
|
case 'host.getRuntime':
|
|
return ok(request, {
|
|
shell: 'expo_mobile',
|
|
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
|
hostVersion: '0.1.0',
|
|
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 '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));
|
|
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));
|
|
}
|
|
}
|
|
|
|
function normalizeError(error: unknown): HostBridgeError {
|
|
if (
|
|
error &&
|
|
typeof error === 'object' &&
|
|
'code' in error &&
|
|
'message' in error
|
|
) {
|
|
return error as HostBridgeError;
|
|
}
|
|
|
|
return {
|
|
code: 'host_error',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
export async function handleMobileHostBridgeMessage(
|
|
rawMessage: string,
|
|
sendResponse: (response: HostBridgeResponse) => void,
|
|
) {
|
|
const parsed = parseRequest(rawMessage);
|
|
if (!isHostBridgeRequest(parsed)) {
|
|
sendResponse(
|
|
failure(
|
|
{ id: 'invalid' },
|
|
invalidRequest('invalid host bridge request'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
sendResponse(await handleRequest(parsed));
|
|
} catch (error) {
|
|
sendResponse(failure(parsed, normalizeError(error)));
|
|
}
|
|
}
|
|
|
|
export function resetMobileHostBridgeForTest() {
|
|
currentShareTarget = null;
|
|
navigation = null;
|
|
}
|