收紧导入结果消费边界
将文本、文档、图片和音频导入结果归一化提升到共享 HostBridge 契约 让 H5 facade 消费宿主导入结果前复核文件名、MIME、字节数和 base64 载荷 移除 H5 facade 本地导入 MIME Set 并用总门禁反查共享 normalizer 增加导入结果边界测试和超限结果回退覆盖 同步宿主壳协议文档和共享决策记录
This commit is contained in:
@@ -2052,6 +2052,39 @@ describe('hostBridge', () => {
|
||||
await expect(importHostDocumentFile()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('原生 App 宿主导入结果超出共享边界时回退为 false', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: { id: string } }).request;
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
action: 'selected',
|
||||
fileName: 'too-large.webm',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 20 * 1024 * 1024 + 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['file.importAudio']),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(importHostAudioFile()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('原生 App 宿主取消导入图片时回退为 false', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -48,6 +48,10 @@ import {
|
||||
normalizeHostBridgeExportTextPayload,
|
||||
normalizeHostBridgeExternalUrlPayload,
|
||||
normalizeHostBridgeHapticsImpactStyle,
|
||||
normalizeHostBridgeImportAudioResult,
|
||||
normalizeHostBridgeImportDocumentResult,
|
||||
normalizeHostBridgeImportImageResult,
|
||||
normalizeHostBridgeImportTextResult,
|
||||
normalizeHostBridgeLifecycleState,
|
||||
normalizeHostBridgeLocalNotification,
|
||||
normalizeHostBridgeQrCodeValue,
|
||||
@@ -907,64 +911,19 @@ export async function exportHostAudioFile(
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set(HOST_BRIDGE_IMAGE_MIME_TYPES);
|
||||
|
||||
function normalizeHostImageImportResult(
|
||||
payload: FileImportImageResult | null | undefined,
|
||||
): FileImportImageResult | false {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
payload.action !== 'selected' &&
|
||||
payload.action !== 'dropped' &&
|
||||
payload.action !== 'captured' ||
|
||||
!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(payload.mimeType) ||
|
||||
typeof payload.fileName !== 'string' ||
|
||||
!payload.fileName.trim() ||
|
||||
typeof payload.base64Data !== 'string' ||
|
||||
!payload.base64Data.trim() ||
|
||||
typeof payload.bytes !== 'number' ||
|
||||
!Number.isInteger(payload.bytes) ||
|
||||
payload.bytes <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const position =
|
||||
payload.position &&
|
||||
typeof payload.position.x === 'number' &&
|
||||
Number.isFinite(payload.position.x) &&
|
||||
typeof payload.position.y === 'number' &&
|
||||
Number.isFinite(payload.position.y)
|
||||
? {
|
||||
x: payload.position.x,
|
||||
y: payload.position.y,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
action: payload.action,
|
||||
fileName: payload.fileName.trim(),
|
||||
base64Data: payload.base64Data,
|
||||
mimeType: payload.mimeType,
|
||||
bytes: payload.bytes,
|
||||
...(position ? { position } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function importHostImageFile() {
|
||||
if (!canUseNativeHostCapability('file.importImage')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeHostImageImportResult(
|
||||
return normalizeHostBridgeImportImageResult(
|
||||
await requestNativeAppHostBridge<FileImportImageResult>(
|
||||
'file.importImage',
|
||||
undefined,
|
||||
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
||||
),
|
||||
);
|
||||
) ?? false;
|
||||
} catch (error) {
|
||||
if (isUnsupportedHostBridgeError(error)) {
|
||||
return false;
|
||||
@@ -979,13 +938,13 @@ export async function captureHostImageFile() {
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeHostImageImportResult(
|
||||
return normalizeHostBridgeImportImageResult(
|
||||
await requestNativeAppHostBridge<FileImportImageResult>(
|
||||
'file.captureImage',
|
||||
undefined,
|
||||
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
||||
),
|
||||
);
|
||||
) ?? false;
|
||||
} catch (error) {
|
||||
if (isUnsupportedHostBridgeError(error)) {
|
||||
return false;
|
||||
@@ -1018,49 +977,19 @@ export async function scanHostQrCode() {
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set(HOST_BRIDGE_TEXT_MIME_TYPES);
|
||||
const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES);
|
||||
|
||||
function normalizeHostTextImportResult(
|
||||
payload: FileImportTextResult | null | undefined,
|
||||
): FileImportTextResult | false {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
payload.action !== 'selected' ||
|
||||
!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(payload.mimeType) ||
|
||||
typeof payload.fileName !== 'string' ||
|
||||
!payload.fileName.trim() ||
|
||||
typeof payload.content !== 'string' ||
|
||||
typeof payload.bytes !== 'number' ||
|
||||
!Number.isInteger(payload.bytes) ||
|
||||
payload.bytes <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'selected',
|
||||
fileName: payload.fileName.trim(),
|
||||
content: payload.content,
|
||||
mimeType: payload.mimeType,
|
||||
bytes: payload.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importHostTextFile() {
|
||||
if (!canUseNativeHostCapability('file.importText')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeHostTextImportResult(
|
||||
return normalizeHostBridgeImportTextResult(
|
||||
await requestNativeAppHostBridge<FileImportTextResult>(
|
||||
'file.importText',
|
||||
undefined,
|
||||
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
||||
),
|
||||
);
|
||||
) ?? false;
|
||||
} catch (error) {
|
||||
if (isUnsupportedHostBridgeError(error)) {
|
||||
return false;
|
||||
@@ -1069,47 +998,19 @@ export async function importHostTextFile() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostDocumentImportResult(
|
||||
payload: FileImportDocumentResult | null | undefined,
|
||||
): FileImportDocumentResult | false {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
payload.action !== 'selected' ||
|
||||
!HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has(payload.mimeType) ||
|
||||
typeof payload.fileName !== 'string' ||
|
||||
!payload.fileName.trim() ||
|
||||
typeof payload.base64Data !== 'string' ||
|
||||
!payload.base64Data.trim() ||
|
||||
typeof payload.bytes !== 'number' ||
|
||||
!Number.isInteger(payload.bytes) ||
|
||||
payload.bytes <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'selected',
|
||||
fileName: payload.fileName.trim(),
|
||||
base64Data: payload.base64Data,
|
||||
mimeType: payload.mimeType,
|
||||
bytes: payload.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importHostDocumentFile() {
|
||||
if (!canUseNativeHostCapability('file.importDocument')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeHostDocumentImportResult(
|
||||
return normalizeHostBridgeImportDocumentResult(
|
||||
await requestNativeAppHostBridge<FileImportDocumentResult>(
|
||||
'file.importDocument',
|
||||
undefined,
|
||||
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
||||
),
|
||||
);
|
||||
) ?? false;
|
||||
} catch (error) {
|
||||
if (isUnsupportedHostBridgeError(error)) {
|
||||
return false;
|
||||
@@ -1118,49 +1019,19 @@ export async function importHostDocumentFile() {
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set(HOST_BRIDGE_AUDIO_MIME_TYPES);
|
||||
|
||||
function normalizeHostAudioImportResult(
|
||||
payload: FileImportAudioResult | null | undefined,
|
||||
): FileImportAudioResult | false {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
payload.action !== 'selected' ||
|
||||
!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(payload.mimeType) ||
|
||||
typeof payload.fileName !== 'string' ||
|
||||
!payload.fileName.trim() ||
|
||||
typeof payload.base64Data !== 'string' ||
|
||||
!payload.base64Data.trim() ||
|
||||
typeof payload.bytes !== 'number' ||
|
||||
!Number.isInteger(payload.bytes) ||
|
||||
payload.bytes <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'selected',
|
||||
fileName: payload.fileName.trim(),
|
||||
base64Data: payload.base64Data,
|
||||
mimeType: payload.mimeType,
|
||||
bytes: payload.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importHostAudioFile() {
|
||||
if (!canUseNativeHostCapability('file.importAudio')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeHostAudioImportResult(
|
||||
return normalizeHostBridgeImportAudioResult(
|
||||
await requestNativeAppHostBridge<FileImportAudioResult>(
|
||||
'file.importAudio',
|
||||
undefined,
|
||||
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
||||
),
|
||||
);
|
||||
) ?? false;
|
||||
} catch (error) {
|
||||
if (isUnsupportedHostBridgeError(error)) {
|
||||
return false;
|
||||
@@ -1365,7 +1236,7 @@ export function subscribeHostImageDrop(
|
||||
return subscribeNativeAppHostBridgeEvent<FileImportImageResult>(
|
||||
'file.imageDropped',
|
||||
(payload) => {
|
||||
const normalizedPayload = normalizeHostImageImportResult(payload);
|
||||
const normalizedPayload = normalizeHostBridgeImportImageResult(payload);
|
||||
if (normalizedPayload) {
|
||||
listener(normalizedPayload);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user