Files
Genarrative/apps/mobile-shell/src/host-bridge/files.ts
T
kdletters cde936bd6d 拆分移动壳文件载荷边界
移动壳文件执行与载荷校验拆成 files 和 filePayloads

移动壳单端门禁登记文件载荷模块

宿主壳方案和统一协议同步移动文件桥接分层
2026-06-20 07:11:57 +08:00

445 lines
12 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 FileImportDocumentResult,
type FileImportImageResult,
type FileImportTextResult,
type HostBridgeAudioMimeType,
type HostBridgeError,
type HostBridgeImageMimeType,
type HostBridgeRequest,
type HostBridgeTextMimeType,
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES,
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES,
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES,
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES,
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
normalizeHostBridgeExportFileName,
normalizeHostBridgeImportFileName,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
assertImportedFileSizeWithinLimit,
base64DecodedByteLength,
ensureAudioBytesMatchMimeType,
ensureImageBytesMatchMimeType,
HOST_BRIDGE_AUDIO_MIME_TYPE_SET,
HOST_BRIDGE_IMAGE_MIME_TYPE_SET,
HOST_BRIDGE_TEXT_MIME_TYPE_SET,
imagePickerResultToImportPayload,
MOBILE_AUDIO_DOCUMENT_PICKER_TYPES,
MOBILE_DOCUMENT_PICKER_TYPES,
normalizedBase64Data,
normalizeExportedAudioFileName,
normalizeExportedImageFileName,
normalizeImportedAudioFileName,
normalizeImportedAudioMimeType,
normalizeImportedDocumentMimeType,
normalizeImportedTextMimeType,
utf8ByteLength,
} from './filePayloads';
import { invalidRequest, ok } from './protocol';
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 > HOST_BRIDGE_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 rawMimeType = exportPayload?.mimeType;
const mimeType =
typeof rawMimeType === 'string'
? rawMimeType.toLowerCase()
: 'text/plain';
if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) {
throw invalidRequest('mimeType must be an allowed text type');
}
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,
};
}
export async function exportMobileHostBridgeTextFile(
request: HostBridgeRequest,
) {
return ok(request, await exportTextFile(request.payload));
}
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 = normalizeHostBridgeImportFileName(
asset.name || 'genarrative-import.txt',
);
const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName);
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed text type');
}
const file = new File(asset.uri);
assertImportedFileSizeWithinLimit(
asset.size,
file,
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
'text exceeds file import size limit',
);
const content = await file.text();
const bytes = utf8ByteLength(content);
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) {
throw invalidRequest('text exceeds file import size limit');
}
return {
action: 'selected',
fileName,
content,
mimeType,
bytes,
};
}
export async function importMobileHostBridgeTextFile(
request: HostBridgeRequest,
) {
return ok(request, await importTextFile());
}
export async function importDocumentFile(): Promise<FileImportDocumentResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_DOCUMENT_PICKER_TYPES,
});
if (result.canceled) {
throw {
code: 'cancelled',
message: 'file import cancelled',
} satisfies HostBridgeError;
}
const asset = result.assets[0];
if (!asset?.uri) {
throw invalidRequest('document file is required');
}
const fileName = normalizeHostBridgeImportFileName(
asset.name || 'genarrative-import-document.txt',
);
const mimeType = normalizeImportedDocumentMimeType(asset.mimeType, fileName);
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed document type');
}
const file = new File(asset.uri);
assertImportedFileSizeWithinLimit(
asset.size,
file,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
'document exceeds file import size limit',
);
const base64Data = normalizedBase64Data(await file.base64());
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
const bytes = base64DecodedByteLength(base64Data);
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES) {
throw invalidRequest('document exceeds file import size limit');
}
return {
action: 'selected',
fileName,
base64Data,
mimeType,
bytes,
};
}
export async function importMobileHostBridgeDocumentFile(
request: HostBridgeRequest,
) {
return ok(request, await importDocumentFile());
}
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_TYPE_SET.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 <= 0 || bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES) {
throw invalidRequest('image exceeds file export size limit');
}
ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType);
const isSharingAvailable = await Sharing.isAvailableAsync();
if (!isSharingAvailable) {
throw {
code: 'unsupported_capability',
message: 'file sharing is unavailable in mobile shell',
} satisfies HostBridgeError;
}
const fileName = normalizeExportedImageFileName(
exportPayload?.fileName,
mimeType as HostBridgeImageMimeType,
);
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,
};
}
export async function exportMobileHostBridgeImageFile(
request: HostBridgeRequest,
) {
return ok(request, await exportImageFile(request.payload));
}
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,
});
const payload = imagePickerResultToImportPayload(result, 'selected');
if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) {
throw invalidRequest('image exceeds file import size limit');
}
return payload;
}
export async function importMobileHostBridgeImageFile(
request: HostBridgeRequest,
) {
return ok(request, await importImageFile());
}
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,
});
const payload = imagePickerResultToImportPayload(result, 'captured');
if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) {
throw invalidRequest('image exceeds file import size limit');
}
return payload;
}
export async function captureMobileHostBridgeImageFile(
request: HostBridgeRequest,
) {
return ok(request, await captureImageFile());
}
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_TYPE_SET.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 > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES) {
throw invalidRequest('audio exceeds file export size limit');
}
ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType);
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 exportMobileHostBridgeAudioFile(
request: HostBridgeRequest,
) {
return ok(request, await exportAudioFile(request.payload));
}
export async function importAudioFile(): Promise<FileImportAudioResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES,
});
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 = normalizeHostBridgeImportFileName(
asset.name || 'genarrative-import-audio.webm',
);
const mimeType = normalizeImportedAudioMimeType(asset.mimeType, fileName);
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed audio type');
}
const file = new File(asset.uri);
assertImportedFileSizeWithinLimit(
asset.size,
file,
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
'audio exceeds file import size limit',
);
const base64Data = normalizedBase64Data(await file.base64());
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
const bytes = base64DecodedByteLength(base64Data);
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES) {
throw invalidRequest('audio exceeds file import size limit');
}
ensureAudioBytesMatchMimeType(base64Data, mimeType);
return {
action: 'selected',
fileName,
base64Data,
mimeType,
bytes,
};
}
export async function importMobileHostBridgeAudioFile(
request: HostBridgeRequest,
) {
return ok(request, await importAudioFile());
}