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', 'appearance.getColorScheme', 'auth.requestLogin', 'payment.request', 'share.setTarget', 'share.open', 'navigation.openNativePage', 'app.openExternalUrl', 'app.setTitle', 'app.setBadgeCount', '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 = { 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 = { bridge: typeof HOST_BRIDGE_PROTOCOL; version: typeof HOST_BRIDGE_VERSION; id: string; } & ( | { ok: true; result?: Result; } | { ok: false; error: HostBridgeError; } ); export type HostBridgeEvent = { bridge: typeof HOST_BRIDGE_PROTOCOL; version: typeof HOST_BRIDGE_VERSION; event: string; payload?: Payload; }; export type NavigationCanGoBackEventPayload = { canGoBack: boolean; }; 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 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; }