新增 Expo 与 Tauri 原生宿主壳
新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,6 +28,9 @@ temp-build-goal-check/
|
|||||||
/public/generated-custom-world-scenes
|
/public/generated-custom-world-scenes
|
||||||
temp*build*/
|
temp*build*/
|
||||||
/server-rs/target/
|
/server-rs/target/
|
||||||
|
/apps/desktop-shell/src-tauri/target/
|
||||||
|
/apps/desktop-shell/src-tauri/gen/
|
||||||
|
/apps/desktop-shell/src-tauri/permissions/autogenerated/
|
||||||
/server-rs/.spacetimedb/
|
/server-rs/.spacetimedb/
|
||||||
/server-rs/.data/
|
/server-rs/.data/
|
||||||
/public/generated-animations
|
/public/generated-animations
|
||||||
|
|||||||
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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
|
|
||||||
微信小程序壳、未来原生 App 壳、固定内置玩法与 AI H5 沙箱之间的宿主能力边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./【前端架构】宿主壳能力统一协议-2026-06-17.md)。
|
微信小程序壳、未来原生 App 壳、固定内置玩法与 AI H5 沙箱之间的宿主能力边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./【前端架构】宿主壳能力统一协议-2026-06-17.md)。
|
||||||
|
|
||||||
|
移动端壳采用 Expo + React Native、桌面端壳采用 Tauri,并统一作为 HostBridge adapter 的分阶段方案见 [【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md)。
|
||||||
|
当前首轮工程入口:`npm run mobile-shell:dev`、`npm run mobile-shell:test`、`npm run desktop-shell:dev`、`npm run desktop-shell:test`。
|
||||||
|
|
||||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||||
|
|
||||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-17 原生移动与桌面壳统一作为 HostBridge Adapter
|
||||||
|
|
||||||
|
- 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。
|
||||||
|
- 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。
|
||||||
|
- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。首轮壳只声明并实现真实可用能力;登录、支付、桌面剪贴板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。
|
||||||
|
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
||||||
|
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
||||||
|
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||||
|
|
||||||
## 2026-06-17 H5 宿主壳能力统一走 HostBridge
|
## 2026-06-17 H5 宿主壳能力统一走 HostBridge
|
||||||
|
|
||||||
- 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。
|
- 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
|
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
|
||||||
| 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` |
|
| 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` |
|
||||||
| 创作流程统一阶段计划 | `docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md` |
|
| 创作流程统一阶段计划 | `docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md` |
|
||||||
|
| 宿主壳、移动 App、桌面 App 与 AI H5 沙箱边界 | `docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`、`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md` |
|
||||||
| 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` |
|
| 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` |
|
||||||
| 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` |
|
| 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` |
|
||||||
| UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` |
|
| UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` |
|
||||||
|
|||||||
290
docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md
Normal file
290
docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Expo React Native 与 Tauri 宿主壳方案
|
||||||
|
|
||||||
|
更新时间:`2026-06-17`
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
移动端壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 Genarrative H5 主站的宿主壳,不重写现有 React 主站,不把固定玩法 runtime 迁到 React Native 或 Rust UI,也不让 AI 生成 H5 游戏直接拿完整宿主能力。
|
||||||
|
|
||||||
|
固定内置玩法继续跑在现有 H5 runtime 内;移动端和桌面端通过 `HostBridge` 提供登录、支付、分享、原生页跳转、系统能力和宿主事件。AI 生成 H5 游戏继续放进独立 sandbox,只能通过受限 `GameBridge` 请求允许的能力。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 主站 H5 仍是唯一的产品体验主线,网页、小程序、移动 App 和桌面 App 共享同一套业务页面、玩法 runtime 和后端契约。
|
||||||
|
2. 移动端用 Expo / React Native 承接 App 外壳、WebView、深链、推送、分享、支付 SDK、系统权限和少量原生页面。
|
||||||
|
3. 桌面端用 Tauri 承接安装包、系统菜单、窗口、文件/剪贴板/外部浏览器等桌面能力,并用 Tauri capabilities 收窄前端可调用的命令。
|
||||||
|
4. H5 业务层只面向 `HostBridge` 能力,不直接判断 Expo、React Native、Tauri、iOS、Android 或桌面平台。
|
||||||
|
5. 壳层能力按能力白名单逐项开放,不提供“任意 native command”或“任意系统 API”透传。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不把现有 React H5 主站整体迁到 React Native。
|
||||||
|
- 不用 Tauri 重写桌面 UI,也不引入第二套桌面业务前端。
|
||||||
|
- 不让固定玩法通过远程代码包下载流程启动。
|
||||||
|
- 不让 AI 生成 H5 游戏直接访问登录、支付、token、完整用户资料、系统文件或宿主私有 API。
|
||||||
|
- 不在第一阶段解决 App Store / 应用商店全部上架材料,只先固定工程边界和验证路线。
|
||||||
|
|
||||||
|
## 总体架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
现有 React H5 主站
|
||||||
|
-> HostBridge
|
||||||
|
-> browser adapter
|
||||||
|
-> wechat mini program adapter
|
||||||
|
-> native app adapter
|
||||||
|
-> Expo React Native shell
|
||||||
|
-> Tauri shell
|
||||||
|
|
||||||
|
AI 生成 H5 游戏 iframe
|
||||||
|
-> GameBridge
|
||||||
|
-> H5 parent runtime
|
||||||
|
-> HostBridge 能力子集或后端 API
|
||||||
|
```
|
||||||
|
|
||||||
|
核心原则:`HostBridge` 是 H5 与宿主壳之间唯一的通用协议;`GameBridge` 是 AI 游戏 sandbox 与父页面之间的受限协议。两个协议不能合并。
|
||||||
|
|
||||||
|
## 工程布局建议
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
mobile-shell/ # Expo + React Native App 壳
|
||||||
|
desktop-shell/ # Tauri 桌面 App 壳
|
||||||
|
packages/
|
||||||
|
shared/
|
||||||
|
src/contracts/
|
||||||
|
hostBridge.ts # HostBridge 消息契约,供 H5 / RN / Tauri 对齐
|
||||||
|
src/
|
||||||
|
services/host-bridge/
|
||||||
|
hostBridge.ts
|
||||||
|
nativeAppHostBridge.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。
|
||||||
|
|
||||||
|
## HostBridge 消息协议
|
||||||
|
|
||||||
|
H5 进入原生 App 壳时由壳层附加稳定 query:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?clientRuntime=native_app
|
||||||
|
&clientType=native_app
|
||||||
|
&hostShell=expo_mobile|tauri_desktop
|
||||||
|
&hostPlatform=ios|android|macos|windows|linux
|
||||||
|
&hostVersion=0.1.0
|
||||||
|
&bridgeVersion=1
|
||||||
|
```
|
||||||
|
|
||||||
|
消息 envelope 统一为 JSON:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type HostBridgeRequest = {
|
||||||
|
bridge: 'GenarrativeHostBridge';
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
method: HostBridgeMethod;
|
||||||
|
payload?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HostBridgeResponse = {
|
||||||
|
bridge: 'GenarrativeHostBridge';
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
ok: boolean;
|
||||||
|
result?: unknown;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type HostBridgeEvent = {
|
||||||
|
bridge: 'GenarrativeHostBridge';
|
||||||
|
version: 1;
|
||||||
|
event: string;
|
||||||
|
payload?: unknown;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
首批 method:
|
||||||
|
|
||||||
|
| method | 用途 | Expo 壳 | Tauri 壳 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `host.getRuntime` | 返回宿主、平台、版本和能力清单 | 支持 | 支持 |
|
||||||
|
| `auth.requestLogin` | 打开宿主登录或账号绑定流程 | 支持 | 可先返回不支持 |
|
||||||
|
| `payment.request` | 发起宿主支付 | 依平台策略接入 | 桌面二维码 / 外部浏览器 |
|
||||||
|
| `share.setTarget` | 同步当前作品分享目标 | 支持 | 未接入前不声明 |
|
||||||
|
| `share.open` | 打开系统分享面板 | 支持 | 可先复制链接 / 打开系统分享 |
|
||||||
|
| `navigation.openNativePage` | 打开受控原生页面 | 支持 | 支持设置 / 关于等 |
|
||||||
|
| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持 | 支持 |
|
||||||
|
| `clipboard.writeText` | 写剪贴板 | 可选 | 可选 |
|
||||||
|
| `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 |
|
||||||
|
|
||||||
|
每个 method 都必须有明确 payload schema、超时、错误码和能力开关;H5 看到不支持时回退到现有浏览器路径。
|
||||||
|
|
||||||
|
## Expo React Native 壳
|
||||||
|
|
||||||
|
Expo 壳只负责 App 外壳和原生能力,不承接玩法业务。
|
||||||
|
|
||||||
|
推荐能力:
|
||||||
|
|
||||||
|
- 用 `react-native-webview` 加载 Genarrative H5。
|
||||||
|
- H5 到 RN:`window.ReactNativeWebView.postMessage(JSON.stringify(request))`。
|
||||||
|
- RN 到 H5:通过 WebView ref 注入脚本,向 H5 派发统一 bridge response / event。
|
||||||
|
- 使用 development build,不依赖 Expo Go 作为真实集成环境;需要自定义原生配置时用 config plugin / prebuild 管理。
|
||||||
|
- App 壳维护启动页、深链、系统分享、推送、权限、App 版本、崩溃日志和支付 SDK。
|
||||||
|
- 登录首期优先复用 H5 账号体系;后续再逐项接入 Apple / Android / 微信等原生登录能力。
|
||||||
|
- 支付必须按上架渠道拆分:iOS / Android 虚拟内容优先评估 IAP / Google Play Billing 或国内渠道要求;H5 支付、小程序虚拟支付和桌面二维码支付不能直接照搬到 App Store 包。
|
||||||
|
|
||||||
|
移动端推荐首屏流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Expo App 启动
|
||||||
|
-> 读取环境和远端 H5 URL
|
||||||
|
-> WebView 加载 /?clientRuntime=native_app&hostShell=expo_mobile...
|
||||||
|
-> H5 getHostRuntime() 识别 native_app
|
||||||
|
-> H5 通过 HostBridge 请求宿主能力
|
||||||
|
-> RN 壳按 allowlist 执行并回包
|
||||||
|
```
|
||||||
|
|
||||||
|
第一版移动端不建议做大量 RN 原生 Tab / 页面。当前 H5 已有移动端一级 Tab,重复实现会带来导航状态、登录态、返回栈和 UI 双维护成本。
|
||||||
|
|
||||||
|
## Tauri 桌面壳
|
||||||
|
|
||||||
|
Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。
|
||||||
|
|
||||||
|
推荐能力:
|
||||||
|
|
||||||
|
- Release 包默认打包当前 H5 `dist`,保证桌面包版本可复现。
|
||||||
|
- Dev 模式允许加载本地 Vite URL,方便调试。
|
||||||
|
- H5 通过 `window.__TAURI__.core.invoke('host_bridge_request', request)` 或后续封装的 `nativeAppHostBridge` 调用桌面能力。
|
||||||
|
- Rust 侧只暴露一个受控 `host_bridge_request` command,再在 Rust 内部按 method 白名单分发。
|
||||||
|
- Tauri capabilities 只授予主窗口所需命令;默认不开放文件系统、shell、全局剪贴板或任意插件能力。
|
||||||
|
- 桌面支付首期走现有 H5 / 二维码 / 外部浏览器路径,不在 Rust 侧保存支付凭据。
|
||||||
|
- 文件导出、作品卡保存、图片拖拽导入、系统托盘、自动更新等桌面能力按后续需求逐项开放。
|
||||||
|
|
||||||
|
桌面 release 和 dev 模式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
release:
|
||||||
|
Tauri binary
|
||||||
|
-> packaged web assets
|
||||||
|
-> /index.html?clientRuntime=native_app&hostShell=tauri_desktop...
|
||||||
|
|
||||||
|
dev:
|
||||||
|
Tauri binary
|
||||||
|
-> http://127.0.0.1:<vite-port>/?clientRuntime=native_app&hostShell=tauri_desktop...
|
||||||
|
```
|
||||||
|
|
||||||
|
如果未来希望桌面端加载远端 H5 URL,必须额外做 origin allowlist、版本协商和 Tauri API 暴露限制;不能让任意远端页面拿到桌面命令。
|
||||||
|
|
||||||
|
## AI H5 沙箱边界
|
||||||
|
|
||||||
|
移动端和桌面端统一后,AI 生成 H5 游戏仍不能直接接入 `HostBridge`。
|
||||||
|
|
||||||
|
AI H5 游戏运行结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台 H5 runtime
|
||||||
|
-> sandbox iframe
|
||||||
|
-> AI 生成 H5 游戏
|
||||||
|
-> window.parent.postMessage(GameBridgeRequest)
|
||||||
|
```
|
||||||
|
|
||||||
|
GameBridge 只允许:
|
||||||
|
|
||||||
|
- 读取启动参数和只读资产 URL。
|
||||||
|
- 上报 ready、progress、score、event、error。
|
||||||
|
- 提交候选结果给父页面,由父页面和后端裁决。
|
||||||
|
- 请求有限的音频、震动、全屏等运行态能力。
|
||||||
|
|
||||||
|
GameBridge 禁止:
|
||||||
|
|
||||||
|
- 登录、支付、订阅授权。
|
||||||
|
- 读取 token、cookie、完整用户资料。
|
||||||
|
- 任意网络代理。
|
||||||
|
- 任意本地文件、剪贴板和系统命令。
|
||||||
|
- 直接调用 Expo / Tauri / 小程序宿主能力。
|
||||||
|
|
||||||
|
## 安全约束
|
||||||
|
|
||||||
|
- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。
|
||||||
|
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
||||||
|
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
|
||||||
|
- 能力按 `capabilities` 下发,H5 根据能力决定是否展示入口或走 fallback。
|
||||||
|
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
|
||||||
|
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
|
||||||
|
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链应使用系统浏览器或降级能力。
|
||||||
|
- AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。
|
||||||
|
|
||||||
|
## 分阶段落地
|
||||||
|
|
||||||
|
### Phase 1:补齐 H5 native_app adapter
|
||||||
|
|
||||||
|
- 在 `src/services/host-bridge/` 增加 `nativeAppHostBridge` transport。
|
||||||
|
- 定义 HostBridge envelope、method、错误码和超时策略。
|
||||||
|
- `getHostRuntime()` 继续识别 `clientRuntime=native_app`。
|
||||||
|
- 现有业务入口只通过 HostBridge 调用登录、支付、分享、原生页跳转。
|
||||||
|
- 增加 H5 单测覆盖:支持、超时、不支持、错误回包、浏览器 fallback。
|
||||||
|
|
||||||
|
当前状态:已新增 `src/services/host-bridge/nativeAppHostBridge.ts`,支持 React Native WebView `postMessage` 和 Tauri `invoke('host_bridge_request')` 两种真实 transport。登录、支付和原生页跳转如果宿主明确返回 `unsupported_method` / `unsupported_capability`,H5 回退到原有路径;生产代码不返回 mock 成功。
|
||||||
|
|
||||||
|
### Phase 2:Expo 移动壳 MVP
|
||||||
|
|
||||||
|
- 新增 `apps/mobile-shell/`。
|
||||||
|
- 接入 `react-native-webview`,加载 H5 URL 并附加宿主 query。
|
||||||
|
- 实现 HostBridge RN transport:runtime、openExternalUrl、share、clipboard、haptics。
|
||||||
|
- Android 返回键与 H5 history 对齐。
|
||||||
|
- iOS / Android 深链打开作品详情、创作页和邀请码。
|
||||||
|
- 登录和支付先 fallback 到 H5;只把能力边界跑通。
|
||||||
|
|
||||||
|
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。首轮真实能力包括 `host.getRuntime`、`share.open`、`share.setTarget`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;登录、支付和原生页跳转尚未接入渠道 SDK 时明确返回 unsupported,让 H5 fallback 承接。
|
||||||
|
|
||||||
|
### Phase 3:Tauri 桌面壳 MVP
|
||||||
|
|
||||||
|
- 新增 `apps/desktop-shell/`。
|
||||||
|
- 配置 Tauri dev / release web asset 加载。
|
||||||
|
- Rust 暴露 `host_bridge_request` command。
|
||||||
|
- capabilities 只开放该 command 和必要窗口能力。
|
||||||
|
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
|
||||||
|
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
|
||||||
|
|
||||||
|
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行,不把 opener 插件直接暴露给前端;首轮真实能力为 `host.getRuntime` 和 `app.openExternalUrl`。剪贴板、分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
||||||
|
|
||||||
|
### Phase 4:宿主能力扩展
|
||||||
|
|
||||||
|
- 移动端接入系统分享、推送、原生登录和渠道支付。
|
||||||
|
- 桌面端接入自动更新、文件导出、图片拖拽导入和系统托盘。
|
||||||
|
- 所有新增能力先更新 HostBridge 契约和测试,再落壳实现。
|
||||||
|
|
||||||
|
### Phase 5:AI H5 sandbox
|
||||||
|
|
||||||
|
- 定义 `GameBridge` 契约。
|
||||||
|
- 生成代码包只进入 sandbox iframe。
|
||||||
|
- 父页面负责资产授权、事件转发和后端裁决。
|
||||||
|
- Expo / Tauri 壳只感知父页面 HostBridge,不直接感知 AI 游戏代码。
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- 普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`。
|
||||||
|
- 未支持的宿主能力不会阻断主流程,H5 fallback 可用。
|
||||||
|
- 固定玩法在四类宿主里读取同一作品数据和运行态 snapshot,不走代码包下载。
|
||||||
|
- 支付、登录、分享都有幂等、超时和错误回包。
|
||||||
|
- AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。
|
||||||
|
- Tauri release 包不允许任意远端页面调用桌面命令。
|
||||||
|
- Expo WebView 外链离开主站后不保留完整 HostBridge。
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- Expo development builds:`https://docs.expo.dev/develop/development-builds/introduction/`
|
||||||
|
- Expo custom native code:`https://docs.expo.dev/workflow/customizing/`
|
||||||
|
- Expo config plugins:`https://docs.expo.dev/config-plugins/introduction/`
|
||||||
|
- React Native WebView guide:`https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md`
|
||||||
|
- Tauri commands:`https://v2.tauri.app/develop/calling-rust/`
|
||||||
|
- Tauri capabilities:`https://v2.tauri.app/security/capabilities/`
|
||||||
|
- Tauri permissions:`https://v2.tauri.app/security/permissions/`
|
||||||
|
|
||||||
|
## 关联文档
|
||||||
|
|
||||||
|
- `docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`
|
||||||
|
- `src/services/host-bridge/hostBridge.ts`
|
||||||
@@ -64,5 +64,6 @@ AI H5 sandbox
|
|||||||
## 后续
|
## 后续
|
||||||
|
|
||||||
- 设计 `native_app` 的 `postMessage` 消息格式和回包超时策略。
|
- 设计 `native_app` 的 `postMessage` 消息格式和回包超时策略。
|
||||||
|
- 原生 App 壳的移动端采用 `Expo + React Native`,桌面端采用 `Tauri`;壳层只作为 HostBridge adapter,不重写现有 H5 主站和固定玩法 runtime。详细方案见 `docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`。
|
||||||
- 为 AI H5 sandbox 单独定义 GameBridge,禁止直接依赖 HostBridge。
|
- 为 AI H5 sandbox 单独定义 GameBridge,禁止直接依赖 HostBridge。
|
||||||
- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。
|
- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。
|
||||||
|
|||||||
9545
package-lock.json
generated
9545
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -9,6 +9,13 @@
|
|||||||
"dev:api-server": "node scripts/dev.mjs api-server",
|
"dev:api-server": "node scripts/dev.mjs api-server",
|
||||||
"dev:web": "node scripts/dev.mjs web",
|
"dev:web": "node scripts/dev.mjs web",
|
||||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||||
|
"mobile-shell:dev": "npm --prefix apps/mobile-shell run dev",
|
||||||
|
"mobile-shell:typecheck": "npm --prefix apps/mobile-shell run typecheck",
|
||||||
|
"mobile-shell:test": "npm --prefix apps/mobile-shell run test",
|
||||||
|
"desktop-shell:dev": "npm --prefix apps/desktop-shell run dev",
|
||||||
|
"desktop-shell:build": "npm --prefix apps/desktop-shell run build --",
|
||||||
|
"desktop-shell:typecheck": "npm --prefix apps/desktop-shell run typecheck",
|
||||||
|
"desktop-shell:test": "cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml",
|
||||||
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
|
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
|
||||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||||
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||||
@@ -70,20 +77,31 @@
|
|||||||
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
|
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "^56.0.15",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"cannon-es": "^0.20.0",
|
"cannon-es": "^0.20.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"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",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-native": "^0.86.0",
|
||||||
|
"react-native-webview": "^13.16.1",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@colbymchenry/codegraph": "^0.8.0",
|
"@colbymchenry/codegraph": "^0.8.0",
|
||||||
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
|||||||
106
packages/shared/src/contracts/hostBridge.ts
Normal file
106
packages/shared/src/contracts/hostBridge.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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 type HostBridgeMethod =
|
||||||
|
| 'host.getRuntime'
|
||||||
|
| 'auth.requestLogin'
|
||||||
|
| 'payment.request'
|
||||||
|
| 'share.setTarget'
|
||||||
|
| 'share.open'
|
||||||
|
| 'navigation.openNativePage'
|
||||||
|
| 'app.openExternalUrl'
|
||||||
|
| 'clipboard.writeText'
|
||||||
|
| 'haptics.impact';
|
||||||
|
|
||||||
|
export type HostBridgeCapability =
|
||||||
|
| HostBridgeMethod
|
||||||
|
| 'host.events'
|
||||||
|
| 'navigation.canGoBack';
|
||||||
|
|
||||||
|
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 NavigateNativePagePayload = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenExternalUrlPayload = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClipboardWriteTextPayload = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HapticsImpactPayload = {
|
||||||
|
style?: 'light' | 'medium' | 'heavy';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShareSetTargetPayload = {
|
||||||
|
target: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShareOpenPayload = {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
export type * from './creativeAgent';
|
export type * from './barkBattle';
|
||||||
export type * from './creationAudio';
|
export type * from './creationAudio';
|
||||||
|
export type * from './creativeAgent';
|
||||||
|
export * from './hostBridge';
|
||||||
export type * from './hyper3d';
|
export type * from './hyper3d';
|
||||||
export type * from './jumpHop';
|
export type * from './jumpHop';
|
||||||
export type * from './puzzleCreativeTemplate';
|
|
||||||
export type * from './puzzleClear';
|
|
||||||
export * from './playTypes';
|
export * from './playTypes';
|
||||||
export type * from './publicWork';
|
export type * from './publicWork';
|
||||||
|
export type * from './puzzleClear';
|
||||||
|
export type * from './puzzleCreativeTemplate';
|
||||||
export type * from './visualNovel';
|
export type * from './visualNovel';
|
||||||
export type * from './barkBattle';
|
|
||||||
export type * from './woodenFish';
|
export type * from './woodenFish';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canUseHostShareGrid,
|
canUseHostShareGrid,
|
||||||
getHostRuntime,
|
getHostRuntime,
|
||||||
|
getNativeAppHostRuntime,
|
||||||
isWechatMiniProgramWebViewRuntime,
|
isWechatMiniProgramWebViewRuntime,
|
||||||
navigateHostNativePage,
|
navigateHostNativePage,
|
||||||
openHostShareGrid,
|
openHostShareGrid,
|
||||||
@@ -17,11 +18,26 @@ import {
|
|||||||
resolveHostRuntime,
|
resolveHostRuntime,
|
||||||
setHostShareTarget,
|
setHostShareTarget,
|
||||||
} from './hostBridge';
|
} from './hostBridge';
|
||||||
|
import { resetNativeAppHostBridgeForTest } from './nativeAppHostBridge';
|
||||||
|
|
||||||
|
function asTauriInvoke(
|
||||||
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
return async function tauriInvoke<Result = unknown>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return (await invoke(command, args)) as Result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
window.wx = undefined;
|
window.wx = undefined;
|
||||||
|
delete window.ReactNativeWebView;
|
||||||
|
delete window.__TAURI__;
|
||||||
|
resetNativeAppHostBridgeForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hostBridge', () => {
|
describe('hostBridge', () => {
|
||||||
@@ -43,7 +59,26 @@ describe('hostBridge', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveHostRuntime({
|
resolveHostRuntime({
|
||||||
location: { search: '?clientRuntime=native_app' },
|
location: {
|
||||||
|
search:
|
||||||
|
'?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: 'native_app',
|
||||||
|
hostShell: 'expo_mobile',
|
||||||
|
hostPlatform: 'ios',
|
||||||
|
hostVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveHostRuntime({
|
||||||
|
tauri: {
|
||||||
|
core: {
|
||||||
|
invoke: asTauriInvoke(vi.fn(async () => null)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: { search: '' },
|
||||||
}).kind,
|
}).kind,
|
||||||
).toBe('native_app');
|
).toBe('native_app');
|
||||||
|
|
||||||
@@ -227,4 +262,100 @@ describe('hostBridge', () => {
|
|||||||
expect(canUseHostShareGrid()).toBe(false);
|
expect(canUseHostShareGrid()).toBe(false);
|
||||||
expect(setHostShareTarget({ type: 'test' })).toBe(false);
|
expect(setHostShareTarget({ type: 'test' })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => {
|
||||||
|
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||||
|
const request = (args as { request: { id: string; method: string } })
|
||||||
|
.request;
|
||||||
|
return {
|
||||||
|
bridge: 'GenarrativeHostBridge',
|
||||||
|
version: 1,
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result: request.method === 'host.getRuntime'
|
||||||
|
? {
|
||||||
|
shell: 'tauri_desktop',
|
||||||
|
platform: 'linux',
|
||||||
|
hostVersion: '0.1.0',
|
||||||
|
bridgeVersion: 1,
|
||||||
|
capabilities: ['host.getRuntime'],
|
||||||
|
}
|
||||||
|
: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||||
|
);
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: asTauriInvoke(invoke),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(navigateHostNativePage('/settings')).resolves.toBe(true);
|
||||||
|
await expect(requestHostLogin()).resolves.toBe(true);
|
||||||
|
await expect(
|
||||||
|
requestHostPayment({
|
||||||
|
payload: null,
|
||||||
|
orderId: 'order-1',
|
||||||
|
}),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
await expect(getNativeAppHostRuntime()).resolves.toMatchObject({
|
||||||
|
shell: 'tauri_desktop',
|
||||||
|
platform: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||||
|
request: expect.objectContaining({
|
||||||
|
method: 'navigation.openNativePage',
|
||||||
|
payload: { url: '/settings' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||||
|
request: expect.objectContaining({
|
||||||
|
method: 'auth.requestLogin',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||||
|
request: expect.objectContaining({
|
||||||
|
method: 'payment.request',
|
||||||
|
payload: {
|
||||||
|
payload: null,
|
||||||
|
orderId: 'order-1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: asTauriInvoke(vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||||
|
const request = (args as { request: { id: string } }).request;
|
||||||
|
return {
|
||||||
|
bridge: 'GenarrativeHostBridge',
|
||||||
|
version: 1,
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'unsupported_method',
|
||||||
|
message: 'unsupported_method',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(navigateHostNativePage('/settings')).resolves.toBe(false);
|
||||||
|
await expect(requestHostLogin()).resolves.toBe(false);
|
||||||
|
await expect(
|
||||||
|
requestHostPayment({
|
||||||
|
payload: null,
|
||||||
|
orderId: 'order-1',
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import type {
|
||||||
|
HostBridgeMethod,
|
||||||
|
HostBridgeRuntimeResult,
|
||||||
|
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||||
import type {
|
import type {
|
||||||
WechatMiniProgramPayParams,
|
WechatMiniProgramPayParams,
|
||||||
WechatMiniProgramVirtualPayParams,
|
WechatMiniProgramVirtualPayParams,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { requestNativeAppHostBridge } from './nativeAppHostBridge';
|
||||||
|
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
const MINI_PROGRAM_AUTH_PAGE_URL =
|
const MINI_PROGRAM_AUTH_PAGE_URL =
|
||||||
@@ -18,6 +23,9 @@ export type HostRuntimeSnapshot = {
|
|||||||
kind: HostRuntimeKind;
|
kind: HostRuntimeKind;
|
||||||
clientType: string | null;
|
clientType: string | null;
|
||||||
clientRuntime: string | null;
|
clientRuntime: string | null;
|
||||||
|
hostShell: string | null;
|
||||||
|
hostPlatform: string | null;
|
||||||
|
hostVersion: string | null;
|
||||||
miniProgramEnv: string | null;
|
miniProgramEnv: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,6 +33,8 @@ export type HostRuntimeContext = {
|
|||||||
location?: Pick<Location, 'search'> | null;
|
location?: Pick<Location, 'search'> | null;
|
||||||
navigator?: Partial<Pick<Navigator, 'userAgent'>> | null;
|
navigator?: Partial<Pick<Navigator, 'userAgent'>> | null;
|
||||||
wx?: Window['wx'] | null;
|
wx?: Window['wx'] | null;
|
||||||
|
tauri?: Window['__TAURI__'] | null;
|
||||||
|
reactNativeWebView?: Window['ReactNativeWebView'] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HostNativePageNavigationOptions = {
|
export type HostNativePageNavigationOptions = {
|
||||||
@@ -47,6 +57,28 @@ export type HostShareGridRequest = {
|
|||||||
publicWorkCode: string;
|
publicWorkCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isUnsupportedHostBridgeError(error: unknown) {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.name === 'unsupported_method' ||
|
||||||
|
error.name === 'unsupported_capability')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestNativeHostBoolean(
|
||||||
|
method: HostBridgeMethod,
|
||||||
|
payload?: unknown,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return Boolean(await requestNativeAppHostBridge(method, payload));
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnsupportedHostBridgeError(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLocation(context: HostRuntimeContext) {
|
function resolveLocation(context: HostRuntimeContext) {
|
||||||
return (
|
return (
|
||||||
context.location ?? (typeof window !== 'undefined' ? window.location : null)
|
context.location ?? (typeof window !== 'undefined' ? window.location : null)
|
||||||
@@ -64,6 +96,19 @@ function resolveWxBridge(context: HostRuntimeContext) {
|
|||||||
return context.wx ?? (typeof window !== 'undefined' ? window.wx : null);
|
return context.wx ?? (typeof window !== 'undefined' ? window.wx : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTauriBridge(context: HostRuntimeContext) {
|
||||||
|
return (
|
||||||
|
context.tauri ?? (typeof window !== 'undefined' ? window.__TAURI__ : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReactNativeWebView(context: HostRuntimeContext) {
|
||||||
|
return (
|
||||||
|
context.reactNativeWebView ??
|
||||||
|
(typeof window !== 'undefined' ? window.ReactNativeWebView : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasWechatMiniProgramBridge(wxBridge: Window['wx'] | null | undefined) {
|
function hasWechatMiniProgramBridge(wxBridge: Window['wx'] | null | undefined) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
wxBridge?.miniProgram?.postMessage || wxBridge?.miniProgram?.navigateTo,
|
wxBridge?.miniProgram?.postMessage || wxBridge?.miniProgram?.navigateTo,
|
||||||
@@ -85,9 +130,14 @@ export function resolveHostRuntime(
|
|||||||
const params = new URLSearchParams(location?.search ?? '');
|
const params = new URLSearchParams(location?.search ?? '');
|
||||||
const clientType = params.get('clientType');
|
const clientType = params.get('clientType');
|
||||||
const clientRuntime = params.get('clientRuntime');
|
const clientRuntime = params.get('clientRuntime');
|
||||||
|
const hostShell = params.get('hostShell');
|
||||||
|
const hostPlatform = params.get('hostPlatform');
|
||||||
|
const hostVersion = params.get('hostVersion');
|
||||||
const miniProgramEnv = params.get('miniProgramEnv');
|
const miniProgramEnv = params.get('miniProgramEnv');
|
||||||
const navigatorLike = resolveNavigator(context);
|
const navigatorLike = resolveNavigator(context);
|
||||||
const wxBridge = resolveWxBridge(context);
|
const wxBridge = resolveWxBridge(context);
|
||||||
|
const tauriBridge = resolveTauriBridge(context);
|
||||||
|
const reactNativeWebView = resolveReactNativeWebView(context);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
clientRuntime === 'wechat_mini_program' ||
|
clientRuntime === 'wechat_mini_program' ||
|
||||||
@@ -99,15 +149,26 @@ export function resolveHostRuntime(
|
|||||||
kind: 'wechat_mini_program',
|
kind: 'wechat_mini_program',
|
||||||
clientType,
|
clientType,
|
||||||
clientRuntime,
|
clientRuntime,
|
||||||
|
hostShell,
|
||||||
|
hostPlatform,
|
||||||
|
hostVersion,
|
||||||
miniProgramEnv,
|
miniProgramEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientRuntime === 'native_app' || clientType === 'native_app') {
|
if (
|
||||||
|
clientRuntime === 'native_app' ||
|
||||||
|
clientType === 'native_app' ||
|
||||||
|
typeof tauriBridge?.core?.invoke === 'function' ||
|
||||||
|
typeof reactNativeWebView?.postMessage === 'function'
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: 'native_app',
|
kind: 'native_app',
|
||||||
clientType,
|
clientType,
|
||||||
clientRuntime,
|
clientRuntime,
|
||||||
|
hostShell,
|
||||||
|
hostPlatform,
|
||||||
|
hostVersion,
|
||||||
miniProgramEnv,
|
miniProgramEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -116,6 +177,9 @@ export function resolveHostRuntime(
|
|||||||
kind: 'browser',
|
kind: 'browser',
|
||||||
clientType,
|
clientType,
|
||||||
clientRuntime,
|
clientRuntime,
|
||||||
|
hostShell,
|
||||||
|
hostPlatform,
|
||||||
|
hostVersion,
|
||||||
miniProgramEnv,
|
miniProgramEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -214,6 +278,17 @@ export async function navigateHostNativePage(
|
|||||||
options: HostNativePageNavigationOptions = {},
|
options: HostNativePageNavigationOptions = {},
|
||||||
) {
|
) {
|
||||||
const runtime = getHostRuntime();
|
const runtime = getHostRuntime();
|
||||||
|
if (runtime.kind === 'native_app') {
|
||||||
|
const result = await requestNativeHostBoolean(
|
||||||
|
'navigation.openNativePage',
|
||||||
|
{ url },
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
options.beforeNavigate?.();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.kind !== 'wechat_mini_program') {
|
if (runtime.kind !== 'wechat_mini_program') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -225,6 +300,10 @@ export async function navigateHostNativePage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requestHostLogin() {
|
export async function requestHostLogin() {
|
||||||
|
if (getHostRuntime().kind === 'native_app') {
|
||||||
|
return await requestNativeHostBoolean('auth.requestLogin');
|
||||||
|
}
|
||||||
|
|
||||||
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
|
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
|
||||||
errorMessage: '请在微信小程序内完成登录',
|
errorMessage: '请在微信小程序内完成登录',
|
||||||
});
|
});
|
||||||
@@ -238,7 +317,15 @@ export async function requestHostPayment({
|
|||||||
payload,
|
payload,
|
||||||
orderId,
|
orderId,
|
||||||
}: HostPaymentRequest) {
|
}: HostPaymentRequest) {
|
||||||
if (getHostRuntime().kind !== 'wechat_mini_program') {
|
const runtime = getHostRuntime();
|
||||||
|
if (runtime.kind === 'native_app') {
|
||||||
|
return await requestNativeHostBoolean('payment.request', {
|
||||||
|
payload,
|
||||||
|
orderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.kind !== 'wechat_mini_program') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +408,15 @@ export async function openWechatMiniProgramShareGridPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setHostShareTarget(message: unknown) {
|
export function setHostShareTarget(message: unknown) {
|
||||||
|
if (getHostRuntime().kind === 'native_app') {
|
||||||
|
void requestNativeAppHostBridge('share.setTarget', {
|
||||||
|
target: message,
|
||||||
|
}).catch(() => {
|
||||||
|
// 分享目标同步是宿主提示能力,失败不应打断当前 H5 流程。
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof window === 'undefined' ||
|
typeof window === 'undefined' ||
|
||||||
getHostRuntime().kind !== 'wechat_mini_program' ||
|
getHostRuntime().kind !== 'wechat_mini_program' ||
|
||||||
@@ -338,3 +434,13 @@ export function setHostShareTarget(message: unknown) {
|
|||||||
export function postWechatMiniProgramMessage(message: unknown) {
|
export function postWechatMiniProgramMessage(message: unknown) {
|
||||||
return setHostShareTarget(message);
|
return setHostShareTarget(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getNativeAppHostRuntime() {
|
||||||
|
if (getHostRuntime().kind !== 'native_app') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await requestNativeAppHostBridge<HostBridgeRuntimeResult>(
|
||||||
|
'host.getRuntime',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
142
src/services/host-bridge/nativeAppHostBridge.test.ts
Normal file
142
src/services/host-bridge/nativeAppHostBridge.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HOST_BRIDGE_PROTOCOL,
|
||||||
|
HOST_BRIDGE_VERSION,
|
||||||
|
type HostBridgeRequest,
|
||||||
|
type HostBridgeResponse,
|
||||||
|
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||||
|
import {
|
||||||
|
canUseNativeAppHostBridge,
|
||||||
|
canUseReactNativeHostBridge,
|
||||||
|
canUseTauriHostBridge,
|
||||||
|
requestNativeAppHostBridge,
|
||||||
|
resetNativeAppHostBridgeForTest,
|
||||||
|
} from './nativeAppHostBridge';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
resetNativeAppHostBridgeForTest();
|
||||||
|
delete window.ReactNativeWebView;
|
||||||
|
delete window.__TAURI__;
|
||||||
|
});
|
||||||
|
|
||||||
|
function dispatchHostBridgeResponse(response: HostBridgeResponse) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent('message', {
|
||||||
|
data: JSON.stringify(response),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTauriInvoke(
|
||||||
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
return async function tauriInvoke<Result = unknown>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return (await invoke(command, args)) as Result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('nativeAppHostBridge', () => {
|
||||||
|
test('通过 React Native WebView postMessage 发送请求并接收回包', async () => {
|
||||||
|
const postMessage = vi.fn((message: string) => {
|
||||||
|
const request = JSON.parse(message) as HostBridgeRequest;
|
||||||
|
dispatchHostBridgeResponse({
|
||||||
|
bridge: HOST_BRIDGE_PROTOCOL,
|
||||||
|
version: HOST_BRIDGE_VERSION,
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
handled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.ReactNativeWebView = { postMessage };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestNativeAppHostBridge('share.open', { title: '测试作品' }),
|
||||||
|
).resolves.toEqual({
|
||||||
|
handled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canUseReactNativeHostBridge()).toBe(true);
|
||||||
|
expect(canUseNativeAppHostBridge()).toBe(true);
|
||||||
|
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||||
|
const request = JSON.parse(postMessage.mock.calls[0]?.[0] ?? '{}');
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
bridge: HOST_BRIDGE_PROTOCOL,
|
||||||
|
version: HOST_BRIDGE_VERSION,
|
||||||
|
method: 'share.open',
|
||||||
|
payload: {
|
||||||
|
title: '测试作品',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('通过 Tauri invoke 发送请求并处理错误回包', async () => {
|
||||||
|
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||||
|
const request = (args as { request: HostBridgeRequest }).request;
|
||||||
|
return {
|
||||||
|
bridge: HOST_BRIDGE_PROTOCOL,
|
||||||
|
version: HOST_BRIDGE_VERSION,
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'unsupported_method',
|
||||||
|
message: 'unsupported_method',
|
||||||
|
},
|
||||||
|
} satisfies HostBridgeResponse;
|
||||||
|
});
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: asTauriInvoke(invoke),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestNativeAppHostBridge('auth.requestLogin'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: 'unsupported_method',
|
||||||
|
message: 'unsupported_method',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canUseTauriHostBridge()).toBe(true);
|
||||||
|
expect(canUseNativeAppHostBridge()).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||||
|
request: expect.objectContaining({
|
||||||
|
method: 'auth.requestLogin',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有原生宿主 bridge 时返回 null', async () => {
|
||||||
|
await expect(
|
||||||
|
requestNativeAppHostBridge('host.getRuntime'),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(canUseNativeAppHostBridge()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('React Native WebView 回包超时时拒绝请求', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
window.ReactNativeWebView = {
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = requestNativeAppHostBridge('share.open', undefined, {
|
||||||
|
timeoutMs: 10,
|
||||||
|
});
|
||||||
|
const assertion = expect(pending).rejects.toMatchObject({
|
||||||
|
name: 'timeout',
|
||||||
|
message: 'host_bridge_timeout',
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(11);
|
||||||
|
|
||||||
|
await assertion;
|
||||||
|
});
|
||||||
|
});
|
||||||
217
src/services/host-bridge/nativeAppHostBridge.ts
Normal file
217
src/services/host-bridge/nativeAppHostBridge.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
HOST_BRIDGE_PROTOCOL,
|
||||||
|
HOST_BRIDGE_VERSION,
|
||||||
|
type HostBridgeError,
|
||||||
|
type HostBridgeMethod,
|
||||||
|
type HostBridgeRequest,
|
||||||
|
type HostBridgeResponse,
|
||||||
|
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||||
|
|
||||||
|
const DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS = 8000;
|
||||||
|
const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
type NativeAppBridgeWindow = Window & {
|
||||||
|
ReactNativeWebView?: {
|
||||||
|
postMessage?: (message: string) => void;
|
||||||
|
};
|
||||||
|
__TAURI__?: {
|
||||||
|
core?: {
|
||||||
|
invoke?: <Result = unknown>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
) => Promise<Result>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingNativeRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
|
||||||
|
|
||||||
|
let nativeBridgeListenerInstalled = false;
|
||||||
|
let nextNativeRequestSequence = 0;
|
||||||
|
|
||||||
|
function resolveNativeWindow() {
|
||||||
|
return typeof window === 'undefined'
|
||||||
|
? null
|
||||||
|
: (window as NativeAppBridgeWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNativeRequestId(method: HostBridgeMethod) {
|
||||||
|
nextNativeRequestSequence += 1;
|
||||||
|
return `host_${method.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}_${nextNativeRequestSequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeoutMs(timeoutMs: number | undefined) {
|
||||||
|
if (!Number.isFinite(timeoutMs ?? Number.NaN) || !timeoutMs) {
|
||||||
|
return DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.max(1, Math.trunc(timeoutMs)),
|
||||||
|
MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHostBridgeError(error: HostBridgeError) {
|
||||||
|
const result = new Error(error.message);
|
||||||
|
result.name = error.code;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<HostBridgeResponse>;
|
||||||
|
return (
|
||||||
|
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
||||||
|
candidate.version === HOST_BRIDGE_VERSION &&
|
||||||
|
typeof candidate.id === 'string' &&
|
||||||
|
typeof candidate.ok === 'boolean'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNativeMessage(data: unknown) {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleNativeResponse(response: HostBridgeResponse) {
|
||||||
|
const pending = pendingNativeRequests.get(response.id);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingNativeRequests.delete(response.id);
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
pending.resolve(response.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.reject(createHostBridgeError(response.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNativeBridgeListener() {
|
||||||
|
const nativeWindow = resolveNativeWindow();
|
||||||
|
if (!nativeWindow || nativeBridgeListenerInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeWindow.addEventListener('message', (event) => {
|
||||||
|
const value = parseNativeMessage(event.data);
|
||||||
|
if (isHostBridgeResponse(value)) {
|
||||||
|
settleNativeResponse(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nativeBridgeListenerInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseReactNativeHostBridge() {
|
||||||
|
return (
|
||||||
|
typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage ===
|
||||||
|
'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseTauriHostBridge() {
|
||||||
|
return (
|
||||||
|
typeof resolveNativeWindow()?.__TAURI__?.core?.invoke === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseNativeAppHostBridge() {
|
||||||
|
return canUseReactNativeHostBridge() || canUseTauriHostBridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestNativeAppHostBridge<Result = unknown>(
|
||||||
|
method: HostBridgeMethod,
|
||||||
|
payload?: unknown,
|
||||||
|
options: {
|
||||||
|
timeoutMs?: number;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const nativeWindow = resolveNativeWindow();
|
||||||
|
if (!nativeWindow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = resolveTimeoutMs(options.timeoutMs);
|
||||||
|
const request: HostBridgeRequest = {
|
||||||
|
bridge: HOST_BRIDGE_PROTOCOL,
|
||||||
|
version: HOST_BRIDGE_VERSION,
|
||||||
|
id: buildNativeRequestId(method),
|
||||||
|
method,
|
||||||
|
timeoutMs,
|
||||||
|
...(payload === undefined ? {} : { payload }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
|
||||||
|
if (typeof tauriInvoke === 'function') {
|
||||||
|
const response = await tauriInvoke<HostBridgeResponse<Result>>(
|
||||||
|
'host_bridge_request',
|
||||||
|
{ request },
|
||||||
|
);
|
||||||
|
if (!isHostBridgeResponse(response)) {
|
||||||
|
throw new Error('host_bridge_invalid_response');
|
||||||
|
}
|
||||||
|
if (response.ok) {
|
||||||
|
return response.result as Result;
|
||||||
|
}
|
||||||
|
throw createHostBridgeError(response.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const postMessage = nativeWindow.ReactNativeWebView?.postMessage;
|
||||||
|
if (typeof postMessage !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureNativeBridgeListener();
|
||||||
|
|
||||||
|
return await new Promise<Result>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
pendingNativeRequests.delete(request.id);
|
||||||
|
reject(createHostBridgeError({
|
||||||
|
code: 'timeout',
|
||||||
|
message: 'host_bridge_timeout',
|
||||||
|
}));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pendingNativeRequests.set(request.id, {
|
||||||
|
resolve: (value) => resolve(value as Result),
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
postMessage(JSON.stringify(request));
|
||||||
|
} catch (error) {
|
||||||
|
pendingNativeRequests.delete(request.id);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetNativeAppHostBridgeForTest() {
|
||||||
|
for (const pending of pendingNativeRequests.values()) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
}
|
||||||
|
pendingNativeRequests.clear();
|
||||||
|
nextNativeRequestSequence = 0;
|
||||||
|
}
|
||||||
11
src/vite-env.d.ts
vendored
11
src/vite-env.d.ts
vendored
@@ -5,6 +5,17 @@ interface ImportMetaEnv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
ReactNativeWebView?: {
|
||||||
|
postMessage?: (message: string) => void;
|
||||||
|
};
|
||||||
|
__TAURI__?: {
|
||||||
|
core?: {
|
||||||
|
invoke?: <Result = unknown>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
) => Promise<Result>;
|
||||||
|
};
|
||||||
|
};
|
||||||
wx?: {
|
wx?: {
|
||||||
miniProgram?: {
|
miniProgram?: {
|
||||||
navigateTo?: (options: {
|
navigateTo?: (options: {
|
||||||
|
|||||||
Reference in New Issue
Block a user