新增 Expo 与 Tauri 原生宿主壳
新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
19
apps/desktop-shell/package.json
Normal file
19
apps/desktop-shell/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@genarrative/desktop-shell",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"typecheck": "node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"typescript": "~5.8.2"
|
||||
}
|
||||
}
|
||||
64
apps/desktop-shell/scripts/check-config.mjs
Normal file
64
apps/desktop-shell/scripts/check-config.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
const configPath = new URL('../src-tauri/tauri.conf.json', import.meta.url);
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const capabilityPath = new URL(
|
||||
'../src-tauri/capabilities/main.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
|
||||
const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url);
|
||||
const buildScript = fs.readFileSync(buildScriptPath, 'utf8');
|
||||
|
||||
if (config.build?.frontendDist !== '../../../dist') {
|
||||
throw new Error('desktop shell must package the root H5 dist');
|
||||
}
|
||||
|
||||
if (config.build?.beforeBuildCommand !== 'npm --prefix ../.. run build:raw && npm run typecheck') {
|
||||
throw new Error('desktop shell build command must run from apps/desktop-shell');
|
||||
}
|
||||
|
||||
const [mainWindow] = config.app?.windows ?? [];
|
||||
if (!mainWindow || mainWindow.create !== false) {
|
||||
throw new Error('desktop shell must create the main window from Rust setup');
|
||||
}
|
||||
|
||||
const requiredUrlParts = [
|
||||
'clientRuntime=native_app',
|
||||
'clientType=native_app',
|
||||
'hostShell=tauri_desktop',
|
||||
'hostPlatform=unknown',
|
||||
'bridgeVersion=1',
|
||||
'hostCapabilities=host.getRuntime,app.openExternalUrl',
|
||||
];
|
||||
|
||||
for (const part of requiredUrlParts) {
|
||||
if (!String(mainWindow.url ?? '').includes(part)) {
|
||||
throw new Error(`desktop shell main window URL missing ${part}`);
|
||||
}
|
||||
if (!String(config.build?.devUrl ?? '').includes(part)) {
|
||||
throw new Error(`desktop shell dev URL missing ${part}`);
|
||||
}
|
||||
}
|
||||
|
||||
const requiredPermissions = [
|
||||
'core:default',
|
||||
'allow-host-bridge-request',
|
||||
];
|
||||
const requiredBuildCommands = ['host_bridge_request'];
|
||||
|
||||
for (const permission of requiredPermissions) {
|
||||
if (!capability.permissions?.includes(permission)) {
|
||||
throw new Error(`desktop shell capability missing ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const command of requiredBuildCommands) {
|
||||
if (!buildScript.includes(command)) {
|
||||
throw new Error(`desktop shell build manifest missing ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (buildScript.includes('resolve_desktop_shell_runtime')) {
|
||||
throw new Error('desktop shell build manifest exposes an unused runtime command');
|
||||
}
|
||||
5079
apps/desktop-shell/src-tauri/Cargo.lock
generated
Normal file
5079
apps/desktop-shell/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
apps/desktop-shell/src-tauri/Cargo.toml
Normal file
14
apps/desktop-shell/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "genarrative-desktop-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
5
apps/desktop-shell/src-tauri/build.rs
Normal file
5
apps/desktop-shell/src-tauri/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
let app_manifest = tauri_build::AppManifest::new().commands(&["host_bridge_request"]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(app_manifest))
|
||||
.expect("failed to run Tauri build script");
|
||||
}
|
||||
10
apps/desktop-shell/src-tauri/capabilities/main.json
Normal file
10
apps/desktop-shell/src-tauri/capabilities/main.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"allow-host-bridge-request"
|
||||
]
|
||||
}
|
||||
213
apps/desktop-shell/src-tauri/src/main.rs
Normal file
213
apps/desktop-shell/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
|
||||
const HOST_BRIDGE_VERSION: u8 = 1;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HostBridgeRequest {
|
||||
bridge: String,
|
||||
version: u8,
|
||||
id: String,
|
||||
method: String,
|
||||
payload: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HostBridgeRuntime {
|
||||
shell: &'static str,
|
||||
platform: &'static str,
|
||||
host_version: &'static str,
|
||||
bridge_version: u8,
|
||||
capabilities: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HostBridgeError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HostBridgeResponse {
|
||||
bridge: &'static str,
|
||||
version: u8,
|
||||
id: String,
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<HostBridgeError>,
|
||||
}
|
||||
|
||||
fn desktop_platform() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn capabilities() -> Vec<&'static str> {
|
||||
vec!["host.getRuntime", "app.openExternalUrl"]
|
||||
}
|
||||
|
||||
fn ok(id: String, result: Value) -> HostBridgeResponse {
|
||||
HostBridgeResponse {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id,
|
||||
ok: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(id: String, code: &'static str, message: impl Into<String>) -> HostBridgeResponse {
|
||||
HostBridgeResponse {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id,
|
||||
ok: false,
|
||||
result: None,
|
||||
error: Some(HostBridgeError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_request(request: &HostBridgeRequest) -> Option<HostBridgeResponse> {
|
||||
if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION {
|
||||
return Some(failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"invalid host bridge envelope",
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse {
|
||||
if let Some(response) = validate_request(&request) {
|
||||
return response;
|
||||
}
|
||||
|
||||
match request.method.as_str() {
|
||||
"host.getRuntime" => ok(
|
||||
request.id,
|
||||
json!(HostBridgeRuntime {
|
||||
shell: "tauri_desktop",
|
||||
platform: desktop_platform(),
|
||||
host_version: env!("CARGO_PKG_VERSION"),
|
||||
bridge_version: HOST_BRIDGE_VERSION,
|
||||
capabilities: capabilities(),
|
||||
}),
|
||||
),
|
||||
_ => failed(
|
||||
request.id,
|
||||
"unsupported_method",
|
||||
format!("{} unsupported in desktop shell", request.method),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn host_bridge_request(
|
||||
app: tauri::AppHandle,
|
||||
request: HostBridgeRequest,
|
||||
) -> HostBridgeResponse {
|
||||
if let Some(response) = validate_request(&request) {
|
||||
return response;
|
||||
}
|
||||
|
||||
match request.method.as_str() {
|
||||
"app.openExternalUrl" => {
|
||||
let Some(url) = request
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
return failed(request.id, "invalid_request", "url is required");
|
||||
};
|
||||
|
||||
match app.opener().open_url(url, None::<&str>) {
|
||||
Ok(()) => ok(request.id, json!(true)),
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
}
|
||||
}
|
||||
_ => resolve_host_bridge_request(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let window_config = app.config().app.windows.get(0).cloned();
|
||||
if let Some(config) = window_config {
|
||||
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![host_bridge_request])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run Genarrative desktop shell");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(method: &str) -> HostBridgeRequest {
|
||||
HostBridgeRequest {
|
||||
bridge: HOST_BRIDGE_PROTOCOL.to_string(),
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: "request-1".to_string(),
|
||||
method: method.to_string(),
|
||||
payload: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_response_reports_tauri_shell() {
|
||||
let response = resolve_host_bridge_request(request("host.getRuntime"));
|
||||
|
||||
assert!(response.ok);
|
||||
let result = response.result.expect("runtime result");
|
||||
assert_eq!(result["shell"], "tauri_desktop");
|
||||
assert_eq!(result["bridgeVersion"], HOST_BRIDGE_VERSION);
|
||||
assert_eq!(result["capabilities"], json!(capabilities()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_method_is_explicit() {
|
||||
let response = resolve_host_bridge_request(request("payment.request"));
|
||||
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("error");
|
||||
assert_eq!(error.code, "unsupported_method");
|
||||
assert!(error.message.contains("payment.request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_envelope_is_rejected() {
|
||||
let mut invalid = request("host.getRuntime");
|
||||
invalid.bridge = "OtherBridge".to_string();
|
||||
|
||||
let response = resolve_host_bridge_request(invalid);
|
||||
|
||||
assert!(!response.ok);
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
}
|
||||
}
|
||||
34
apps/desktop-shell/src-tauri/tauri.conf.json
Normal file
34
apps/desktop-shell/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Genarrative",
|
||||
"version": "0.1.0",
|
||||
"identifier": "world.genarrative.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run dev:web",
|
||||
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
|
||||
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,app.openExternalUrl",
|
||||
"frontendDist": "../../../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"label": "main",
|
||||
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,app.openExternalUrl",
|
||||
"title": "Genarrative",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* https: data: blob:; media-src 'self' asset: http://127.0.0.1:* https: data: blob:; connect-src 'self' http://127.0.0.1:* https: ws://127.0.0.1:* wss:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
}
|
||||
}
|
||||
9
apps/desktop-shell/tsconfig.json
Normal file
9
apps/desktop-shell/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": []
|
||||
}
|
||||
91
apps/mobile-shell/App.tsx
Normal file
91
apps/mobile-shell/App.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
22
apps/mobile-shell/app.json
Normal file
22
apps/mobile-shell/app.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
apps/mobile-shell/package.json
Normal file
29
apps/mobile-shell/package.json
Normal 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
5
apps/mobile-shell/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare const process: {
|
||||
env: {
|
||||
EXPO_PUBLIC_GENARRATIVE_WEB_URL?: string;
|
||||
};
|
||||
};
|
||||
213
apps/mobile-shell/src/mobileHostBridge.ts
Normal file
213
apps/mobile-shell/src/mobileHostBridge.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
36
apps/mobile-shell/src/mobileShellNavigation.test.ts
Normal file
36
apps/mobile-shell/src/mobileShellNavigation.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
27
apps/mobile-shell/src/mobileShellNavigation.ts
Normal file
27
apps/mobile-shell/src/mobileShellNavigation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
apps/mobile-shell/src/mobileShellUrl.test.ts
Normal file
26
apps/mobile-shell/src/mobileShellUrl.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
apps/mobile-shell/src/mobileShellUrl.ts
Normal file
25
apps/mobile-shell/src/mobileShellUrl.ts
Normal 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();
|
||||
}
|
||||
10
apps/mobile-shell/tsconfig.json
Normal file
10
apps/mobile-shell/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
apps/mobile-shell/vitest.config.ts
Normal file
8
apps/mobile-shell/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user