cde936bd6d
移动壳文件执行与载荷校验拆成 files 和 filePayloads 移动壳单端门禁登记文件载荷模块 宿主壳方案和统一协议同步移动文件桥接分层
445 lines
12 KiB
TypeScript
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());
|
|
}
|