fd6c4d4830
拆分移动壳 HostBridge 协议、文件和分享模块 更新原生壳结构门禁和移动壳配置检查 同步宿主壳架构文档和共享决策记录
519 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|