接入桌面壳文本文件导出能力

新增 HostBridge file.exportText 契约、文件名清洗和 H5 导出入口

Tauri 桌面壳通过受控 host_bridge_request 打开保存对话框并写入文本文件

Expo 移动壳对未接入的文件导出能力明确返回 unsupported

更新宿主壳方案、统一协议和项目共享决策记录
This commit is contained in:
2026-06-17 23:02:01 +08:00
parent 6f19e1c3ba
commit d67f9d5725
14 changed files with 540 additions and 12 deletions

View File

@@ -1,6 +1,9 @@
import { describe, expect, test } from 'vitest';
import { normalizeHostBridgeExternalUrl } from './hostBridge';
import {
normalizeHostBridgeExportFileName,
normalizeHostBridgeExternalUrl,
} from './hostBridge';
describe('HostBridge shared contract helpers', () => {
test('只允许明确的外链协议交给宿主打开', () => {
@@ -24,4 +27,19 @@ describe('HostBridge shared contract helpers', () => {
).toBeNull();
expect(normalizeHostBridgeExternalUrl('/relative/path')).toBeNull();
});
test('归一化宿主导出文件名', () => {
expect(normalizeHostBridgeExportFileName(' 作品:记录?.txt ')).toBe(
'作品-记录-.txt',
);
expect(normalizeHostBridgeExportFileName('../secret.txt')).toBe(
'secret.txt',
);
expect(normalizeHostBridgeExportFileName('')).toBe(
'genarrative-export.txt',
);
expect(normalizeHostBridgeExportFileName('a'.repeat(140))).toHaveLength(
120,
);
});
});

View File

@@ -23,6 +23,7 @@ export type HostBridgeMethod =
| 'app.openExternalUrl'
| 'app.setTitle'
| 'clipboard.writeText'
| 'file.exportText'
| 'haptics.impact';
export type HostBridgeCapability =
@@ -143,6 +144,18 @@ export type ClipboardWriteTextPayload = {
text: string;
};
export type FileExportTextPayload = {
fileName: string;
content: string;
mimeType?: string;
};
export type FileExportTextResult = {
action: 'saved';
fileName: string;
bytes: number;
};
export type HapticsImpactPayload = {
style?: 'light' | 'medium' | 'heavy';
};
@@ -156,3 +169,34 @@ export type ShareOpenPayload = {
message?: string;
url?: string;
};
const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt';
const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120;
function isHostBridgeInvalidFileNameCharacter(value: string) {
if (hasHostBridgeControlCharacter(value)) {
return true;
}
return ['<', '>', ':', '"', '/', '\\', '|', '?', '*'].includes(value);
}
export function normalizeHostBridgeExportFileName(rawFileName: unknown) {
if (typeof rawFileName !== 'string') {
return HOST_BRIDGE_FILE_NAME_FALLBACK;
}
const fileName = rawFileName
.trim()
.split('')
.map((character) =>
isHostBridgeInvalidFileNameCharacter(character) ? '-' : character,
)
.join('')
.replace(/\s+/g, ' ')
.replace(/^[.\s-]+/, '')
.slice(0, HOST_BRIDGE_FILE_NAME_MAX_LENGTH)
.trim();
return fileName || HOST_BRIDGE_FILE_NAME_FALLBACK;
}