接入移动壳图片导入能力

Expo 壳通过系统相册选择器实现 file.importImage

限制导入图片 MIME 与大小并避免暴露设备本地 URI

H5 facade 将用户取消导入归为无选择回退

更新移动壳依赖、配置校验、测试和架构文档
This commit is contained in:
2026-06-18 03:04:21 +08:00
parent 199f02cf9f
commit 14f838c414
12 changed files with 348 additions and 7 deletions

View File

@@ -1,6 +1,7 @@
import * as Clipboard from 'expo-clipboard';
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 Sharing from 'expo-sharing';
import {
@@ -16,11 +17,13 @@ import {
type FileExportImageResult,
type FileExportTextPayload,
type FileExportTextResult,
type FileImportImageResult,
type HapticsImpactPayload,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeCapability,
type HostBridgeError,
type HostBridgeImageMimeType,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
@@ -39,7 +42,8 @@ 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 EXPORT_IMAGE_MIME_TYPES = new Set([
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
'image/png',
'image/jpeg',
'image/webp',
@@ -60,6 +64,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'clipboard.writeText',
'file.exportText',
'file.exportImage',
'file.importImage',
'haptics.impact',
];
@@ -251,7 +256,10 @@ 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)) {
if (
typeof mimeType !== 'string' ||
!HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType)
) {
throw invalidRequest('mimeType must be an allowed image type');
}
@@ -288,6 +296,90 @@ async function exportImageFile(payload: unknown): Promise<FileExportImageResult>
};
}
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 =
@@ -448,6 +540,8 @@ async function handleRequest(request: HostBridgeRequest) {
return ok(request, await exportTextFile(request.payload));
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 'app.setBadgeCount':