新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
214 lines
5.8 KiB
Rust
214 lines
5.8 KiB
Rust
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");
|
|
}
|
|
}
|