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

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

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

移动壳配置检查补充文件分享失败边界反查
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
@@ -1991,6 +1991,8 @@ for (const snippet of [
'Notifications.getPermissionsAsync',
'Notifications.requestPermissionsAsync',
'Sharing.shareAsync',
'assertMobileFileSharingAvailable',
'shareMobileFile',
'DocumentPicker.getDocumentAsync',
'Clipboard.getStringAsync',
'ImagePicker.launchImageLibraryAsync',
@@ -2108,6 +2110,10 @@ for (const snippet of [
'exportAudioFile({',
"code: 'unsupported_capability'",
"code: 'cancelled'",
'maps native sharing availability failures to stable export errors',
'maps native sharing sheet failures to stable export errors',
"message: 'file sharing unavailable'",
"message: 'file sharing failed'",
'rejects image import before picker launch when library permission is denied',
'rejects image import before picker launch when library permission request fails',
'rejects image capture before camera launch when camera permission request fails',
@@ -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'
);
}