Files
Genarrative/apps/mobile-shell/src/mobileHostBridge.ts
kdletters eb9981e67d 接入移动壳文本文件导出能力
Expo 移动壳通过文件系统写入缓存文本并调用系统分享保存面板

补充移动壳导出能力依赖、配置守卫和 HostBridge 单测

更新宿主壳能力协议、方案文档和共享决策记录
2026-06-17 23:32:00 +08:00

387 lines
10 KiB
TypeScript

import * as Clipboard from 'expo-clipboard';
import { File, Paths } from 'expo-file-system';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Sharing from 'expo-sharing';
import { Platform, Share } from 'react-native';
import {
type ClipboardWriteTextPayload,
type FileExportTextPayload,
type FileExportTextResult,
type HapticsImpactPayload,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeCapability,
type HostBridgeError,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
type NavigateNativePagePayload,
normalizeHostBridgeExportFileName,
normalizeHostBridgeExternalUrl,
type OpenExternalUrlPayload,
type ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
const WEB_APP_ORIGIN = 'https://app.genarrative.world';
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
'host.events',
'share.open',
'share.setTarget',
'navigation.openNativePage',
'navigation.canGoBack',
'app.openExternalUrl',
'clipboard.writeText',
'file.exportText',
'haptics.impact',
];
export type MobileHostBridgeNavigation = {
allowedOrigin: string;
openWebViewUrl: (url: string) => 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 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 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,
};
}
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 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;
}
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: MOBILE_HOST_CAPABILITIES,
});
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
case 'file.exportText':
return ok(request, await exportTextFile(request.payload));
case 'haptics.impact':
return ok(request, await runHaptics(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;
}