锁定移动壳文件分享失败响应

移动壳文件导出统一收口系统分享能力探测失败

移动壳文件导出统一收口系统分享面板失败

移动壳配置检查补充文件分享失败边界反查
This commit is contained in:
2026-06-20 14:00:40 +08:00
parent 1e19ebb4d9
commit 933e690824
3 changed files with 152 additions and 45 deletions
@@ -153,9 +153,9 @@ describe('mobile HostBridge file actions', () => {
}),
() =>
exportImageFile({
base64Data: PNG_BASE64,
fileName: '分享卡',
mimeType: 'image/png',
base64Data: PNG_BASE64,
fileName: '分享卡',
mimeType: 'image/png',
}),
() =>
exportAudioFile({
@@ -175,6 +175,47 @@ describe('mobile HostBridge file actions', () => {
expect(shareAsyncMock).not.toHaveBeenCalled();
});
test('maps native sharing availability failures to stable export errors', async () => {
shareAvailableMock.mockRejectedValueOnce(new Error('expo sharing crashed'));
await expect(
exportTextFile({
content: '泥巴AI',
fileName: '创作记录',
mimeType: 'text/plain',
}),
).rejects.toMatchObject({
code: 'host_error',
message: 'file sharing unavailable',
});
expect(writtenFiles).toHaveLength(0);
expect(shareAsyncMock).not.toHaveBeenCalled();
});
test('maps native sharing sheet failures to stable export errors', async () => {
shareAsyncMock.mockRejectedValueOnce(new Error('expo native share failed'));
await expect(
exportImageFile({
base64Data: PNG_BASE64,
fileName: '分享卡',
mimeType: 'image/png',
}),
).rejects.toMatchObject({
code: 'host_error',
message: 'file sharing failed',
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/分享卡.png',
content: PNG_BASE64,
options: { encoding: 'base64' },
},
]);
});
test('imports text and document files without exposing local URIs', async () => {
fileTexts.set('file:///picked/story.md', '故事');
fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事'));
+102 -42
View File
@@ -51,6 +51,43 @@ import {
} from './filePayloads';
import { invalidRequest, ok } from './protocol';
async function assertMobileFileSharingAvailable() {
let isSharingAvailable = false;
try {
isSharingAvailable = await Sharing.isAvailableAsync();
} catch {
throw {
code: 'host_error',
message: 'file sharing unavailable',
} satisfies HostBridgeError;
}
if (!isSharingAvailable) {
throw {
code: 'unsupported_capability',
message: 'file sharing is unavailable in mobile shell',
} satisfies HostBridgeError;
}
}
async function shareMobileFile(
file: File,
options: {
mimeType: string;
UTI: string;
dialogTitle: string;
},
) {
try {
await Sharing.shareAsync(file.uri, options);
} catch {
throw {
code: 'host_error',
message: 'file sharing failed',
} satisfies HostBridgeError;
}
}
export async function exportTextFile(
payload: unknown,
): Promise<FileExportTextResult> {
@@ -65,13 +102,7 @@ export async function exportTextFile(
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;
}
await assertMobileFileSharingAvailable();
const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName);
const rawMimeType = exportPayload?.mimeType;
@@ -82,13 +113,23 @@ export async function exportTextFile(
if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) {
throw invalidRequest('mimeType must be an allowed text type');
}
const file = new File(Paths.cache, fileName);
file.write(content);
await Sharing.shareAsync(file.uri, {
mimeType,
UTI: 'public.plain-text',
dialogTitle: fileName,
});
try {
const file = new File(Paths.cache, fileName);
file.write(content);
await shareMobileFile(file, {
mimeType,
UTI: 'public.plain-text',
dialogTitle: fileName,
});
} catch (error) {
if (isHostBridgeFileSharingError(error)) {
throw error;
}
throw {
code: 'host_error',
message: 'file sharing failed',
} satisfies HostBridgeError;
}
return {
action: 'saved',
@@ -236,25 +277,29 @@ export async function exportImageFile(
}
ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType);
const isSharingAvailable = await Sharing.isAvailableAsync();
if (!isSharingAvailable) {
throw {
code: 'unsupported_capability',
message: 'file sharing is unavailable in mobile shell',
} satisfies HostBridgeError;
}
await assertMobileFileSharingAvailable();
const fileName = normalizeExportedImageFileName(
exportPayload?.fileName,
mimeType as HostBridgeImageMimeType,
);
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,
});
try {
const file = new File(Paths.cache, fileName);
file.write(base64Data, { encoding: 'base64' });
await shareMobileFile(file, {
mimeType,
UTI: mimeType === 'image/png' ? 'public.png' : 'public.image',
dialogTitle: fileName,
});
} catch (error) {
if (isHostBridgeFileSharingError(error)) {
throw error;
}
throw {
code: 'host_error',
message: 'file sharing failed',
} satisfies HostBridgeError;
}
return {
action: 'saved',
@@ -368,25 +413,29 @@ export async function exportAudioFile(
}
ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType);
const isSharingAvailable = await Sharing.isAvailableAsync();
if (!isSharingAvailable) {
throw {
code: 'unsupported_capability',
message: 'file sharing is unavailable in mobile shell',
} satisfies HostBridgeError;
}
await assertMobileFileSharingAvailable();
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,
});
try {
const file = new File(Paths.cache, fileName);
file.write(base64Data, { encoding: 'base64' });
await shareMobileFile(file, {
mimeType,
UTI: 'public.audio',
dialogTitle: fileName,
});
} catch (error) {
if (isHostBridgeFileSharingError(error)) {
throw error;
}
throw {
code: 'host_error',
message: 'file sharing failed',
} satisfies HostBridgeError;
}
return {
action: 'saved',
@@ -458,3 +507,14 @@ export async function importMobileHostBridgeAudioFile(
) {
return ok(request, await importAudioFile());
}
function isHostBridgeFileSharingError(error: unknown): error is HostBridgeError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'host_error' &&
'message' in error &&
error.message === 'file sharing failed'
);
}