锁定移动壳文件导入失败响应

移动壳文本文件选择与读取失败返回稳定错误

移动壳文档文件读取失败返回稳定错误

移动壳音频文件读取失败返回稳定错误

移动壳配置检查补充文件导入失败边界反查
This commit is contained in:
2026-06-20 14:12:12 +08:00
parent 6bbf510102
commit cf3b02829c
3 changed files with 175 additions and 22 deletions
@@ -29,6 +29,8 @@ const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toSt
const fileTexts = vi.hoisted(() => new Map<string, string>());
const fileBase64Data = vi.hoisted(() => new Map<string, string>());
const fileSizes = vi.hoisted(() => new Map<string, number | null>());
const failingTextFiles = vi.hoisted(() => new Set<string>());
const failingBase64Files = vi.hoisted(() => new Set<string>());
const writtenFiles = vi.hoisted(
() =>
[] as {
@@ -63,10 +65,16 @@ vi.mock('expo-file-system', () => ({
}
text() {
if (failingTextFiles.has(this.uri)) {
return Promise.reject(new Error('native text read failed'));
}
return Promise.resolve(fileTexts.get(this.uri) ?? '');
}
base64() {
if (failingBase64Files.has(this.uri)) {
return Promise.reject(new Error('native base64 read failed'));
}
return Promise.resolve(fileBase64Data.get(this.uri) ?? '');
}
},
@@ -112,6 +120,8 @@ describe('mobile HostBridge file actions', () => {
fileTexts.clear();
fileBase64Data.clear();
fileSizes.clear();
failingTextFiles.clear();
failingBase64Files.clear();
writtenFiles.length = 0;
});
@@ -277,6 +287,82 @@ describe('mobile HostBridge file actions', () => {
});
});
test('maps native document picker failures to stable import errors', async () => {
documentPickerMock.mockRejectedValueOnce(new Error('native text picker failed'));
await expect(importTextFile()).rejects.toMatchObject({
code: 'host_error',
message: 'text file picker unavailable',
});
});
test('maps native text file read failures to stable import errors', async () => {
failingTextFiles.add('file:///picked/story.md');
fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事'));
documentPickerMock.mockResolvedValueOnce({
canceled: false,
assets: [
{
uri: 'file:///picked/story.md',
name: 'story.md',
mimeType: 'text/markdown',
size: Buffer.byteLength('故事'),
lastModified: 0,
},
],
});
await expect(importTextFile()).rejects.toMatchObject({
code: 'host_error',
message: 'text file unavailable',
});
});
test('maps native binary file read failures to stable import errors', async () => {
failingBase64Files.add('file:///picked/brief.docx');
fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx'));
documentPickerMock.mockResolvedValueOnce({
canceled: false,
assets: [
{
uri: 'file:///picked/brief.docx',
name: 'brief.docx',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: Buffer.byteLength('PK\x03\x04docx'),
lastModified: 0,
},
],
});
await expect(importDocumentFile()).rejects.toMatchObject({
code: 'host_error',
message: 'document file unavailable',
});
});
test('maps native audio file read failures to stable import errors', async () => {
failingBase64Files.add('file:///picked/voice.mp3');
fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'));
documentPickerMock.mockResolvedValueOnce({
canceled: false,
assets: [
{
uri: 'file:///picked/voice.mp3',
name: 'voice.mp3',
mimeType: 'audio/mpeg',
size: Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'),
lastModified: 0,
},
],
});
await expect(importAudioFile()).rejects.toMatchObject({
code: 'host_error',
message: 'audio file unavailable',
});
});
test('imports and captures images only after native permissions are granted', async () => {
libraryPermissionMock.mockResolvedValueOnce({
status: ImagePicker.PermissionStatus.GRANTED,
+74 -18
View File
@@ -51,6 +51,13 @@ import {
} from './filePayloads';
import { invalidRequest, ok } from './protocol';
type MobileDocumentPickerOptions = Parameters<
typeof DocumentPicker.getDocumentAsync
>[0];
type MobileDocumentPickerResult = Awaited<
ReturnType<typeof DocumentPicker.getDocumentAsync>
>;
async function assertMobileFileSharingAvailable() {
let isSharingAvailable = false;
try {
@@ -145,11 +152,14 @@ export async function exportMobileHostBridgeTextFile(
}
export async function importTextFile(): Promise<FileImportTextResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: ['text/*', 'application/json'],
});
const result = await pickMobileDocumentFile(
{
copyToCacheDirectory: true,
multiple: false,
type: ['text/*', 'application/json'],
},
'text file picker unavailable',
);
if (result.canceled) {
throw {
code: 'cancelled',
@@ -177,7 +187,7 @@ export async function importTextFile(): Promise<FileImportTextResult> {
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
'text exceeds file import size limit',
);
const content = await file.text();
const content = await readMobileTextFile(file, 'text file unavailable');
const bytes = utf8ByteLength(content);
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) {
throw invalidRequest('text exceeds file import size limit');
@@ -199,11 +209,14 @@ export async function importMobileHostBridgeTextFile(
}
export async function importDocumentFile(): Promise<FileImportDocumentResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_DOCUMENT_PICKER_TYPES,
});
const result = await pickMobileDocumentFile(
{
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_DOCUMENT_PICKER_TYPES,
},
'document picker unavailable',
);
if (result.canceled) {
throw {
code: 'cancelled',
@@ -231,7 +244,9 @@ export async function importDocumentFile(): Promise<FileImportDocumentResult> {
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
'document exceeds file import size limit',
);
const base64Data = normalizedBase64Data(await file.base64());
const base64Data = normalizedBase64Data(
await readMobileBase64File(file, 'document file unavailable'),
);
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
@@ -467,11 +482,14 @@ export async function exportMobileHostBridgeAudioFile(
}
export async function importAudioFile(): Promise<FileImportAudioResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES,
});
const result = await pickMobileDocumentFile(
{
copyToCacheDirectory: true,
multiple: false,
type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES,
},
'audio picker unavailable',
);
if (result.canceled) {
throw {
code: 'cancelled',
@@ -499,7 +517,9 @@ export async function importAudioFile(): Promise<FileImportAudioResult> {
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
'audio exceeds file import size limit',
);
const base64Data = normalizedBase64Data(await file.base64());
const base64Data = normalizedBase64Data(
await readMobileBase64File(file, 'audio file unavailable'),
);
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
@@ -534,3 +554,39 @@ function isHostBridgeFileSharingError(error: unknown): error is HostBridgeError
error.message === 'file sharing failed'
);
}
async function pickMobileDocumentFile(
options: MobileDocumentPickerOptions,
unavailableMessage: string,
): Promise<MobileDocumentPickerResult> {
try {
return await DocumentPicker.getDocumentAsync(options);
} catch {
throw {
code: 'host_error',
message: unavailableMessage,
} satisfies HostBridgeError;
}
}
async function readMobileTextFile(file: File, unavailableMessage: string) {
try {
return await file.text();
} catch {
throw {
code: 'host_error',
message: unavailableMessage,
} satisfies HostBridgeError;
}
}
async function readMobileBase64File(file: File, unavailableMessage: string) {
try {
return await file.base64();
} catch {
throw {
code: 'host_error',
message: unavailableMessage,
} satisfies HostBridgeError;
}
}