接入桌面壳图片导入能力
新增 file.importImage 与 file.imageDropped HostBridge 能力 Tauri 壳通过系统图片选择和主窗口拖拽读取真实图片文件 H5 统一使用导入图片与拖入图片事件 facade 限制图片 MIME 与大小并避免暴露本机绝对路径 更新壳能力校验、测试和架构文档
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user