Expo 移动壳通过文件系统写入缓存文本并调用系统分享保存面板 补充移动壳导出能力依赖、配置守卫和 HostBridge 单测 更新宿主壳能力协议、方案文档和共享决策记录
387 lines
10 KiB
TypeScript
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;
|
|
}
|