接入原生壳文本文件导入能力
新增 file.importText HostBridge 契约和 H5 facade 移动端通过 Expo DocumentPicker 读取受控文本文件 桌面端通过 Tauri 文件选择框读取受控文本文件 更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type FileExportTextPayload,
|
||||
type FileExportTextResult,
|
||||
type FileImportImageResult,
|
||||
type FileImportTextResult,
|
||||
type HapticsImpactPayload,
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_VERSION,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
type HostBridgeMethod,
|
||||
type HostBridgeRequest,
|
||||
type HostBridgeResponse,
|
||||
type HostBridgeTextMimeType,
|
||||
type NavigateNativePagePayload,
|
||||
normalizeHostBridgeBadgeCount,
|
||||
normalizeHostBridgeClipboardText,
|
||||
@@ -46,8 +49,15 @@ 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 IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
|
||||
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',
|
||||
@@ -78,6 +88,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'clipboard.writeText',
|
||||
'clipboard.readText',
|
||||
'file.exportText',
|
||||
'file.importText',
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'haptics.impact',
|
||||
@@ -283,6 +294,82 @@ async function exportTextFile(payload: unknown): Promise<FileExportTextResult> {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportImageFile(payload: unknown): Promise<FileExportImageResult> {
|
||||
const exportPayload = payload as FileExportImagePayload | undefined;
|
||||
const mimeType = exportPayload?.mimeType;
|
||||
@@ -627,6 +714,8 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
return ok(request, await readClipboard());
|
||||
case 'file.exportText':
|
||||
return ok(request, await exportTextFile(request.payload));
|
||||
case 'file.importText':
|
||||
return ok(request, await importTextFile());
|
||||
case 'file.exportImage':
|
||||
return ok(request, await exportImageFile(request.payload));
|
||||
case 'file.importImage':
|
||||
|
||||
Reference in New Issue
Block a user