Files
Genarrative/packages/shared/src/contracts/hostBridge.ts
T
kdletters a54210b0d1 收口原生壳公开主站来源
新增共享 HostBridge 公开主站 origin 和默认 URL 常量

移动壳启动 URL、分享和 WebView 策略改为引用共享主站来源

桌面壳配置检查反查 Rust WEB_APP_ORIGIN 与共享契约一致

同步原生壳方案和共享决策记录
2026-06-19 01:40:22 +08:00

549 lines
12 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 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 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;
}