diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index dfab2a1aa..4e4941914 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1252,26 +1252,32 @@ function assertH5HostBridgePayloadBoundaries() { { functionName: 'importHostTextFile', normalizer: 'normalizeHostBridgeImportTextResult', + preserveCancellation: true, }, { functionName: 'importHostDocumentFile', normalizer: 'normalizeHostBridgeImportDocumentResult', + preserveCancellation: true, }, { functionName: 'importHostImageFile', normalizer: 'normalizeHostBridgeImportImageResult', + preserveCancellation: true, }, { functionName: 'captureHostImageFile', normalizer: 'normalizeHostBridgeImportImageResult', + preserveCancellation: true, }, { functionName: 'importHostAudioFile', normalizer: 'normalizeHostBridgeImportAudioResult', + preserveCancellation: true, }, { functionName: 'subscribeHostImageDrop', normalizer: 'normalizeHostBridgeImportImageResult', + preserveCancellation: false, }, ]) { const functionSource = extractFunctionSource( @@ -1283,6 +1289,24 @@ function assertH5HostBridgePayloadBoundaries() { `${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`, ); } + if ( + importBoundary.preserveCancellation && + (!functionSource.includes('isCancelledHostBridgeError(error)') || + !functionSource.includes('return null;')) + ) { + throw new Error( + `${importBoundary.functionName} must preserve native user cancellation as null`, + ); + } + } + const unsupportedHelperSource = extractFunctionSource( + h5HostBridgeSource, + 'isUnsupportedHostBridgeError', + ); + if (unsupportedHelperSource.includes("'cancelled'")) { + throw new Error( + 'H5 HostBridge facade must not treat native user cancellation as unsupported capability', + ); } for (const localMimeSet of [ 'HOST_BRIDGE_TEXT_MIME_TYPE_SET', diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 1a2527138..f6df3597a 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -1488,8 +1488,10 @@ describe('hostBridge', () => { test('原生 App HostBridge 读取剪贴板结果按共享清单归一化', async () => { const oversizedText = 'a'.repeat(HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH + 5); + let requestCount = 0; const invoke = vi.fn( async (_command: string, args?: Record) => { + requestCount += 1; const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', @@ -1497,10 +1499,7 @@ describe('hostBridge', () => { id: request.id, ok: true, result: { - text: - invoke.mock.calls.length === 1 - ? 42 - : oversizedText, + text: requestCount === 1 ? 42 : oversizedText, }, }; }, @@ -2147,7 +2146,7 @@ describe('hostBridge', () => { expect(invoke).not.toHaveBeenCalled(); }); - test('原生 App 宿主取消导入音频时回退为 false', async () => { + test('原生 App 宿主取消导入音频时返回 null', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; @@ -2174,10 +2173,10 @@ describe('hostBridge', () => { }, }; - await expect(importHostAudioFile()).resolves.toBe(false); + await expect(importHostAudioFile()).resolves.toBeNull(); }); - test('原生 App 宿主取消导入文本时回退为 false', async () => { + test('原生 App 宿主取消导入文本时返回 null', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; @@ -2204,10 +2203,10 @@ describe('hostBridge', () => { }, }; - await expect(importHostTextFile()).resolves.toBe(false); + await expect(importHostTextFile()).resolves.toBeNull(); }); - test('原生 App 宿主取消导入文档时回退为 false', async () => { + test('原生 App 宿主取消导入文档时返回 null', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; @@ -2234,7 +2233,7 @@ describe('hostBridge', () => { }, }; - await expect(importHostDocumentFile()).resolves.toBe(false); + await expect(importHostDocumentFile()).resolves.toBeNull(); }); test('原生 App 宿主导入文档返回畸形 payload 时回退为 false', async () => { @@ -2303,7 +2302,7 @@ describe('hostBridge', () => { await expect(importHostAudioFile()).resolves.toBe(false); }); - test('原生 App 宿主取消导入图片时回退为 false', async () => { + test('原生 App 宿主取消导入图片时返回 null', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; @@ -2330,7 +2329,37 @@ describe('hostBridge', () => { }, }; - await expect(importHostImageFile()).resolves.toBe(false); + await expect(importHostImageFile()).resolves.toBeNull(); + }); + + test('原生 App 宿主取消拍摄图片时返回 null', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: false, + error: { + code: 'cancelled', + message: 'camera capture cancelled', + }, + }; + }, + ); + window.history.replaceState( + null, + '', + nativeAppPath(['file.captureImage']), + ); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + await expect(captureHostImageFile()).resolves.toBeNull(); }); test('原生 App 宿主取消扫码时不触发 H5 摄像头回退', async () => { diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index ab698d07d..a3b53c76c 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -173,11 +173,14 @@ function isUnsupportedHostBridgeError(error: unknown) { return ( error instanceof Error && (error.name === 'unsupported_method' || - error.name === 'unsupported_capability' || - error.name === 'cancelled') + error.name === 'unsupported_capability') ); } +function isCancelledHostBridgeError(error: unknown) { + return error instanceof Error && error.name === 'cancelled'; +} + function normalizeHostCapabilities(values: unknown): HostBridgeCapability[] { if (!Array.isArray(values)) { return []; @@ -994,6 +997,9 @@ export async function importHostImageFile() { ), ) ?? false; } catch (error) { + if (isCancelledHostBridgeError(error)) { + return null; + } if (isUnsupportedHostBridgeError(error)) { return false; } @@ -1015,6 +1021,9 @@ export async function captureHostImageFile() { ), ) ?? false; } catch (error) { + if (isCancelledHostBridgeError(error)) { + return null; + } if (isUnsupportedHostBridgeError(error)) { return false; } @@ -1036,7 +1045,7 @@ export async function scanHostQrCode() { ); return normalizeHostBridgeQrCodeValue(result?.value) ?? false; } catch (error) { - if (error instanceof Error && error.name === 'cancelled') { + if (isCancelledHostBridgeError(error)) { return null; } if (isUnsupportedHostBridgeError(error)) { @@ -1060,6 +1069,9 @@ export async function importHostTextFile() { ), ) ?? false; } catch (error) { + if (isCancelledHostBridgeError(error)) { + return null; + } if (isUnsupportedHostBridgeError(error)) { return false; } @@ -1081,6 +1093,9 @@ export async function importHostDocumentFile() { ), ) ?? false; } catch (error) { + if (isCancelledHostBridgeError(error)) { + return null; + } if (isUnsupportedHostBridgeError(error)) { return false; } @@ -1102,6 +1117,9 @@ export async function importHostAudioFile() { ), ) ?? false; } catch (error) { + if (isCancelledHostBridgeError(error)) { + return null; + } if (isUnsupportedHostBridgeError(error)) { return false; }