锁定移动壳文件分享失败响应
移动壳文件导出统一收口系统分享能力探测失败 移动壳文件导出统一收口系统分享面板失败 移动壳配置检查补充文件分享失败边界反查
This commit is contained in:
@@ -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('故事'));
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user