新增 HostBridge appearance.getColorScheme 只读契约和 H5 facade Expo 壳通过 React Native Appearance 读取系统配色 Tauri 壳通过主窗口 theme 读取桌面配色 补齐外观查询测试、漂移检查和架构文档
266 lines
5.6 KiB
TypeScript
266 lines
5.6 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',
|
|
'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<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 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;
|
|
}
|