Files
Genarrative/packages/shared/src/contracts/hostBridge.ts
T
kdletters 75cd4f1abc 收口原生壳能力来源
新增共享 HostBridge capability profile,区分 Expo 移动通用、iOS 额外和 Tauri 桌面能力

移动壳能力清单改为引用共享 profile,入口 URL 类型支持 readonly 能力数组

两端配置检查和原生壳总门禁改为反查共享 profile,桌面 Rust 清单保持运行时镜像

补充 HostBridge profile 单测和 H5 readonly 能力合并类型兼容

同步 Expo/Tauri 壳方案、HostBridge 协议文档和项目决策记录
2026-06-19 02:02:15 +08:00

611 lines
14 KiB
TypeScript

export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge';
export const HOST_BRIDGE_VERSION = 1;
export const HOST_BRIDGE_TAURI_COMMAND = 'host_bridge_request';
export const HOST_BRIDGE_PUBLIC_WEB_ORIGIN = 'https://app.genarrative.world';
export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://app.genarrative.world/';
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',
'appearance.getColorScheme',
'auth.requestLogin',
'payment.request',
'share.setTarget',
'share.open',
'navigation.openNativePage',
'app.reloadWebView',
'app.openExternalUrl',
'app.setTitle',
'app.setBadgeCount',
'network.status',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
'notification.showLocal',
] as const;
export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number];
export function isHostBridgeMethod(value: unknown): value is HostBridgeMethod {
return (
typeof value === 'string' &&
HOST_BRIDGE_METHODS.includes(value as HostBridgeMethod)
);
}
export const HOST_BRIDGE_CAPABILITIES = [
...HOST_BRIDGE_METHODS,
'host.events',
'app.lifecycle',
'network.statusChanged',
'file.imageDropped',
'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 const HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES: readonly HostBridgeCapability[] = [
'host.getRuntime',
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'share.open',
'share.setTarget',
'navigation.openNativePage',
'navigation.canGoBack',
'app.reloadWebView',
'app.openExternalUrl',
'network.status',
'network.statusChanged',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
'notification.showLocal',
];
export const HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES: readonly HostBridgeCapability[] = [
'app.setBadgeCount',
];
export const HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES: readonly HostBridgeCapability[] = [
...HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES,
...HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES,
];
export const HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES: readonly HostBridgeCapability[] = [
'host.getRuntime',
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'share.open',
'share.setTarget',
'navigation.openNativePage',
'navigation.canGoBack',
'app.reloadWebView',
'app.openExternalUrl',
'app.setTitle',
'app.setBadgeCount',
'network.status',
'network.statusChanged',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.importAudio',
'file.exportAudio',
'file.imageDropped',
'notification.showLocal',
];
export type HostBridgeRuntimeResult = {
shell: NativeHostShell;
platform: NativeHostPlatform;
hostVersion: string | null;
bridgeVersion: number;
capabilities: readonly 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 const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120;
export function normalizeHostBridgeRequestId(rawId: unknown) {
if (typeof rawId !== 'string') {
return null;
}
const id = rawId.trim();
if (
!id ||
id.length > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH ||
hasHostBridgeControlCharacter(id)
) {
return null;
}
return id;
}
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 HostAppLifecycleState = 'active' | 'inactive' | 'background';
export type AppLifecycleEventPayload = {
state: HostAppLifecycleState;
focused: boolean;
nativeState?: string;
};
export function normalizeHostBridgeLifecycleState(
rawState: unknown,
): HostAppLifecycleState {
return rawState === 'active' || rawState === 'background'
? rawState
: 'inactive';
}
export type HostNetworkConnectionType =
| 'none'
| 'unknown'
| 'cellular'
| 'wifi'
| 'ethernet'
| 'bluetooth'
| 'vpn'
| 'other';
export type NetworkStatusResult = {
isConnected: boolean;
isInternetReachable: boolean | null;
connectionType: HostNetworkConnectionType;
nativeType?: string;
};
export function normalizeHostBridgeConnectionType(
rawConnectionType: unknown,
): HostNetworkConnectionType {
if (typeof rawConnectionType !== 'string') {
return 'unknown';
}
const normalizedType = rawConnectionType.toLowerCase();
if (
normalizedType === 'none' ||
normalizedType === 'cellular' ||
normalizedType === 'wifi' ||
normalizedType === 'ethernet' ||
normalizedType === 'bluetooth' ||
normalizedType === 'vpn'
) {
return normalizedType;
}
if (normalizedType === 'other') {
return 'other';
}
return 'unknown';
}
export type HostAppearanceColorScheme = 'light' | 'dark' | 'unknown';
export type AppearanceColorSchemeResult = {
colorScheme: HostAppearanceColorScheme;
};
export function normalizeHostBridgeColorScheme(
rawColorScheme: unknown,
): HostAppearanceColorScheme {
return rawColorScheme === 'light' || rawColorScheme === 'dark'
? rawColorScheme
: 'unknown';
}
export type NavigateNativePagePayload = {
url: string;
};
export type OpenExternalUrlPayload = {
url: string;
};
export type SetTitlePayload = {
title: string;
};
export type SetBadgeCountPayload = {
count: number;
};
export const HOST_BRIDGE_BADGE_COUNT_MAX = 99999;
export function normalizeHostBridgeBadgeCount(rawCount: unknown) {
if (
typeof rawCount !== 'number' ||
!Number.isInteger(rawCount) ||
rawCount < 0 ||
rawCount > HOST_BRIDGE_BADGE_COUNT_MAX
) {
return null;
}
return rawCount;
}
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 ClipboardReadTextResult = {
text: string;
};
export const HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH = 100000;
export function normalizeHostBridgeClipboardText(
rawText: unknown,
): ClipboardReadTextResult | null {
if (typeof rawText !== 'string') {
return null;
}
return {
text: rawText.slice(0, HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH),
};
}
export type FileExportTextPayload = {
fileName: string;
content: string;
mimeType?: string;
};
export type FileExportTextResult = {
action: 'saved';
fileName: string;
bytes: number;
};
export type HostBridgeTextMimeType =
| 'text/plain'
| 'text/markdown'
| 'text/csv'
| 'application/json';
export const HOST_BRIDGE_TEXT_MIME_TYPES = [
'text/plain',
'text/markdown',
'text/csv',
'application/json',
] as const satisfies readonly HostBridgeTextMimeType[];
export type FileImportTextResult = {
action: 'selected';
fileName: string;
content: string;
mimeType: HostBridgeTextMimeType;
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 HostBridgeImageMimeType =
| 'image/png'
| 'image/jpeg'
| 'image/webp';
export const HOST_BRIDGE_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
] as const satisfies readonly HostBridgeImageMimeType[];
export type FileImportImageResult = {
action: 'selected' | 'dropped' | 'captured';
fileName: string;
base64Data: string;
mimeType: HostBridgeImageMimeType;
bytes: number;
position?: {
x: number;
y: number;
};
};
export type HostBridgeAudioMimeType =
| 'audio/mpeg'
| 'audio/mp4'
| 'audio/wav'
| 'audio/ogg'
| 'audio/webm';
export const HOST_BRIDGE_AUDIO_MIME_TYPES = [
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
] as const satisfies readonly HostBridgeAudioMimeType[];
export type FileImportAudioResult = {
action: 'selected';
fileName: string;
base64Data: string;
mimeType: HostBridgeAudioMimeType;
bytes: number;
};
export type FileExportAudioPayload = {
fileName: string;
base64Data: string;
mimeType: HostBridgeAudioMimeType;
};
export type FileExportAudioResult = {
action: 'saved';
fileName: string;
bytes: number;
};
export const HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [
'light',
'medium',
'heavy',
] as const;
export type HostBridgeHapticsImpactStyle =
(typeof HOST_BRIDGE_HAPTICS_IMPACT_STYLES)[number];
export function normalizeHostBridgeHapticsImpactStyle(rawStyle: unknown) {
if (rawStyle === undefined) {
return 'light';
}
return HOST_BRIDGE_HAPTICS_IMPACT_STYLES.includes(
rawStyle as HostBridgeHapticsImpactStyle,
)
? rawStyle as HostBridgeHapticsImpactStyle
: null;
}
export type HapticsImpactPayload = {
style?: HostBridgeHapticsImpactStyle;
};
export type LocalNotificationPayload = {
title: string;
body?: string;
};
export const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH = 80;
export const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH = 240;
function normalizeHostBridgePlainText(
value: unknown,
maxLength: number,
required: boolean,
) {
if (typeof value !== 'string') {
return required ? null : undefined;
}
if (hasHostBridgeControlCharacter(value)) {
return null;
}
const text = value.trim().replace(/\s+/g, ' ');
if (!text) {
return required ? null : undefined;
}
return text.slice(0, maxLength);
}
export function normalizeHostBridgeLocalNotification(
payload: unknown,
): LocalNotificationPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<LocalNotificationPayload>;
const title = normalizeHostBridgePlainText(
candidate.title,
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
true,
);
if (!title) {
return null;
}
const body = normalizeHostBridgePlainText(
candidate.body,
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
false,
);
if (body === null) {
return null;
}
return body ? { title, body } : { title };
}
export type ShareSetTargetPayload = {
target: unknown;
};
export type ShareOpenPayload = {
title?: string;
message?: string;
url?: string;
};
export const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt';
export 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;
}