Files
Genarrative/packages/shared/src/contracts/hostBridge.ts
T
kdletters 910625d5e1 接入原生壳分享卡图片导出
新增 file.exportImage 宿主能力契约

分享卡下载在原生壳中优先走宿主图片导出

Expo 壳写入缓存图片并调用系统分享保存

Tauri 壳通过保存对话框写入图片字节

补齐能力漂移检查、测试和架构文档
2026-06-18 01:31:28 +08:00

231 lines
4.8 KiB
TypeScript

export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge';
export const HOST_BRIDGE_VERSION = 1;
export type HostShellKind = 'browser' | 'wechat_mini_program' | 'native_app';
export type NativeHostShell = 'expo_mobile' | 'tauri_desktop';
export type NativeHostPlatform =
| 'ios'
| 'android'
| 'macos'
| 'windows'
| 'linux'
| 'unknown';
export const HOST_BRIDGE_METHODS = [
'host.getRuntime',
'auth.requestLogin',
'payment.request',
'share.setTarget',
'share.open',
'navigation.openNativePage',
'app.openExternalUrl',
'app.setTitle',
'clipboard.writeText',
'file.exportText',
'file.exportImage',
'haptics.impact',
] as const;
export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number];
export const HOST_BRIDGE_CAPABILITIES = [
...HOST_BRIDGE_METHODS,
'host.events',
'navigation.canGoBack',
] as const;
export type HostBridgeCapability = (typeof HOST_BRIDGE_CAPABILITIES)[number];
export function isHostBridgeCapability(
value: unknown,
): value is HostBridgeCapability {
return (
typeof value === 'string' &&
HOST_BRIDGE_CAPABILITIES.includes(value as HostBridgeCapability)
);
}
export type HostBridgeRuntimeResult = {
shell: NativeHostShell;
platform: NativeHostPlatform;
hostVersion: string | null;
bridgeVersion: number;
capabilities: HostBridgeCapability[];
};
export type HostBridgeRequest<Payload = unknown> = {
bridge: typeof HOST_BRIDGE_PROTOCOL;
version: typeof HOST_BRIDGE_VERSION;
id: string;
method: HostBridgeMethod;
payload?: Payload;
timeoutMs?: number;
};
export type HostBridgeError = {
code:
| 'invalid_request'
| 'unsupported_method'
| 'unsupported_capability'
| 'timeout'
| 'cancelled'
| 'host_error';
message: string;
};
export type HostBridgeResponse<Result = unknown> = {
bridge: typeof HOST_BRIDGE_PROTOCOL;
version: typeof HOST_BRIDGE_VERSION;
id: string;
} & (
| {
ok: true;
result?: Result;
}
| {
ok: false;
error: HostBridgeError;
}
);
export type HostBridgeEvent<Payload = unknown> = {
bridge: typeof HOST_BRIDGE_PROTOCOL;
version: typeof HOST_BRIDGE_VERSION;
event: string;
payload?: Payload;
};
export type NavigationCanGoBackEventPayload = {
canGoBack: boolean;
};
export type NavigateNativePagePayload = {
url: string;
};
export type OpenExternalUrlPayload = {
url: string;
};
export type SetTitlePayload = {
title: string;
};
export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [
'http:',
'https:',
'mailto:',
'tel:',
] as const;
export type HostBridgeExternalUrlProtocol =
(typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number];
function hasHostBridgeControlCharacter(value: string) {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127;
});
}
export function normalizeHostBridgeExternalUrl(rawUrl: unknown) {
if (typeof rawUrl !== 'string') {
return null;
}
const urlText = rawUrl.trim();
if (!urlText || hasHostBridgeControlCharacter(urlText)) {
return null;
}
try {
const url = new URL(urlText);
if (
!HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS.includes(
url.protocol as HostBridgeExternalUrlProtocol,
)
) {
return null;
}
return url.toString();
} catch {
return null;
}
}
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 FileExportImagePayload = {
fileName: string;
base64Data: string;
mimeType: 'image/png' | 'image/jpeg' | 'image/webp';
};
export type FileExportImageResult = {
action: 'saved';
fileName: string;
bytes: number;
};
export type HapticsImpactPayload = {
style?: 'light' | 'medium' | 'heavy';
};
export type ShareSetTargetPayload = {
target: unknown;
};
export type ShareOpenPayload = {
title?: string;
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;
}