新增 Expo 与 Tauri 原生宿主壳

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

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

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

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

View 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"
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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"

View 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");
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。",
"windows": ["main"],
"permissions": [
"core:default",
"allow-host-bridge-request"
]
}

View 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");
}
}

View 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": []
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"strict": true,
"types": ["vite/client"]
},
"include": []
}