接入原生壳文本文件导入能力

新增 file.importText HostBridge 契约和 H5 facade

移动端通过 Expo DocumentPicker 读取受控文本文件

桌面端通过 Tauri 文件选择框读取受控文本文件

更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
2026-06-18 04:51:56 +08:00
parent c7a24fba37
commit 1c6749b53e
16 changed files with 527 additions and 4 deletions

View File

@@ -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':