接入原生壳文本文件导入能力

新增 file.importText HostBridge 契约和 H5 facade

移动端通过 Expo DocumentPicker 读取受控文本文件

桌面端通过 Tauri 文件选择框读取受控文本文件

更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
2026-06-18 04:51:56 +08:00
parent c7a24fba37
commit 1c6749b53e
16 changed files with 527 additions and 4 deletions
+101
View File
@@ -13,6 +13,7 @@ import {
getHostRuntime,
getNativeAppHostRuntime,
importHostImageFile,
importHostTextFile,
isWechatMiniProgramWebViewRuntime,
navigateHostNativePage,
openHostExternalUrl,
@@ -607,6 +608,14 @@ describe('hostBridge', () => {
mimeType: 'image/png',
bytes: 5,
}
: request.method === 'file.importText'
? {
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
}
: true,
};
});
@@ -627,6 +636,7 @@ describe('hostBridge', () => {
'app.setBadgeCount',
'network.status',
'share.open',
'file.importText',
'file.exportImage',
'file.importImage',
'file.imageDropped',
@@ -697,6 +707,13 @@ describe('hostBridge', () => {
mimeType: 'image/png',
bytes: 5,
});
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
await expect(
showHostLocalNotification({
title: ' 生成完成 ',
@@ -806,6 +823,12 @@ describe('hostBridge', () => {
timeoutMs: 30000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importText',
timeoutMs: 30000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'notification.showLocal',
@@ -882,6 +905,7 @@ describe('hostBridge', () => {
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(
showHostLocalNotification({
title: '生成完成',
@@ -906,6 +930,7 @@ describe('hostBridge', () => {
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(
showHostLocalNotification({
title: '生成完成',
@@ -1155,6 +1180,82 @@ 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: ' 剧情.md ',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importText']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importText',
timeoutMs: 30000,
}),
});
});
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: false,
error: {
code: 'cancelled',
message: 'file import cancelled',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importText']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostTextFile()).resolves.toBe(false);
});
test('原生 App 宿主取消导入图片时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
+59
View File
@@ -7,11 +7,13 @@ import type {
FileExportTextPayload,
FileExportTextResult,
FileImportImageResult,
FileImportTextResult,
HapticsImpactPayload,
HostBridgeCapability,
HostBridgeImageMimeType,
HostBridgeMethod,
HostBridgeRuntimeResult,
HostBridgeTextMimeType,
LocalNotificationPayload,
NetworkStatusResult,
OpenExternalUrlPayload,
@@ -94,6 +96,8 @@ export type HostFileExportImageRequest = FileExportImagePayload;
export type HostFileImportImageResult = FileImportImageResult;
export type HostFileImportTextResult = FileImportTextResult;
export type HostClipboardWriteTextRequest = {
text: string;
};
@@ -830,6 +834,61 @@ export async function importHostImageFile() {
}
}
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
'text/plain',
'text/markdown',
'text/csv',
'application/json',
]);
function normalizeHostTextImportResult(
payload: FileImportTextResult | null | undefined,
): FileImportTextResult | false {
if (
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_TEXT_MIME_TYPES.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(
await requestNativeAppHostBridge<FileImportTextResult>(
'file.importText',
undefined,
{ timeoutMs: 30000 },
),
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
export async function requestHostHapticsImpact(
params: HostHapticsImpactRequest = {},
) {