新增 Expo 与 Tauri 原生宿主壳

新增 HostBridge 原生宿主契约和 H5 native_app transport

新增 Expo React Native 移动壳并收紧 WebView 外链边界

新增 Tauri 桌面壳并用 capability 收口受控命令

更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
2026-06-17 21:39:34 +08:00
parent f92e791464
commit 9b7da18879
35 changed files with 16229 additions and 308 deletions

91
apps/mobile-shell/App.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { StatusBar } from 'expo-status-bar';
import { useEffect, useMemo, useRef, useState } from 'react';
import { BackHandler, Linking, Platform, StyleSheet, View } from 'react-native';
import type { WebViewMessageEvent } from 'react-native-webview';
import { WebView } from 'react-native-webview';
import {
handleMobileHostBridgeMessage,
MOBILE_HOST_CAPABILITIES,
} from './src/mobileHostBridge';
import { shouldOpenInMobileShellWebView } from './src/mobileShellNavigation';
import { buildMobileShellUrl } from './src/mobileShellUrl';
const defaultWebUrl = 'http://127.0.0.1:3000/';
export default function App() {
const webViewRef = useRef<WebView>(null);
const [canGoBack, setCanGoBack] = useState(false);
const webUrl = useMemo(
() =>
buildMobileShellUrl(
process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL || defaultWebUrl,
{
platform: Platform.OS === 'ios' ? 'ios' : 'android',
hostVersion: '0.1.0',
capabilities: MOBILE_HOST_CAPABILITIES,
},
),
[],
);
const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]);
useEffect(() => {
const subscription = BackHandler.addEventListener(
'hardwareBackPress',
() => {
if (!canGoBack) {
return false;
}
webViewRef.current?.goBack();
return true;
},
);
return () => subscription.remove();
}, [canGoBack]);
const handleMessage = (event: WebViewMessageEvent) => {
void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => {
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
JSON.stringify(response),
)} })); true;`,
);
});
};
const handleShouldStartLoad = (request: { url: string }) => {
if (shouldOpenInMobileShellWebView(request.url, allowedWebOrigin)) {
return true;
}
void Linking.openURL(request.url).catch(() => undefined);
return false;
};
return (
<View style={styles.root}>
<StatusBar style="auto" />
<WebView
ref={webViewRef}
source={{ uri: webUrl }}
javaScriptEnabled
domStorageEnabled
originWhitelist={[allowedWebOrigin]}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
onNavigationStateChange={(event) => setCanGoBack(event.canGoBack)}
setSupportMultipleWindows={false}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: '#fffdf9',
},
});

View File

@@ -0,0 +1,22 @@
{
"expo": {
"name": "Genarrative",
"slug": "genarrative-mobile-shell",
"scheme": "genarrative",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"package": "world.genarrative.mobile"
},
"extra": {
"genarrativeHostBridgeVersion": 1
}
}
}

View File

@@ -0,0 +1,29 @@
{
"name": "@genarrative/mobile-shell",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "expo/AppEntry.js",
"scripts": {
"dev": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@expo/metro-runtime": "^56.0.15",
"expo": "^56.0.12",
"expo-clipboard": "^56.0.4",
"expo-haptics": "^56.0.3",
"expo-linking": "^56.0.14",
"expo-status-bar": "^56.0.4",
"react": "^19.0.0",
"react-native": "^0.86.0",
"react-native-webview": "^13.16.1"
},
"devDependencies": {
"typescript": "~5.8.2",
"vitest": "^0.34.6"
}
}

5
apps/mobile-shell/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare const process: {
env: {
EXPO_PUBLIC_GENARRATIVE_WEB_URL?: string;
};
};

View File

@@ -0,0 +1,213 @@
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import { Platform, Share } from 'react-native';
import {
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeCapability,
type HostBridgeError,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
type OpenExternalUrlPayload,
type ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
'share.open',
'share.setTarget',
'app.openExternalUrl',
'clipboard.writeText',
'haptics.impact',
];
let currentShareTarget: unknown = null;
function unsupported(method: HostBridgeMethod): HostBridgeError {
return {
code: 'unsupported_method',
message: `${method} unsupported in mobile shell`,
};
}
function invalidRequest(message: string): HostBridgeError {
return {
code: 'invalid_request',
message,
};
}
function isHostBridgeRequest(value: unknown): value is HostBridgeRequest {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<HostBridgeRequest>;
return (
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
candidate.version === HOST_BRIDGE_VERSION &&
typeof candidate.id === 'string' &&
typeof candidate.method === 'string'
);
}
function parseRequest(raw: string) {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
function ok<Result>(
request: HostBridgeRequest,
result?: Result,
): HostBridgeResponse<Result> {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result,
};
}
function failure(
request: Pick<HostBridgeRequest, 'id'>,
error: HostBridgeError,
): HostBridgeResponse {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: false,
error,
};
}
async function openExternalUrl(payload: unknown) {
const url = (payload as OpenExternalUrlPayload | undefined)?.url;
if (!url) {
throw invalidRequest('url is required');
}
await Linking.openURL(url);
return true;
}
async function writeClipboard(payload: unknown) {
const text = (payload as ClipboardWriteTextPayload | undefined)?.text;
if (typeof text !== 'string') {
throw invalidRequest('text is required');
}
await Clipboard.setStringAsync(text);
return true;
}
async function runHaptics(payload: unknown) {
const style = (payload as HapticsImpactPayload | undefined)?.style;
const impactStyle =
style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: style === 'medium'
? Haptics.ImpactFeedbackStyle.Medium
: Haptics.ImpactFeedbackStyle.Light;
await Haptics.impactAsync(impactStyle);
return true;
}
async function openShare(payload: unknown) {
const sharePayload =
payload && typeof payload === 'object'
? (payload as ShareOpenPayload)
: currentShareTarget && typeof currentShareTarget === 'object'
? (currentShareTarget as ShareOpenPayload)
: undefined;
const url = sharePayload?.url;
const message = [sharePayload?.message, url].filter(Boolean).join('\n');
await Share.share({
title: sharePayload?.title,
message: message || url || sharePayload?.title || '',
url,
});
return true;
}
async function handleRequest(request: HostBridgeRequest) {
switch (request.method) {
case 'host.getRuntime':
return ok(request, {
shell: 'expo_mobile',
platform: Platform.OS === 'ios' ? 'ios' : 'android',
hostVersion: '0.1.0',
bridgeVersion: HOST_BRIDGE_VERSION,
capabilities: MOBILE_HOST_CAPABILITIES,
});
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
case 'haptics.impact':
return ok(request, await runHaptics(request.payload));
case 'share.open':
return ok(request, await openShare(request.payload));
case 'share.setTarget':
currentShareTarget =
request.payload && typeof request.payload === 'object'
? (request.payload as { target?: unknown }).target
: null;
return ok(request, true);
case 'auth.requestLogin':
case 'payment.request':
case 'navigation.openNativePage':
return failure(request, unsupported(request.method));
default:
return failure(request, unsupported(request.method));
}
}
function normalizeError(error: unknown): HostBridgeError {
if (
error &&
typeof error === 'object' &&
'code' in error &&
'message' in error
) {
return error as HostBridgeError;
}
return {
code: 'host_error',
message: error instanceof Error ? error.message : String(error),
};
}
export async function handleMobileHostBridgeMessage(
rawMessage: string,
sendResponse: (response: HostBridgeResponse) => void,
) {
const parsed = parseRequest(rawMessage);
if (!isHostBridgeRequest(parsed)) {
sendResponse(
failure(
{ id: 'invalid' },
invalidRequest('invalid host bridge request'),
),
);
return;
}
try {
sendResponse(await handleRequest(parsed));
} catch (error) {
sendResponse(failure(parsed, normalizeError(error)));
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { shouldOpenInMobileShellWebView } from './mobileShellNavigation';
describe('shouldOpenInMobileShellWebView', () => {
test('只允许主站同源页面留在移动壳 WebView 内', () => {
const allowedOrigin = 'https://app.genarrative.world';
expect(
shouldOpenInMobileShellWebView(
'https://app.genarrative.world/works/detail?work=PZ-1',
allowedOrigin,
),
).toBe(true);
expect(
shouldOpenInMobileShellWebView('/creation/puzzle', allowedOrigin),
).toBe(true);
expect(
shouldOpenInMobileShellWebView('about:blank', allowedOrigin),
).toBe(true);
});
test('外链和非网页协议必须离开带 HostBridge 的 WebView', () => {
const allowedOrigin = 'https://app.genarrative.world';
expect(
shouldOpenInMobileShellWebView('https://example.com/', allowedOrigin),
).toBe(false);
expect(
shouldOpenInMobileShellWebView('mailto:hi@example.com', allowedOrigin),
).toBe(false);
expect(shouldOpenInMobileShellWebView('not a url', allowedOrigin)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,27 @@
export function shouldOpenInMobileShellWebView(
rawUrl: string,
allowedOrigin: string,
) {
if (rawUrl === 'about:blank') {
return true;
}
if (
!rawUrl.startsWith('/') &&
!rawUrl.startsWith('#') &&
!/^[a-z][a-z0-9+.-]*:/i.test(rawUrl)
) {
return false;
}
try {
const url = new URL(rawUrl, allowedOrigin);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
return url.origin === allowedOrigin;
} catch {
return false;
}
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest';
import { buildMobileShellUrl } from './mobileShellUrl';
describe('buildMobileShellUrl', () => {
test('为 H5 附加原生移动壳上下文', () => {
const url = new URL(
buildMobileShellUrl('https://app.test/works/detail?work=PZ-1', {
platform: 'ios',
hostVersion: '0.1.0',
capabilities: ['host.getRuntime', 'share.open'],
}),
);
expect(url.searchParams.get('clientRuntime')).toBe('native_app');
expect(url.searchParams.get('clientType')).toBe('native_app');
expect(url.searchParams.get('hostShell')).toBe('expo_mobile');
expect(url.searchParams.get('hostPlatform')).toBe('ios');
expect(url.searchParams.get('hostVersion')).toBe('0.1.0');
expect(url.searchParams.get('bridgeVersion')).toBe('1');
expect(url.searchParams.get('hostCapabilities')).toBe(
'host.getRuntime,share.open',
);
expect(url.searchParams.get('work')).toBe('PZ-1');
});
});

View File

@@ -0,0 +1,25 @@
import type {
HostBridgeCapability,
NativeHostPlatform,
} from '../../../packages/shared/src/contracts/hostBridge';
export type MobileShellUrlOptions = {
platform: Extract<NativeHostPlatform, 'ios' | 'android'>;
hostVersion: string;
capabilities: HostBridgeCapability[];
};
export function buildMobileShellUrl(
rawUrl: string,
options: MobileShellUrlOptions,
) {
const url = new URL(rawUrl);
url.searchParams.set('clientRuntime', 'native_app');
url.searchParams.set('clientType', 'native_app');
url.searchParams.set('hostShell', 'expo_mobile');
url.searchParams.set('hostPlatform', options.platform);
url.searchParams.set('hostVersion', options.hostVersion);
url.searchParams.set('bridgeVersion', '1');
url.searchParams.set('hostCapabilities', options.capabilities.join(','));
return url.toString();
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"jsx": "react-jsx",
"strict": true,
"types": ["react-native"]
},
"include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});