接入桌面壳图片导入能力

新增 file.importImage 与 file.imageDropped HostBridge 能力

Tauri 壳通过系统图片选择和主窗口拖拽读取真实图片文件

H5 统一使用导入图片与拖入图片事件 facade

限制图片 MIME 与大小并避免暴露本机绝对路径

更新壳能力校验、测试和架构文档
This commit is contained in:
2026-06-18 02:52:37 +08:00
parent 586e46fa63
commit 199f02cf9f
10 changed files with 418 additions and 4 deletions
+108
View File
@@ -12,6 +12,7 @@ import {
getHostNetworkStatus,
getHostRuntime,
getNativeAppHostRuntime,
importHostImageFile,
isWechatMiniProgramWebViewRuntime,
navigateHostNativePage,
openHostExternalUrl,
@@ -31,6 +32,7 @@ import {
setHostAppTitle,
setHostShareTarget,
subscribeHostAppLifecycle,
subscribeHostImageDrop,
subscribeHostNetworkStatusChange,
subscribeHostRuntimeChange,
writeHostClipboardText,
@@ -591,6 +593,14 @@ describe('hostBridge', () => {
connectionType: 'ethernet',
nativeType: 'online',
}
: request.method === 'file.importImage'
? {
action: 'selected',
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
}
: true,
};
});
@@ -611,6 +621,8 @@ describe('hostBridge', () => {
'network.status',
'share.open',
'file.exportImage',
'file.importImage',
'file.imageDropped',
]),
);
window.__TAURI__ = {
@@ -667,6 +679,13 @@ describe('hostBridge', () => {
mimeType: 'image/png',
}),
).resolves.toBe(true);
await expect(importHostImageFile()).resolves.toEqual({
action: 'selected',
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
@@ -759,6 +778,12 @@ describe('hostBridge', () => {
timeoutMs: 30000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importImage',
timeoutMs: 30000,
}),
});
});
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
@@ -824,6 +849,7 @@ describe('hostBridge', () => {
mimeType: 'image/png',
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
});
test('普通浏览器不处理宿主文件导出', async () => {
@@ -841,6 +867,7 @@ describe('hostBridge', () => {
mimeType: 'image/png',
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
});
test('原生 App 宿主通过 HostBridge 导出文本文件', async () => {
@@ -981,4 +1008,85 @@ describe('hostBridge', () => {
}),
});
});
test('原生 App 宿主通过 HostBridge 导入和订阅拖入图片文件', 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: ' 参考图.png ',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
},
};
},
);
const listener = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['file.importImage', 'file.imageDropped']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostImageFile()).resolves.toEqual({
action: 'selected',
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
const unsubscribe = subscribeHostImageDrop(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'file.imageDropped',
payload: {
action: 'dropped',
fileName: '拖入图.webp',
base64Data: 'ZHJvcA==',
mimeType: 'image/webp',
bytes: 4,
position: {
x: 32,
y: 48,
},
},
}),
}),
);
unsubscribe();
expect(listener).toHaveBeenCalledWith({
action: 'dropped',
fileName: '拖入图.webp',
base64Data: 'ZHJvcA==',
mimeType: 'image/webp',
bytes: 4,
position: {
x: 32,
y: 48,
},
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importImage',
timeoutMs: 30000,
}),
});
});
});
+92
View File
@@ -5,8 +5,10 @@ import type {
FileExportImageResult,
FileExportTextPayload,
FileExportTextResult,
FileImportImageResult,
HapticsImpactPayload,
HostBridgeCapability,
HostBridgeImageMimeType,
HostBridgeMethod,
HostBridgeRuntimeResult,
NetworkStatusResult,
@@ -86,6 +88,8 @@ export type HostFileExportTextRequest = FileExportTextPayload;
export type HostFileExportImageRequest = FileExportImagePayload;
export type HostFileImportImageResult = FileImportImageResult;
export type HostClipboardWriteTextRequest = {
text: string;
};
@@ -108,6 +112,8 @@ export type HostAppLifecycleSnapshot = AppLifecycleEventPayload;
export type HostNetworkStatusSnapshot = NetworkStatusResult;
export type HostImageDropSnapshot = FileImportImageResult;
const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
let cachedNativeHostRuntime: HostBridgeRuntimeResult | null = null;
@@ -729,6 +735,74 @@ export async function exportHostImageFile(
}
}
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
'image/png',
'image/jpeg',
'image/webp',
]);
function normalizeHostImageImportResult(
payload: FileImportImageResult | null | undefined,
): FileImportImageResult | false {
if (
!payload ||
typeof payload !== 'object' ||
(payload.action !== 'selected' && payload.action !== 'dropped') ||
!HOST_BRIDGE_IMAGE_MIME_TYPES.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(
await requestNativeAppHostBridge<FileImportImageResult>(
'file.importImage',
undefined,
{ timeoutMs: 30000 },
),
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
export async function requestHostHapticsImpact(
params: HostHapticsImpactRequest = {},
) {
@@ -872,3 +946,21 @@ export function subscribeHostNetworkStatusChange(
},
);
}
export function subscribeHostImageDrop(
listener: (payload: HostImageDropSnapshot) => void,
) {
if (!canUseNativeHostCapability('file.imageDropped')) {
return () => undefined;
}
return subscribeNativeAppHostBridgeEvent<FileImportImageResult>(
'file.imageDropped',
(payload) => {
const normalizedPayload = normalizeHostImageImportResult(payload);
if (normalizedPayload) {
listener(normalizedPayload);
}
},
);
}