8bd8363ffa
将原生壳公开主站统一为 www.genarrative.world 调整 Tauri release 直接加载线上主站并移除 frontendDist 打包 补齐创作入口配置开发代理与回归测试 同步原生壳方案文档和共享记忆
1208 lines
29 KiB
TypeScript
1208 lines
29 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://www.genarrative.world';
|
|
export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://www.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_RUNTIME_CONTEXT_QUERY_KEY = {
|
|
clientRuntime: 'clientRuntime',
|
|
clientType: 'clientType',
|
|
miniProgramEnv: 'miniProgramEnv',
|
|
hostShell: 'hostShell',
|
|
hostPlatform: 'hostPlatform',
|
|
hostVersion: 'hostVersion',
|
|
bridgeVersion: 'bridgeVersion',
|
|
hostCapabilities: 'hostCapabilities',
|
|
} as const;
|
|
|
|
export const HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS = [
|
|
'clientType',
|
|
'clientRuntime',
|
|
'miniProgramEnv',
|
|
'hostShell',
|
|
'hostPlatform',
|
|
'hostVersion',
|
|
'bridgeVersion',
|
|
'hostCapabilities',
|
|
] as const;
|
|
|
|
export const HOST_BRIDGE_NATIVE_APP_QUERY_KEY = {
|
|
clientRuntime: 'clientRuntime',
|
|
clientType: 'clientType',
|
|
hostShell: 'hostShell',
|
|
hostPlatform: 'hostPlatform',
|
|
hostVersion: 'hostVersion',
|
|
bridgeVersion: 'bridgeVersion',
|
|
hostCapabilities: 'hostCapabilities',
|
|
} as const;
|
|
|
|
export const HOST_BRIDGE_NATIVE_APP_QUERY_KEYS = [
|
|
'clientRuntime',
|
|
'clientType',
|
|
'hostShell',
|
|
'hostPlatform',
|
|
'hostVersion',
|
|
'bridgeVersion',
|
|
'hostCapabilities',
|
|
] as const;
|
|
|
|
export const HOST_BRIDGE_NATIVE_APP_QUERY = {
|
|
clientRuntime: 'native_app',
|
|
clientType: 'native_app',
|
|
hostShellExpoMobile: 'expo_mobile',
|
|
hostShellTauriDesktop: 'tauri_desktop',
|
|
} as const;
|
|
|
|
export const HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY = {
|
|
clientType: 'mini_program',
|
|
clientRuntime: 'wechat_mini_program',
|
|
} as const;
|
|
|
|
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.importDocument',
|
|
'file.exportImage',
|
|
'file.importImage',
|
|
'file.captureImage',
|
|
'scanner.scanQrCode',
|
|
'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_WECHAT_MINI_PROGRAM_CAPABILITIES: readonly HostBridgeCapability[] = [
|
|
'auth.requestLogin',
|
|
'payment.request',
|
|
'share.setTarget',
|
|
'share.open',
|
|
'navigation.openNativePage',
|
|
];
|
|
|
|
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.importDocument',
|
|
'file.exportImage',
|
|
'file.importImage',
|
|
'file.captureImage',
|
|
'scanner.scanQrCode',
|
|
'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',
|
|
'clipboard.writeText',
|
|
'clipboard.readText',
|
|
'file.exportText',
|
|
'file.importText',
|
|
'file.importDocument',
|
|
'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_DEFAULT_REQUEST_TIMEOUT_MS = 8000;
|
|
export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000;
|
|
export const HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
|
|
export const HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS = 30000;
|
|
export const HOST_BRIDGE_SCANNER_TIMEOUT_MS = 60000;
|
|
export const HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS = 1200;
|
|
export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128;
|
|
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 const HOST_BRIDGE_EVENTS = [
|
|
'app.lifecycle',
|
|
'network.statusChanged',
|
|
'navigation.canGoBack',
|
|
'file.imageDropped',
|
|
] as const;
|
|
|
|
export type HostBridgeEventName = (typeof HOST_BRIDGE_EVENTS)[number];
|
|
|
|
export function isHostBridgeEventName(
|
|
value: unknown,
|
|
): value is HostBridgeEventName {
|
|
return (
|
|
typeof value === 'string' &&
|
|
HOST_BRIDGE_EVENTS.includes(value as HostBridgeEventName)
|
|
);
|
|
}
|
|
|
|
export type HostBridgeEvent<Payload = unknown> = {
|
|
bridge: typeof HOST_BRIDGE_PROTOCOL;
|
|
version: typeof HOST_BRIDGE_VERSION;
|
|
event: HostBridgeEventName;
|
|
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 const HOST_BRIDGE_APP_TITLE_MAX_LENGTH = 80;
|
|
|
|
export function normalizeHostBridgeAppTitle(rawTitle: unknown): SetTitlePayload | null {
|
|
if (typeof rawTitle !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const title = rawTitle.trim();
|
|
if (!title || hasHostBridgeControlCharacter(title)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
title: title.slice(0, HOST_BRIDGE_APP_TITLE_MAX_LENGTH),
|
|
};
|
|
}
|
|
|
|
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];
|
|
|
|
export const HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = [
|
|
'blob:',
|
|
'data:',
|
|
'file:',
|
|
'filesystem:',
|
|
] as const;
|
|
|
|
export type HostBridgeMobileWebViewBlockedDownloadProtocol =
|
|
(typeof HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_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 function normalizeHostBridgeExternalUrlPayload(
|
|
rawUrl: unknown,
|
|
): OpenExternalUrlPayload | null {
|
|
const url = normalizeHostBridgeExternalUrl(rawUrl);
|
|
return url ? { url } : 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?: HostBridgeTextMimeType;
|
|
};
|
|
|
|
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[];
|
|
|
|
const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set<HostBridgeTextMimeType>(
|
|
HOST_BRIDGE_TEXT_MIME_TYPES,
|
|
);
|
|
|
|
export type FileImportTextResult = {
|
|
action: 'selected';
|
|
fileName: string;
|
|
content: string;
|
|
mimeType: HostBridgeTextMimeType;
|
|
bytes: number;
|
|
};
|
|
|
|
export type HostBridgeDocumentMimeType =
|
|
| HostBridgeTextMimeType
|
|
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
|
|
export const HOST_BRIDGE_DOCUMENT_MIME_TYPES = [
|
|
...HOST_BRIDGE_TEXT_MIME_TYPES,
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
] as const satisfies readonly HostBridgeDocumentMimeType[];
|
|
|
|
export type FileImportDocumentResult = {
|
|
action: 'selected';
|
|
fileName: string;
|
|
base64Data: string;
|
|
mimeType: HostBridgeDocumentMimeType;
|
|
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[];
|
|
|
|
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set<HostBridgeImageMimeType>(
|
|
HOST_BRIDGE_IMAGE_MIME_TYPES,
|
|
);
|
|
|
|
export type FileImportImageResult = {
|
|
action: 'selected' | 'dropped' | 'captured';
|
|
fileName: string;
|
|
base64Data: string;
|
|
mimeType: HostBridgeImageMimeType;
|
|
bytes: number;
|
|
position?: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
};
|
|
|
|
export type ScannerScanQrCodeResult = {
|
|
value: string;
|
|
format: 'qr_code';
|
|
};
|
|
|
|
export const HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH = 4096;
|
|
|
|
export function normalizeHostBridgeQrCodeValue(
|
|
rawValue: unknown,
|
|
): ScannerScanQrCodeResult | null {
|
|
if (typeof rawValue !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const value = rawValue.trim();
|
|
if (!value || hasHostBridgeControlCharacter(value)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
value: value.slice(0, HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH),
|
|
format: 'qr_code',
|
|
};
|
|
}
|
|
|
|
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[];
|
|
|
|
const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set<HostBridgeAudioMimeType>(
|
|
HOST_BRIDGE_AUDIO_MIME_TYPES,
|
|
);
|
|
|
|
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_IMPORT_DOCUMENT_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;
|
|
|
|
function estimateHostBridgeUtf8Bytes(value: string) {
|
|
return new TextEncoder().encode(value).length;
|
|
}
|
|
|
|
export function normalizeHostBridgeExportTextPayload(
|
|
payload: unknown,
|
|
): FileExportTextPayload | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileExportTextPayload>;
|
|
if (typeof candidate.content !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
estimateHostBridgeUtf8Bytes(candidate.content) >
|
|
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPayload: FileExportTextPayload = {
|
|
fileName: normalizeHostBridgeExportFileName(candidate.fileName),
|
|
content: candidate.content,
|
|
};
|
|
|
|
if (candidate.mimeType === undefined) {
|
|
return normalizedPayload;
|
|
}
|
|
|
|
const mimeType = candidate.mimeType as HostBridgeTextMimeType;
|
|
if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...normalizedPayload,
|
|
mimeType,
|
|
};
|
|
}
|
|
|
|
function normalizeHostBridgeImportedBytes(rawBytes: unknown, maxBytes: number) {
|
|
if (
|
|
typeof rawBytes !== 'number' ||
|
|
!Number.isInteger(rawBytes) ||
|
|
rawBytes <= 0 ||
|
|
rawBytes > maxBytes
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return rawBytes;
|
|
}
|
|
|
|
export function normalizeHostBridgeImportFileName(rawFileName: unknown) {
|
|
return normalizeHostBridgeExportFileName(rawFileName);
|
|
}
|
|
|
|
export function normalizeHostBridgeImportTextResult(
|
|
payload: unknown,
|
|
): FileImportTextResult | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileImportTextResult>;
|
|
if (candidate.action !== 'selected') {
|
|
return null;
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
|
|
const bytes = normalizeHostBridgeImportedBytes(
|
|
candidate.bytes,
|
|
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
|
|
);
|
|
const mimeType = candidate.mimeType as HostBridgeTextMimeType;
|
|
if (
|
|
!fileName ||
|
|
bytes === null ||
|
|
!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType) ||
|
|
typeof candidate.content !== 'string' ||
|
|
estimateHostBridgeUtf8Bytes(candidate.content) >
|
|
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
action: 'selected',
|
|
fileName,
|
|
content: candidate.content,
|
|
mimeType,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
export function normalizeHostBridgeImportDocumentResult(
|
|
payload: unknown,
|
|
): FileImportDocumentResult | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileImportDocumentResult>;
|
|
if (candidate.action !== 'selected') {
|
|
return null;
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
|
|
const bytes = normalizeHostBridgeImportedBytes(
|
|
candidate.bytes,
|
|
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
|
|
);
|
|
const mimeType = candidate.mimeType as HostBridgeDocumentMimeType;
|
|
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
|
|
if (
|
|
!fileName ||
|
|
bytes === null ||
|
|
!HOST_BRIDGE_DOCUMENT_MIME_TYPES.includes(mimeType) ||
|
|
!base64Data ||
|
|
estimateHostBridgeBase64Bytes(base64Data) >
|
|
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
action: 'selected',
|
|
fileName,
|
|
base64Data,
|
|
mimeType,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
export function normalizeHostBridgeImportImageResult(
|
|
payload: unknown,
|
|
): FileImportImageResult | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileImportImageResult>;
|
|
if (
|
|
candidate.action !== 'selected' &&
|
|
candidate.action !== 'dropped' &&
|
|
candidate.action !== 'captured'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
|
|
const bytes = normalizeHostBridgeImportedBytes(
|
|
candidate.bytes,
|
|
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES,
|
|
);
|
|
const mimeType = candidate.mimeType as HostBridgeImageMimeType;
|
|
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
|
|
if (
|
|
!fileName ||
|
|
bytes === null ||
|
|
!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType) ||
|
|
!base64Data ||
|
|
estimateHostBridgeBase64Bytes(base64Data) >
|
|
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const position =
|
|
candidate.position &&
|
|
typeof candidate.position.x === 'number' &&
|
|
Number.isFinite(candidate.position.x) &&
|
|
typeof candidate.position.y === 'number' &&
|
|
Number.isFinite(candidate.position.y)
|
|
? {
|
|
x: candidate.position.x,
|
|
y: candidate.position.y,
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
action: candidate.action,
|
|
fileName,
|
|
base64Data,
|
|
mimeType,
|
|
bytes,
|
|
...(position ? { position } : {}),
|
|
};
|
|
}
|
|
|
|
export function normalizeHostBridgeImportAudioResult(
|
|
payload: unknown,
|
|
): FileImportAudioResult | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileImportAudioResult>;
|
|
if (candidate.action !== 'selected') {
|
|
return null;
|
|
}
|
|
|
|
const fileName = normalizeHostBridgeImportFileName(candidate.fileName);
|
|
const bytes = normalizeHostBridgeImportedBytes(
|
|
candidate.bytes,
|
|
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
|
|
);
|
|
const mimeType = candidate.mimeType as HostBridgeAudioMimeType;
|
|
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
|
|
if (
|
|
!fileName ||
|
|
bytes === null ||
|
|
!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType) ||
|
|
!base64Data ||
|
|
estimateHostBridgeBase64Bytes(base64Data) >
|
|
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
action: 'selected',
|
|
fileName,
|
|
base64Data,
|
|
mimeType,
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
function normalizeHostBridgeBase64Data(rawData: unknown) {
|
|
if (typeof rawData !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const base64Data = rawData.trim();
|
|
if (!base64Data || hasHostBridgeControlCharacter(base64Data)) {
|
|
return null;
|
|
}
|
|
|
|
return base64Data;
|
|
}
|
|
|
|
function estimateHostBridgeBase64Bytes(base64Data: string) {
|
|
const normalizedLength = base64Data.replace(/\s+/g, '').length;
|
|
return Math.ceil((normalizedLength * 3) / 4);
|
|
}
|
|
|
|
export function normalizeHostBridgeExportImagePayload(
|
|
payload: unknown,
|
|
): FileExportImagePayload | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileExportImagePayload>;
|
|
const mimeType = candidate.mimeType as HostBridgeImageMimeType;
|
|
if (!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType)) {
|
|
return null;
|
|
}
|
|
|
|
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
|
|
if (
|
|
!base64Data ||
|
|
estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
fileName: normalizeHostBridgeExportFileName(candidate.fileName),
|
|
base64Data,
|
|
mimeType,
|
|
};
|
|
}
|
|
|
|
export function normalizeHostBridgeExportAudioPayload(
|
|
payload: unknown,
|
|
): FileExportAudioPayload | null {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const candidate = payload as Partial<FileExportAudioPayload>;
|
|
const mimeType = candidate.mimeType as HostBridgeAudioMimeType;
|
|
if (!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType)) {
|
|
return null;
|
|
}
|
|
|
|
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
|
|
if (
|
|
!base64Data ||
|
|
estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
fileName: normalizeHostBridgeExportFileName(candidate.fileName),
|
|
base64Data,
|
|
mimeType,
|
|
};
|
|
}
|
|
|
|
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 type LocalNotificationResult = {
|
|
action: 'delivered_to_system';
|
|
};
|
|
|
|
export const HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT: LocalNotificationResult = {
|
|
action: 'delivered_to_system',
|
|
};
|
|
|
|
export const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH = 80;
|
|
export const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH = 240;
|
|
export const HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID =
|
|
'genarrative-local';
|
|
|
|
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;
|
|
href?: string;
|
|
path?: string;
|
|
targetPath?: string;
|
|
work?: string;
|
|
};
|
|
|
|
export type HostBridgeSharePayloadNormalization =
|
|
| { status: 'empty' }
|
|
| { status: 'invalid' }
|
|
| { status: 'valid'; payload: ShareOpenPayload };
|
|
|
|
function hostBridgeStringField(value: unknown, field: string) {
|
|
if (!value || typeof value !== 'object') {
|
|
return undefined;
|
|
}
|
|
|
|
const fieldValue = (value as Record<string, unknown>)[field];
|
|
if (typeof fieldValue !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const text = fieldValue.trim();
|
|
return text || undefined;
|
|
}
|
|
|
|
function hostBridgeShareTargetPayload(value: unknown) {
|
|
if (!value || typeof value !== 'object') {
|
|
return value;
|
|
}
|
|
|
|
const target = value as Record<string, unknown>;
|
|
return target.target ?? value;
|
|
}
|
|
|
|
function normalizeHostBridgePublicShareUrl(rawUrl: string | undefined) {
|
|
if (!rawUrl || rawUrl.startsWith('//')) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(rawUrl, HOST_BRIDGE_PUBLIC_WEB_ORIGIN);
|
|
if (url.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN) {
|
|
return undefined;
|
|
}
|
|
|
|
return url.toString();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function hostBridgeWorkDetailUrl(work: string) {
|
|
const searchParams = new URLSearchParams({ work });
|
|
return `${HOST_BRIDGE_PUBLIC_WEB_ORIGIN}/works/detail?${searchParams.toString()}`;
|
|
}
|
|
|
|
export function normalizeHostBridgeShareOpenPayload(
|
|
value: unknown,
|
|
): HostBridgeSharePayloadNormalization {
|
|
const target = hostBridgeShareTargetPayload(value);
|
|
const payload =
|
|
target && typeof target === 'object'
|
|
? (target as Record<string, unknown>).payload ?? target
|
|
: target;
|
|
|
|
if (!payload || typeof payload !== 'object') {
|
|
return { status: 'empty' };
|
|
}
|
|
|
|
const title = hostBridgeStringField(payload, 'title');
|
|
const message = hostBridgeStringField(payload, 'message');
|
|
const rawDirectUrl =
|
|
hostBridgeStringField(payload, 'url') ??
|
|
hostBridgeStringField(payload, 'href');
|
|
const directUrl = normalizeHostBridgePublicShareUrl(rawDirectUrl);
|
|
if (rawDirectUrl && !directUrl) {
|
|
return { status: 'invalid' };
|
|
}
|
|
|
|
const work = hostBridgeStringField(payload, 'work');
|
|
const rawPath =
|
|
hostBridgeStringField(payload, 'path') ??
|
|
hostBridgeStringField(payload, 'targetPath');
|
|
const pathUrl = normalizeHostBridgePublicShareUrl(rawPath);
|
|
if (rawPath && !pathUrl) {
|
|
return { status: 'invalid' };
|
|
}
|
|
|
|
const url = directUrl ?? (work ? hostBridgeWorkDetailUrl(work) : undefined) ?? pathUrl;
|
|
if (!title && !message && !url) {
|
|
return { status: 'empty' };
|
|
}
|
|
|
|
return {
|
|
status: 'valid',
|
|
payload: {
|
|
...(title ? { title } : {}),
|
|
...(message ? { message } : {}),
|
|
...(url ? { url } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|