Files
Genarrative/apps/mobile-shell/src/host-bridge/mobileHostBridgeFiles.ts
T
kdletters fd6c4d4830 对齐三端桥接层结构
拆分移动壳 HostBridge 协议、文件和分享模块

更新原生壳结构门禁和移动壳配置检查

同步宿主壳架构文档和共享决策记录
2026-06-18 17:13:15 +08:00

519 lines
14 KiB
TypeScript

import * as DocumentPicker from 'expo-document-picker';
import { File, Paths } from 'expo-file-system';
import * as ImagePicker from 'expo-image-picker';
import * as Sharing from 'expo-sharing';
import {
type FileExportAudioPayload,
type FileExportAudioResult,
type FileExportImagePayload,
type FileExportImageResult,
type FileExportTextPayload,
type FileExportTextResult,
type FileImportAudioResult,
type FileImportImageResult,
type FileImportTextResult,
type HostBridgeAudioMimeType,
type HostBridgeError,
type HostBridgeImageMimeType,
type HostBridgeTextMimeType,
normalizeHostBridgeExportFileName,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { invalidRequest } from './mobileHostBridgeProtocol';
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
const EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
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',
]);
const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
]);
export 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;
}
export 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;
}
export 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,
};
}
export 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';
}
function imagePickerResultToImportPayload(
result: ImagePicker.ImagePickerResult,
action: FileImportImageResult['action'],
): FileImportImageResult {
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,
fileName: normalizeHostBridgeExportFileName(
asset.fileName || fallbackImportedImageFileName(mimeType),
),
base64Data,
mimeType,
bytes,
};
}
export 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,
});
return imagePickerResultToImportPayload(result, 'selected');
}
export async function captureImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'camera permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
return imagePickerResultToImportPayload(result, 'captured');
}
function audioFileExtension(mimeType: HostBridgeAudioMimeType) {
if (mimeType === 'audio/mpeg') {
return 'mp3';
}
if (mimeType === 'audio/mp4') {
return 'm4a';
}
if (mimeType === 'audio/wav') {
return 'wav';
}
if (mimeType === 'audio/ogg') {
return 'ogg';
}
return 'webm';
}
function normalizeExportedAudioFileName(
rawFileName: unknown,
mimeType: HostBridgeAudioMimeType,
) {
const fileName = normalizeHostBridgeExportFileName(rawFileName);
const extension = audioFileExtension(mimeType);
return fileName.toLowerCase().endsWith(`.${extension}`)
? fileName
: `${fileName}.${extension}`;
}
function normalizeImportedAudioMimeType(
value: unknown,
fileName: string,
): HostBridgeAudioMimeType | null {
if (typeof value === 'string') {
const mimeType = value.toLowerCase();
if (HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)) {
return mimeType as HostBridgeAudioMimeType;
}
}
const normalizedName = fileName.toLowerCase();
if (normalizedName.endsWith('.mp3')) {
return 'audio/mpeg';
}
if (normalizedName.endsWith('.m4a') || normalizedName.endsWith('.mp4')) {
return 'audio/mp4';
}
if (normalizedName.endsWith('.wav')) {
return 'audio/wav';
}
if (normalizedName.endsWith('.ogg')) {
return 'audio/ogg';
}
if (normalizedName.endsWith('.webm')) {
return 'audio/webm';
}
return null;
}
export async function exportAudioFile(
payload: unknown,
): Promise<FileExportAudioResult> {
const exportPayload = payload as FileExportAudioPayload | undefined;
const mimeType = exportPayload?.mimeType;
if (
typeof mimeType !== 'string' ||
!HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)
) {
throw invalidRequest('mimeType must be an allowed audio type');
}
const base64Data = normalizedBase64Data(exportPayload?.base64Data);
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
const bytes = base64DecodedByteLength(base64Data);
if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) {
throw invalidRequest('audio 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 = normalizeExportedAudioFileName(
exportPayload?.fileName,
mimeType as HostBridgeAudioMimeType,
);
const file = new File(Paths.cache, fileName);
file.write(base64Data, { encoding: 'base64' });
await Sharing.shareAsync(file.uri, {
mimeType,
UTI: 'public.audio',
dialogTitle: fileName,
});
return {
action: 'saved',
fileName,
bytes,
};
}
export async function importAudioFile(): Promise<FileImportAudioResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: [
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
],
});
if (result.canceled) {
throw {
code: 'cancelled',
message: 'file import cancelled',
} satisfies HostBridgeError;
}
const asset = result.assets[0];
if (!asset?.uri) {
throw invalidRequest('audio file is required');
}
const fileName = normalizeHostBridgeExportFileName(
asset.name || 'genarrative-import-audio.webm',
);
const mimeType = normalizeImportedAudioMimeType(asset.mimeType, fileName);
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed audio type');
}
if (
typeof asset.size === 'number' &&
(asset.size <= 0 || asset.size > IMPORT_AUDIO_MAX_BYTES)
) {
throw invalidRequest('audio exceeds file import size limit');
}
const file = new File(asset.uri);
const base64Data = await file.base64();
const bytes = base64DecodedByteLength(base64Data);
if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) {
throw invalidRequest('audio exceeds file import size limit');
}
return {
action: 'selected',
fileName,
base64Data,
mimeType,
bytes,
};
}