接入原生壳分享卡图片导出

新增 file.exportImage 宿主能力契约

分享卡下载在原生壳中优先走宿主图片导出

Expo 壳写入缓存图片并调用系统分享保存

Tauri 壳通过保存对话框写入图片字节

补齐能力漂移检查、测试和架构文档
This commit is contained in:
2026-06-18 01:31:28 +08:00
parent 6843185a6c
commit 910625d5e1
17 changed files with 673 additions and 34 deletions

View File

@@ -7,6 +7,8 @@ import { Platform, Share } from 'react-native';
import {
type ClipboardWriteTextPayload,
type FileExportImagePayload,
type FileExportImageResult,
type FileExportTextPayload,
type FileExportTextResult,
type HapticsImpactPayload,
@@ -27,6 +29,12 @@ import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
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 EXPORT_IMAGE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/webp',
]);
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
@@ -38,6 +46,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'app.openExternalUrl',
'clipboard.writeText',
'file.exportText',
'file.exportImage',
'haptics.impact',
];
@@ -86,6 +95,28 @@ function utf8ByteLength(value: string) {
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;
@@ -193,6 +224,46 @@ async function exportTextFile(payload: unknown): Promise<FileExportTextResult> {
};
}
async function exportImageFile(payload: unknown): Promise<FileExportImageResult> {
const exportPayload = payload as FileExportImagePayload | undefined;
const mimeType = exportPayload?.mimeType;
if (typeof mimeType !== 'string' || !EXPORT_IMAGE_MIME_TYPES.has(mimeType)) {
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,
};
}
async function runHaptics(payload: unknown) {
const style = (payload as HapticsImpactPayload | undefined)?.style;
const impactStyle =
@@ -322,6 +393,8 @@ async function handleRequest(request: HostBridgeRequest) {
return ok(request, await writeClipboard(request.payload));
case 'file.exportText':
return ok(request, await exportTextFile(request.payload));
case 'file.exportImage':
return ok(request, await exportImageFile(request.payload));
case 'haptics.impact':
return ok(request, await runHaptics(request.payload));
case 'share.open':