锁定移动壳文件导入失败响应
移动壳文本文件选择与读取失败返回稳定错误 移动壳文档文件读取失败返回稳定错误 移动壳音频文件读取失败返回稳定错误 移动壳配置检查补充文件导入失败边界反查
This commit is contained in:
@@ -420,7 +420,7 @@ function assertMobileDocumentPickerBoundary(
|
||||
expectedTypeExpression,
|
||||
) {
|
||||
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
|
||||
if (!functionBody.includes('DocumentPicker.getDocumentAsync({')) {
|
||||
if (!functionBody.includes('pickMobileDocumentFile(')) {
|
||||
throw new Error(`mobile shell ${functionName} must use DocumentPicker`);
|
||||
}
|
||||
for (const requiredPickerOption of [
|
||||
@@ -948,9 +948,9 @@ if (
|
||||
}
|
||||
|
||||
for (const [functionName, readCall] of [
|
||||
['importTextFile', 'file.text()'],
|
||||
['importDocumentFile', 'file.base64()'],
|
||||
['importAudioFile', 'file.base64()'],
|
||||
['importTextFile', 'readMobileTextFile(file,'],
|
||||
['importDocumentFile', 'readMobileBase64File(file,'],
|
||||
['importAudioFile', 'readMobileBase64File(file,'],
|
||||
]) {
|
||||
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
|
||||
const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit(');
|
||||
@@ -1993,6 +1993,9 @@ for (const snippet of [
|
||||
'Sharing.shareAsync',
|
||||
'assertMobileFileSharingAvailable',
|
||||
'shareMobileFile',
|
||||
'pickMobileDocumentFile',
|
||||
'readMobileTextFile',
|
||||
'readMobileBase64File',
|
||||
'DocumentPicker.getDocumentAsync',
|
||||
'Clipboard.getStringAsync',
|
||||
'ImagePicker.launchImageLibraryAsync',
|
||||
@@ -2112,8 +2115,16 @@ for (const snippet of [
|
||||
"code: 'cancelled'",
|
||||
'maps native sharing availability failures to stable export errors',
|
||||
'maps native sharing sheet failures to stable export errors',
|
||||
'maps native document picker failures to stable import errors',
|
||||
'maps native text file read failures to stable import errors',
|
||||
'maps native binary file read failures to stable import errors',
|
||||
'maps native audio file read failures to stable import errors',
|
||||
"message: 'file sharing unavailable'",
|
||||
"message: 'file sharing failed'",
|
||||
"message: 'text file picker unavailable'",
|
||||
"message: 'text file unavailable'",
|
||||
"message: 'document file unavailable'",
|
||||
"message: 'audio file unavailable'",
|
||||
'rejects image import before picker launch when library permission is denied',
|
||||
'rejects image import before picker launch when library permission request fails',
|
||||
'maps native image library launch failures to stable import errors',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user