diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f0e822a3f..774cca992 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -37,6 +37,22 @@ const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8 const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url); const main = fs.readFileSync(mainPath, 'utf8'); const rustSourceDir = new URL('../src-tauri/src/', import.meta.url); +const desktopHostBridgeCapabilitiesPath = new URL( + '../src-tauri/src/host_bridge/capabilities.rs', + import.meta.url, +); +const desktopHostBridgeCapabilitiesSource = fs.readFileSync( + desktopHostBridgeCapabilitiesPath, + 'utf8', +); +const desktopHostBridgeDispatchPath = new URL( + '../src-tauri/src/host_bridge/dispatch.rs', + import.meta.url, +); +const desktopHostBridgeDispatchSource = fs.readFileSync( + desktopHostBridgeDispatchPath, + 'utf8', +); const productionSourceRoots = [ new URL('../package.json', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), @@ -814,8 +830,12 @@ const sharedMethods = extractStringArrayExport( 'HOST_BRIDGE_METHODS', ); const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); -const desktopCapabilities = extractDesktopCapabilities(rustHostSource); -const desktopHandledMethods = extractDesktopHandledMethods(rustHostSource); +const desktopCapabilities = extractDesktopCapabilities( + desktopHostBridgeCapabilitiesSource, +); +const desktopHandledMethods = extractDesktopHandledMethods( + desktopHostBridgeDispatchSource, +); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); const unknownHandledDesktopMethods = desktopHandledMethods.filter( @@ -1041,6 +1061,8 @@ const sharedTauriCommand = extractTsStringConst( ); const allowedTauriCommands = [sharedTauriCommand]; const requiredRustHostModules = [ + 'host_bridge/capabilities.rs', + 'host_bridge/dispatch.rs', 'host_bridge/files.rs', 'host_bridge/mod.rs', 'host_bridge/protocol.rs', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs new file mode 100644 index 000000000..70a35c070 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs @@ -0,0 +1,66 @@ +pub(crate) fn capabilities() -> Vec<&'static str> { + vec![ + "host.getRuntime", + "appearance.getColorScheme", + "host.events", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "network.statusChanged", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn runtime_capability_list_stays_ordered() { + assert_eq!( + capabilities(), + vec![ + "host.getRuntime", + "appearance.getColorScheme", + "host.events", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "network.statusChanged", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] + ); + assert!(Value::from(capabilities()).as_array().is_some()); + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs new file mode 100644 index 000000000..e8b7e894c --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -0,0 +1,705 @@ +use crate::host_bridge::capabilities::capabilities; +use crate::host_bridge::files::{ + export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, + import_image_file_payload, import_text_file_payload, write_export_bytes_file, + write_export_text_file, +}; +use crate::host_bridge::protocol::{ + failed, ok, required_string_payload, validate_request, HostBridgeRequest, HostBridgeResponse, + HostBridgeRuntime, HOST_BRIDGE_VERSION, +}; +use crate::host_bridge::share::{share_text_from_request, DesktopShareState}; +use crate::shell::webview::{ + color_scheme_from_theme, desktop_platform, normalize_external_url, normalize_native_page_url, + resolve_desktop_network_status, +}; +use serde_json::{json, Value}; +use tauri::Manager; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_notification::NotificationExt; +use tauri_plugin_opener::OpenerExt; + +const BADGE_COUNT_MAX: i64 = 99999; +const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; +const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; +const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; + +fn normalize_window_title(raw_title: &str) -> Option { + let title = raw_title.trim(); + if title.is_empty() || title.chars().any(char::is_control) { + return None; + } + + Some(title.chars().take(80).collect()) +} + +fn badge_count_payload(request: &HostBridgeRequest) -> Result, HostBridgeResponse> { + let count = request + .payload + .as_ref() + .and_then(|value| value.get("count")) + .and_then(Value::as_i64) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "count must be an integer between 0 and 99999", + ) + })?; + + if !(0..=BADGE_COUNT_MAX).contains(&count) { + return Err(failed( + request.id.clone(), + "invalid_request", + "count must be an integer between 0 and 99999", + )); + } + + Ok(if count == 0 { None } else { Some(count) }) +} + +fn normalize_clipboard_text(text: String) -> String { + text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect() +} + +fn normalize_plain_text( + value: Option<&str>, + max_length: usize, + required: bool, +) -> Option> { + let Some(value) = value else { + return if required { None } else { Some(None) }; + }; + if value.chars().any(char::is_control) { + return None; + } + + let text = value.split_whitespace().collect::>().join(" "); + if text.is_empty() { + return if required { None } else { Some(None) }; + } + + Some(Some(text.chars().take(max_length).collect())) +} + +fn local_notification_payload( + request: &HostBridgeRequest, +) -> Result<(String, Option), HostBridgeResponse> { + let payload = request + .payload + .as_ref() + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?; + let title = match normalize_plain_text( + payload.get("title").and_then(Value::as_str), + LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, + true, + ) { + Some(Some(title)) => title, + _ => { + return Err(failed( + request.id.clone(), + "invalid_request", + "title is required", + )) + } + }; + let body = match normalize_plain_text( + payload.get("body").and_then(Value::as_str), + LOCAL_NOTIFICATION_BODY_MAX_LENGTH, + false, + ) { + Some(body) => body, + None => { + return Err(failed( + request.id.clone(), + "invalid_request", + "body is invalid", + )) + } + }; + + Ok((title, body)) +} + +pub(crate) 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), + ), + } +} + +pub(super) async fn execute_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 url = match required_string_payload(&request, "url") + .ok() + .and_then(normalize_external_url) + { + Some(url) => url, + None => { + return failed( + request.id, + "invalid_request", + "url must use an allowed external protocol", + ) + } + }; + + match app.opener().open_url(url, None::<&str>) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "appearance.getColorScheme" => match app.get_webview_window("main") { + Some(window) => match window.theme() { + Ok(theme) => ok( + request.id, + json!({ + "colorScheme": color_scheme_from_theme(theme) + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + None => failed(request.id, "host_error", "main window not found"), + }, + "navigation.openNativePage" => { + let url = match required_string_payload(&request, "url") + .ok() + .and_then(normalize_native_page_url) + { + Some(url) => url, + None => { + return failed( + request.id, + "invalid_request", + "url must use an allowed same-origin H5 route", + ) + } + }; + + match app.get_webview_window("main") { + Some(window) => match window.navigate(url) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + None => failed(request.id, "host_error", "main window not found"), + } + } + "app.reloadWebView" => match app.get_webview_window("main") { + Some(window) => match window.reload() { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + None => failed(request.id, "host_error", "main window not found"), + }, + "clipboard.writeText" => { + let text = match required_string_payload(&request, "text") { + Ok(text) => text, + Err(response) => return response, + }; + + match app.clipboard().write_text(text) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "clipboard.readText" => match app.clipboard().read_text() { + Ok(text) => ok( + request.id, + json!({ + "text": normalize_clipboard_text(text), + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + "file.exportText" => { + let (file_name, content) = match export_text_payload(&request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "json", "md", "csv"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)) + .await; + let bytes = match export_result { + Ok(Ok(bytes)) => bytes, + Ok(Err(error)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": bytes, + }), + ) + } + "file.importText" => { + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "md", "markdown", "csv", "json"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "file.exportImage" => { + let (file_name, bytes) = match export_image_payload(&request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) + .await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(error)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) + } + "file.importImage" => { + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = tauri::async_runtime::spawn_blocking(move || { + import_image_file_payload(path, "selected", None) + }) + .await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "file.importAudio" => { + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "file.exportAudio" => { + let (file_name, bytes) = match export_audio_payload(&request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) + .await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(error)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) + } + "app.setTitle" => { + let title = match required_string_payload(&request, "title") + .ok() + .and_then(normalize_window_title) + { + Some(title) => title, + None => return failed(request.id, "invalid_request", "title is required"), + }; + + match app.get_webview_window("main") { + Some(window) => match window.set_title(&title) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + None => failed(request.id, "host_error", "main window not found"), + } + } + "app.setBadgeCount" => { + let count = match badge_count_payload(&request) { + Ok(count) => count, + Err(response) => return response, + }; + + match app.get_webview_window("main") { + Some(window) => match window.set_badge_count(count) { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, + None => failed(request.id, "host_error", "main window not found"), + } + } + "network.status" => { + let network_status = + tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await; + match network_status { + Ok(status) => ok(request.id, status), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "notification.showLocal" => { + let (title, body) = match local_notification_payload(&request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let mut notification = app.notification().builder().title(title); + if let Some(body) = body { + notification = notification.body(body); + } + + match notification.show() { + Ok(()) => ok(request.id, json!(true)), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + "share.setTarget" => { + let target = request + .payload + .as_ref() + .and_then(|payload| payload.get("target")); + let Some(target) = target else { + return failed(request.id, "invalid_request", "target is required"); + }; + let share_state = app.state::(); + + let response = match share_state.target.lock() { + Ok(mut current_target) => { + *current_target = Some(target.clone()); + ok(request.id, json!(true)) + } + Err(_) => failed(request.id, "host_error", "share target lock poisoned"), + }; + response + } + "share.open" => { + let share_state = app.state::(); + let share_text = match share_text_from_request(&request, &share_state) { + Ok(text) => text, + Err(response) => return response, + }; + + match app.clipboard().write_text(share_text) { + Ok(()) => ok( + request.id, + json!({ + "action": "copied_to_clipboard" + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } + _ => resolve_host_bridge_request(request), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + #[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())); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("appearance.getColorScheme"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("host.events"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.lifecycle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("network.status"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("network.statusChanged"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("share.open"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("share.setTarget"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("navigation.openNativePage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.reloadWebView"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.setTitle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("app.setBadgeCount"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("clipboard.readText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.imageDropped"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("notification.showLocal"))); + } + + #[test] + fn unsupported_method_is_explicit() { + for method in ["auth.requestLogin", "payment.request"] { + let response = resolve_host_bridge_request(request(method)); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "unsupported_method"); + assert!(error.message.contains(method)); + } + } + + #[test] + fn clipboard_text_is_truncated_to_contract_limit() { + assert_eq!( + normalize_clipboard_text("作品号 PZ-1".to_string()), + "作品号 PZ-1" + ); + assert_eq!( + normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), + CLIPBOARD_TEXT_MAX_LENGTH + ); + } + + #[test] + fn local_notification_payload_is_normalized() { + let mut request = request("notification.showLocal"); + request.payload = Some(json!({ + "title": " 生成完成 ", + "body": " 作品已准备好 可以试玩 " + })); + + let (title, body) = local_notification_payload(&request).expect("payload"); + + assert_eq!(title, "生成完成"); + assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩")); + } + + #[test] + fn local_notification_payload_rejects_empty_and_control_text() { + let mut empty = request("notification.showLocal"); + empty.payload = Some(json!({ + "title": " " + })); + + let response = local_notification_payload(&empty).expect_err("empty title"); + + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut control = request("notification.showLocal"); + control.payload = Some(json!({ + "title": "生成\n完成" + })); + + let response = local_notification_payload(&control).expect_err("control title"); + + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + #[test] + fn window_title_normalization_requires_visible_text() { + assert_eq!( + normalize_window_title(" Genarrative "), + Some("Genarrative".to_string()) + ); + assert_eq!(normalize_window_title(""), None); + assert_eq!(normalize_window_title("Genarrative\nDev"), None); + + let long_title = "甲".repeat(120); + assert_eq!( + normalize_window_title(&long_title) + .expect("truncated title") + .chars() + .count(), + 80 + ); + } + + #[test] + fn badge_count_payload_accepts_clear_and_positive_counts() { + let mut clear = request("app.setBadgeCount"); + clear.payload = Some(json!({ "count": 0 })); + assert_eq!(badge_count_payload(&clear).expect("clear badge"), None); + + let mut count = request("app.setBadgeCount"); + count.payload = Some(json!({ "count": 12 })); + assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12)); + } + + #[test] + fn badge_count_payload_rejects_invalid_counts() { + for count in [json!(-1), json!(1.5), json!(100000), json!("1")] { + let mut invalid = request("app.setBadgeCount"); + invalid.payload = Some(json!({ "count": count })); + + let response = badge_count_payload(&invalid).expect_err("invalid count"); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!( + error.message, + "count must be an integer between 0 and 99999" + ); + } + } +} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs index 9981f5e36..44811af6d 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs @@ -1,3 +1,5 @@ +pub(crate) mod capabilities; +mod dispatch; pub(crate) mod files; pub(crate) mod protocol; mod share; @@ -5,509 +7,11 @@ mod share; pub(crate) use protocol::HostBridgeReplayState; pub(crate) use share::DesktopShareState; -use crate::host_bridge::files::{ - export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, - import_image_file_payload, import_text_file_payload, write_export_bytes_file, - write_export_text_file, -}; +use crate::host_bridge::dispatch::execute_host_bridge_request; use crate::host_bridge::protocol::{ - capabilities, failed, normalize_request_id, ok, required_string_payload, validate_request, - HostBridgeReplayReservation, HostBridgeRequest, HostBridgeResponse, HostBridgeRuntime, - HOST_BRIDGE_VERSION, + normalize_request_id, validate_request, HostBridgeReplayReservation, HostBridgeRequest, + HostBridgeResponse, }; -use crate::host_bridge::share::share_text_from_request; -use crate::shell::webview::{ - color_scheme_from_theme, desktop_platform, normalize_external_url, normalize_native_page_url, - resolve_desktop_network_status, -}; -use serde_json::{json, Value}; -use tauri::Manager; -use tauri_plugin_clipboard_manager::ClipboardExt; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_notification::NotificationExt; -use tauri_plugin_opener::OpenerExt; - -const BADGE_COUNT_MAX: i64 = 99999; -const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; -const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; -const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; - -fn normalize_window_title(raw_title: &str) -> Option { - let title = raw_title.trim(); - if title.is_empty() || title.chars().any(char::is_control) { - return None; - } - - Some(title.chars().take(80).collect()) -} - -fn badge_count_payload(request: &HostBridgeRequest) -> Result, HostBridgeResponse> { - let count = request - .payload - .as_ref() - .and_then(|value| value.get("count")) - .and_then(Value::as_i64) - .ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "count must be an integer between 0 and 99999", - ) - })?; - - if !(0..=BADGE_COUNT_MAX).contains(&count) { - return Err(failed( - request.id.clone(), - "invalid_request", - "count must be an integer between 0 and 99999", - )); - } - - Ok(if count == 0 { None } else { Some(count) }) -} - -fn normalize_clipboard_text(text: String) -> String { - text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect() -} - -fn normalize_plain_text( - value: Option<&str>, - max_length: usize, - required: bool, -) -> Option> { - let Some(value) = value else { - return if required { None } else { Some(None) }; - }; - if value.chars().any(char::is_control) { - return None; - } - - let text = value.split_whitespace().collect::>().join(" "); - if text.is_empty() { - return if required { None } else { Some(None) }; - } - - Some(Some(text.chars().take(max_length).collect())) -} - -fn local_notification_payload( - request: &HostBridgeRequest, -) -> Result<(String, Option), HostBridgeResponse> { - let payload = request - .payload - .as_ref() - .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?; - let title = match normalize_plain_text( - payload.get("title").and_then(Value::as_str), - LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, - true, - ) { - Some(Some(title)) => title, - _ => { - return Err(failed( - request.id.clone(), - "invalid_request", - "title is required", - )) - } - }; - let body = match normalize_plain_text( - payload.get("body").and_then(Value::as_str), - LOCAL_NOTIFICATION_BODY_MAX_LENGTH, - false, - ) { - Some(body) => body, - None => { - return Err(failed( - request.id.clone(), - "invalid_request", - "body is invalid", - )) - } - }; - - Ok((title, body)) -} - -pub(crate) 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), - ), - } -} - -async fn execute_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 url = match required_string_payload(&request, "url") - .ok() - .and_then(normalize_external_url) - { - Some(url) => url, - None => { - return failed( - request.id, - "invalid_request", - "url must use an allowed external protocol", - ) - } - }; - - match app.opener().open_url(url, None::<&str>) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "appearance.getColorScheme" => match app.get_webview_window("main") { - Some(window) => match window.theme() { - Ok(theme) => ok( - request.id, - json!({ - "colorScheme": color_scheme_from_theme(theme) - }), - ), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - None => failed(request.id, "host_error", "main window not found"), - }, - "navigation.openNativePage" => { - let url = match required_string_payload(&request, "url") - .ok() - .and_then(normalize_native_page_url) - { - Some(url) => url, - None => { - return failed( - request.id, - "invalid_request", - "url must use an allowed same-origin H5 route", - ) - } - }; - - match app.get_webview_window("main") { - Some(window) => match window.navigate(url) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - None => failed(request.id, "host_error", "main window not found"), - } - } - "app.reloadWebView" => match app.get_webview_window("main") { - Some(window) => match window.reload() { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - None => failed(request.id, "host_error", "main window not found"), - }, - "clipboard.writeText" => { - let text = match required_string_payload(&request, "text") { - Ok(text) => text, - Err(response) => return response, - }; - - match app.clipboard().write_text(text) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "clipboard.readText" => match app.clipboard().read_text() { - Ok(text) => ok( - request.id, - json!({ - "text": normalize_clipboard_text(text), - }), - ), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - "file.exportText" => { - let (file_name, content) = match export_text_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let file_path = app - .dialog() - .file() - .add_filter("Text", &["txt", "json", "md", "csv"]) - .set_file_name(file_name.clone()) - .blocking_save_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let export_result = - tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)) - .await; - let bytes = match export_result { - Ok(Ok(bytes)) => bytes, - Ok(Err(error)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": bytes, - }), - ) - } - "file.importText" => { - let file_path = app - .dialog() - .file() - .add_filter("Text", &["txt", "md", "markdown", "csv", "json"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.exportImage" => { - let (file_name, bytes) = match export_image_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let file_path = app - .dialog() - .file() - .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) - .set_file_name(file_name.clone()) - .blocking_save_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let export_result = - tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) - .await; - let byte_count = match export_result { - Ok(Ok(byte_count)) => byte_count, - Ok(Err(error)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": byte_count, - }), - ) - } - "file.importImage" => { - let file_path = app - .dialog() - .file() - .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = tauri::async_runtime::spawn_blocking(move || { - import_image_file_payload(path, "selected", None) - }) - .await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.importAudio" => { - let file_path = app - .dialog() - .file() - .add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.exportAudio" => { - let (file_name, bytes) = match export_audio_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let file_path = app - .dialog() - .file() - .add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"]) - .set_file_name(file_name.clone()) - .blocking_save_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let export_result = - tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) - .await; - let byte_count = match export_result { - Ok(Ok(byte_count)) => byte_count, - Ok(Err(error)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": byte_count, - }), - ) - } - "app.setTitle" => { - let title = match required_string_payload(&request, "title") - .ok() - .and_then(normalize_window_title) - { - Some(title) => title, - None => return failed(request.id, "invalid_request", "title is required"), - }; - - match app.get_webview_window("main") { - Some(window) => match window.set_title(&title) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - None => failed(request.id, "host_error", "main window not found"), - } - } - "app.setBadgeCount" => { - let count = match badge_count_payload(&request) { - Ok(count) => count, - Err(response) => return response, - }; - - match app.get_webview_window("main") { - Some(window) => match window.set_badge_count(count) { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - }, - None => failed(request.id, "host_error", "main window not found"), - } - } - "network.status" => { - let network_status = - tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await; - match network_status { - Ok(status) => ok(request.id, status), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "notification.showLocal" => { - let (title, body) = match local_notification_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let mut notification = app.notification().builder().title(title); - if let Some(body) = body { - notification = notification.body(body); - } - - match notification.show() { - Ok(()) => ok(request.id, json!(true)), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "share.setTarget" => { - let target = request - .payload - .as_ref() - .and_then(|payload| payload.get("target")); - let Some(target) = target else { - return failed(request.id, "invalid_request", "target is required"); - }; - let share_state = app.state::(); - - let response = match share_state.target.lock() { - Ok(mut current_target) => { - *current_target = Some(target.clone()); - ok(request.id, json!(true)) - } - Err(_) => failed(request.id, "host_error", "share target lock poisoned"), - }; - response - } - "share.open" => { - let share_state = app.state::(); - let share_text = match share_text_from_request(&request, &share_state) { - Ok(text) => text, - Err(response) => return response, - }; - - match app.clipboard().write_text(share_text) { - Ok(()) => ok( - request.id, - json!({ - "action": "copied_to_clipboard" - }), - ), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - _ => resolve_host_bridge_request(request), - } -} #[tauri::command] pub(crate) async fn host_bridge_request( @@ -530,205 +34,3 @@ pub(crate) async fn host_bridge_request( Ok(response) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::host_bridge::protocol::request; - - #[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())); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("appearance.getColorScheme"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("host.events"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.lifecycle"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("network.status"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("network.statusChanged"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("share.open"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("share.setTarget"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("navigation.openNativePage"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.reloadWebView"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.setTitle"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("app.setBadgeCount"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("clipboard.readText"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.exportText"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.importText"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.exportImage"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.importImage"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.importAudio"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.exportAudio"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("file.imageDropped"))); - assert!(result["capabilities"] - .as_array() - .unwrap() - .contains(&json!("notification.showLocal"))); - } - - #[test] - fn unsupported_method_is_explicit() { - for method in ["auth.requestLogin", "payment.request"] { - let response = resolve_host_bridge_request(request(method)); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "unsupported_method"); - assert!(error.message.contains(method)); - } - } - - #[test] - fn clipboard_text_is_truncated_to_contract_limit() { - assert_eq!( - normalize_clipboard_text("作品号 PZ-1".to_string()), - "作品号 PZ-1" - ); - assert_eq!( - normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), - CLIPBOARD_TEXT_MAX_LENGTH - ); - } - - #[test] - fn local_notification_payload_is_normalized() { - let mut request = request("notification.showLocal"); - request.payload = Some(json!({ - "title": " 生成完成 ", - "body": " 作品已准备好 可以试玩 " - })); - - let (title, body) = local_notification_payload(&request).expect("payload"); - - assert_eq!(title, "生成完成"); - assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩")); - } - - #[test] - fn local_notification_payload_rejects_empty_and_control_text() { - let mut empty = request("notification.showLocal"); - empty.payload = Some(json!({ - "title": " " - })); - - let response = local_notification_payload(&empty).expect_err("empty title"); - - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut control = request("notification.showLocal"); - control.payload = Some(json!({ - "title": "生成\n完成" - })); - - let response = local_notification_payload(&control).expect_err("control title"); - - assert_eq!(response.error.expect("error").code, "invalid_request"); - } - - #[test] - fn window_title_normalization_requires_visible_text() { - assert_eq!( - normalize_window_title(" Genarrative "), - Some("Genarrative".to_string()) - ); - assert_eq!(normalize_window_title(""), None); - assert_eq!(normalize_window_title("Genarrative\nDev"), None); - - let long_title = "甲".repeat(120); - assert_eq!( - normalize_window_title(&long_title) - .expect("truncated title") - .chars() - .count(), - 80 - ); - } - - #[test] - fn badge_count_payload_accepts_clear_and_positive_counts() { - let mut clear = request("app.setBadgeCount"); - clear.payload = Some(json!({ "count": 0 })); - assert_eq!(badge_count_payload(&clear).expect("clear badge"), None); - - let mut count = request("app.setBadgeCount"); - count.payload = Some(json!({ "count": 12 })); - assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12)); - } - - #[test] - fn badge_count_payload_rejects_invalid_counts() { - for count in [json!(-1), json!(1.5), json!(100000), json!("1")] { - let mut invalid = request("app.setBadgeCount"); - invalid.payload = Some(json!({ "count": count })); - - let response = badge_count_payload(&invalid).expect_err("invalid count"); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!( - error.message, - "count must be an integer between 0 and 99999" - ); - } - } -} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs index 6394c9b2a..0ba43c570 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -141,34 +141,6 @@ impl HostBridgeReplayState { } } -pub(crate) fn capabilities() -> Vec<&'static str> { - vec![ - "host.getRuntime", - "appearance.getColorScheme", - "host.events", - "app.lifecycle", - "share.open", - "share.setTarget", - "navigation.openNativePage", - "app.reloadWebView", - "app.openExternalUrl", - "app.setTitle", - "app.setBadgeCount", - "network.status", - "network.statusChanged", - "clipboard.writeText", - "clipboard.readText", - "file.exportText", - "file.importText", - "file.exportImage", - "file.importImage", - "file.importAudio", - "file.exportAudio", - "file.imageDropped", - "notification.showLocal", - ] -} - pub(crate) fn ok(id: String, result: Value) -> HostBridgeResponse { HostBridgeResponse { bridge: HOST_BRIDGE_PROTOCOL, @@ -281,7 +253,7 @@ pub(crate) fn request(method: &str) -> HostBridgeRequest { #[cfg(test)] mod tests { use super::*; - use serde_json::{json, Value}; + use serde_json::json; #[test] fn invalid_envelope_is_rejected() { @@ -363,37 +335,4 @@ mod tests { assert_eq!(error.code, "invalid_request"); assert_eq!(error.message, "text is required"); } - - #[test] - fn runtime_capability_list_stays_ordered() { - assert_eq!( - capabilities(), - vec![ - "host.getRuntime", - "appearance.getColorScheme", - "host.events", - "app.lifecycle", - "share.open", - "share.setTarget", - "navigation.openNativePage", - "app.reloadWebView", - "app.openExternalUrl", - "app.setTitle", - "app.setBadgeCount", - "network.status", - "network.statusChanged", - "clipboard.writeText", - "clipboard.readText", - "file.exportText", - "file.importText", - "file.exportImage", - "file.importImage", - "file.importAudio", - "file.exportAudio", - "file.imageDropped", - "notification.showLocal", - ] - ); - assert!(Value::from(capabilities()).as_array().is_some()); - } } diff --git a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs index da3ccbcb2..abe6b233f 100644 --- a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs +++ b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs @@ -1,4 +1,5 @@ -use crate::host_bridge::protocol::{capabilities, HOST_BRIDGE_VERSION}; +use crate::host_bridge::capabilities::capabilities; +use crate::host_bridge::protocol::HOST_BRIDGE_VERSION; use crate::shell::tray::show_main_window; use crate::shell::webview::{desktop_platform, WEB_APP_ORIGIN}; use tauri::{Manager, Url, WebviewWindow}; diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 2848c07cb..af8cac38b 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -10,6 +10,8 @@ const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url); const shellAppSource = fs.readFileSync(shellAppPath, 'utf8'); const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); +const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url); +const dispatchSource = fs.readFileSync(dispatchPath, 'utf8'); const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url); const bridgeSourceFiles = fs .readdirSync(bridgeDirPath, { withFileTypes: true }) @@ -161,7 +163,7 @@ function extractStringConstExport(source, exportName) { function extractMobileBridgeHandledMethods(source) { const match = source.match( - /async function handleRequest\(request: HostBridgeRequest\)[^{]*\{[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, ); if (!match) { throw new Error('unable to read mobile shell HostBridge handler methods'); @@ -456,7 +458,7 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); -const handledMobileMethods = extractMobileBridgeHandledMethods(bridgeSource); +const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); const mobileCapabilities = extractStringArrayExport( hostBridgeSource, 'MOBILE_HOST_CAPABILITIES', @@ -874,7 +876,7 @@ for (const forbiddenNotificationSnippet of [ if ( !/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test( - bridgeSource, + dispatchSource, ) ) { throw new Error('mobile shell local notifications must stay immediate and channel-only'); @@ -936,13 +938,13 @@ if (!shellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { throw new Error('mobile shell URL must use the shared mobile shell host version'); } -if (!bridgeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { +if (!dispatchSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { throw new Error('mobile shell runtime response must use the shared mobile shell host version'); } if ( shellAppSource.includes("hostVersion: '0.1.0'") || - bridgeSource.includes("hostVersion: '0.1.0'") + hostBridgeSource.includes("hostVersion: '0.1.0'") ) { throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source'); } @@ -995,6 +997,6 @@ if (mobileCapabilitySet.has('app.setBadgeCount')) { throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount'); } -if (!bridgeSource.includes('Appearance.getColorScheme()')) { +if (!dispatchSource.includes('Appearance.getColorScheme()')) { throw new Error('mobile shell HostBridge must read the native color scheme'); } diff --git a/apps/mobile-shell/src/host-bridge/bridge.ts b/apps/mobile-shell/src/host-bridge/bridge.ts index dcaf2ed2f..ab5b4afef 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.ts @@ -1,317 +1,34 @@ -import * as Clipboard from 'expo-clipboard'; -import * as Haptics from 'expo-haptics'; -import * as Linking from 'expo-linking'; -import * as Notifications from 'expo-notifications'; import { - Appearance, - Platform, - PushNotificationIOS, -} from 'react-native'; - -import { - type ClipboardReadTextResult, - type ClipboardWriteTextPayload, - type HapticsImpactPayload, - HOST_BRIDGE_VERSION, - type HostBridgeError, type HostBridgeRequest, type HostBridgeResponse, - type NavigateNativePagePayload, - normalizeHostBridgeBadgeCount, - normalizeHostBridgeClipboardText, - normalizeHostBridgeColorScheme, - normalizeHostBridgeExternalUrl, - normalizeHostBridgeHapticsImpactStyle, - normalizeHostBridgeLocalNotification, normalizeHostBridgeRequestId, - type OpenExternalUrlPayload, - type SetBadgeCountPayload, } from '../../../../packages/shared/src/contracts/hostBridge'; -import { resolveMobileShellWebViewUrl } from '../shell/navigation'; -import { getMobileNetworkStatus } from '../shell/network'; -import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime'; import { - captureImageFile, - exportAudioFile, - exportImageFile, - exportTextFile, - importAudioFile, - importImageFile, - importTextFile, -} from './files'; + dispatchMobileHostBridgeRequest, + resetMobileHostBridgeDispatchForTest, +} from './dispatch'; import { HOST_BRIDGE_RESPONSE_CACHE_MAX, - type MobileHostBridgeNavigation, failure, invalidRequest, isHostBridgeRequest, normalizeMobileHostBridgeError, - ok, parseRequest, - resolveMobileHostCapabilities, - unsupported, } from './protocol'; -import { openShare } from './share'; export { IOS_MOBILE_HOST_CAPABILITIES, MOBILE_HOST_CAPABILITIES, resolveMobileHostCapabilities, -} from './protocol'; +} from './capabilities'; +export { configureMobileHostBridgeNavigation } from './dispatch'; -const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; - -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: false, - shouldSetBadge: false, - }), -}); - -let currentShareTarget: unknown = null; -let navigation: MobileHostBridgeNavigation | null = null; const completedHostBridgeResponses = new Map(); const inFlightHostBridgeResponses = new Map< string, Promise >(); -export function configureMobileHostBridgeNavigation( - nextNavigation: MobileHostBridgeNavigation | null, -) { - navigation = nextNavigation; -} - -async function openExternalUrl(payload: unknown) { - const url = normalizeHostBridgeExternalUrl( - (payload as OpenExternalUrlPayload | undefined)?.url, - ); - if (!url) { - throw invalidRequest('url must use an allowed external protocol'); - } - - 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 readClipboard(): Promise { - const result = normalizeHostBridgeClipboardText( - await Clipboard.getStringAsync(), - ); - if (!result) { - throw { - code: 'host_error', - message: 'clipboard text unavailable', - } satisfies HostBridgeError; - } - - return result; -} - -async function runHaptics(payload: unknown) { - const style = normalizeHostBridgeHapticsImpactStyle( - (payload as HapticsImpactPayload | undefined)?.style, - ); - if (!style) { - throw invalidRequest('haptics impact style must be light, medium, or heavy'); - } - - const impactStyle = - style === 'heavy' - ? Haptics.ImpactFeedbackStyle.Heavy - : style === 'medium' - ? Haptics.ImpactFeedbackStyle.Medium - : Haptics.ImpactFeedbackStyle.Light; - - await Haptics.impactAsync(impactStyle); - return true; -} - -function setBadgeCount(payload: unknown) { - if (Platform.OS !== 'ios') { - throw { - code: 'unsupported_capability', - message: 'app badge count is only supported on iOS mobile shell', - } satisfies HostBridgeError; - } - - const count = normalizeHostBridgeBadgeCount( - (payload as SetBadgeCountPayload | undefined)?.count, - ); - if (count === null) { - throw invalidRequest('count must be an integer between 0 and 99999'); - } - - PushNotificationIOS.setApplicationIconBadgeNumber(count); - return true; -} - -function hasNotificationPermission( - permission: Awaited>, -) { - return ( - permission.granted || - permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL - ); -} - -async function ensureNotificationPermission() { - const currentPermission = await Notifications.getPermissionsAsync(); - if (hasNotificationPermission(currentPermission)) { - return; - } - - const requestedPermission = await Notifications.requestPermissionsAsync({ - ios: { - allowAlert: true, - allowBadge: false, - allowSound: false, - }, - }); - if (!hasNotificationPermission(requestedPermission)) { - throw { - code: 'host_error', - message: 'notification permission denied', - } satisfies HostBridgeError; - } -} - -async function showLocalNotification(payload: unknown) { - const notification = normalizeHostBridgeLocalNotification(payload); - if (!notification) { - throw invalidRequest('title is required'); - } - - await ensureNotificationPermission(); - if (Platform.OS === 'android') { - await Notifications.setNotificationChannelAsync( - LOCAL_NOTIFICATION_CHANNEL_ID, - { - name: 'Genarrative', - importance: Notifications.AndroidImportance.DEFAULT, - }, - ); - } - - await Notifications.scheduleNotificationAsync({ - content: notification, - trigger: - Platform.OS === 'android' - ? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID } - : null, - }); - return true; -} - -function getColorScheme() { - return { - colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()), - }; -} - -function openNativePage(payload: unknown) { - if (!navigation) { - throw unsupported('navigation.openNativePage'); - } - - const url = (payload as NavigateNativePagePayload | undefined)?.url; - if (typeof url !== 'string') { - throw invalidRequest('url is required'); - } - - const webViewUrl = resolveMobileShellWebViewUrl( - url, - navigation.allowedOrigin, - ); - if (!webViewUrl) { - throw invalidRequest('url must be an allowed same-origin web path'); - } - - navigation.openWebViewUrl(webViewUrl); - return true; -} - -function reloadWebView() { - if (!navigation) { - throw unsupported('app.reloadWebView'); - } - - navigation.reloadWebView(); - 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: MOBILE_SHELL_HOST_VERSION, - bridgeVersion: HOST_BRIDGE_VERSION, - capabilities: resolveMobileHostCapabilities(), - }); - case 'appearance.getColorScheme': - return ok(request, getColorScheme()); - case 'app.openExternalUrl': - return ok(request, await openExternalUrl(request.payload)); - case 'app.reloadWebView': - return ok(request, reloadWebView()); - case 'network.status': - return ok(request, await getMobileNetworkStatus()); - case 'clipboard.writeText': - return ok(request, await writeClipboard(request.payload)); - case 'clipboard.readText': - return ok(request, await readClipboard()); - case 'file.exportText': - return ok(request, await exportTextFile(request.payload)); - case 'file.importText': - return ok(request, await importTextFile()); - case 'file.exportImage': - return ok(request, await exportImageFile(request.payload)); - case 'file.importImage': - return ok(request, await importImageFile()); - case 'file.captureImage': - return ok(request, await captureImageFile()); - case 'file.importAudio': - return ok(request, await importAudioFile()); - case 'file.exportAudio': - return ok(request, await exportAudioFile(request.payload)); - case 'haptics.impact': - return ok(request, await runHaptics(request.payload)); - case 'notification.showLocal': - return ok(request, await showLocalNotification(request.payload)); - case 'app.setBadgeCount': - return ok(request, setBadgeCount(request.payload)); - case 'share.open': - return ok(request, await openShare(request.payload, currentShareTarget)); - case 'share.setTarget': - currentShareTarget = - request.payload && typeof request.payload === 'object' - ? (request.payload as { target?: unknown }).target - : null; - return ok(request, true); - case 'navigation.openNativePage': - return ok(request, openNativePage(request.payload)); - case 'auth.requestLogin': - case 'payment.request': - return failure(request, unsupported(request.method)); - default: - return failure(request, unsupported(request.method)); - } -} - function rememberHostBridgeResponse(response: HostBridgeResponse) { completedHostBridgeResponses.set(response.id, response); if (completedHostBridgeResponses.size > HOST_BRIDGE_RESPONSE_CACHE_MAX) { @@ -335,7 +52,7 @@ async function resolveMobileHostBridgeResponse(request: HostBridgeRequest) { return await inFlightResponse; } - const responsePromise = handleRequest(request) + const responsePromise = dispatchMobileHostBridgeRequest(request) .catch((error: unknown) => failure(request, normalizeMobileHostBridgeError(error)), ) @@ -369,8 +86,7 @@ export async function handleMobileHostBridgeMessage( } export function resetMobileHostBridgeForTest() { - currentShareTarget = null; - navigation = null; + resetMobileHostBridgeDispatchForTest(); completedHostBridgeResponses.clear(); inFlightHostBridgeResponses.clear(); } diff --git a/apps/mobile-shell/src/host-bridge/capabilities.ts b/apps/mobile-shell/src/host-bridge/capabilities.ts new file mode 100644 index 000000000..3f2c96759 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/capabilities.ts @@ -0,0 +1,40 @@ +import { Platform } from 'react-native'; + +import type { HostBridgeCapability } from '../../../../packages/shared/src/contracts/hostBridge'; + +export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'host.events', + 'app.lifecycle', + 'share.open', + 'share.setTarget', + 'navigation.openNativePage', + 'navigation.canGoBack', + 'app.reloadWebView', + 'app.openExternalUrl', + 'network.status', + 'network.statusChanged', + 'clipboard.writeText', + 'clipboard.readText', + 'file.exportText', + 'file.importText', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'file.importAudio', + 'file.exportAudio', + 'haptics.impact', + 'notification.showLocal', +]; + +export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ + ...MOBILE_HOST_CAPABILITIES, + 'app.setBadgeCount', +]; + +export function resolveMobileHostCapabilities(platform = Platform.OS) { + return platform === 'ios' + ? IOS_MOBILE_HOST_CAPABILITIES + : MOBILE_HOST_CAPABILITIES; +} diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts new file mode 100644 index 000000000..a73fc9235 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -0,0 +1,303 @@ +import * as Clipboard from 'expo-clipboard'; +import * as Haptics from 'expo-haptics'; +import * as Linking from 'expo-linking'; +import * as Notifications from 'expo-notifications'; +import { + Appearance, + Platform, + PushNotificationIOS, +} from 'react-native'; + +import { + type ClipboardReadTextResult, + type ClipboardWriteTextPayload, + type HapticsImpactPayload, + HOST_BRIDGE_VERSION, + type HostBridgeError, + type HostBridgeRequest, + type NavigateNativePagePayload, + normalizeHostBridgeBadgeCount, + normalizeHostBridgeClipboardText, + normalizeHostBridgeColorScheme, + normalizeHostBridgeExternalUrl, + normalizeHostBridgeHapticsImpactStyle, + normalizeHostBridgeLocalNotification, + type OpenExternalUrlPayload, + type SetBadgeCountPayload, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { resolveMobileShellWebViewUrl } from '../shell/navigation'; +import { getMobileNetworkStatus } from '../shell/network'; +import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime'; +import { resolveMobileHostCapabilities } from './capabilities'; +import { + captureImageFile, + exportAudioFile, + exportImageFile, + exportTextFile, + importAudioFile, + importImageFile, + importTextFile, +} from './files'; +import { + type MobileHostBridgeNavigation, + failure, + invalidRequest, + ok, + unsupported, +} from './protocol'; +import { openShare } from './share'; + +const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), +}); + +let currentShareTarget: unknown = null; +let navigation: MobileHostBridgeNavigation | null = null; + +export function configureMobileHostBridgeNavigation( + nextNavigation: MobileHostBridgeNavigation | null, +) { + navigation = nextNavigation; +} + +async function openExternalUrl(payload: unknown) { + const url = normalizeHostBridgeExternalUrl( + (payload as OpenExternalUrlPayload | undefined)?.url, + ); + if (!url) { + throw invalidRequest('url must use an allowed external protocol'); + } + + 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 readClipboard(): Promise { + const result = normalizeHostBridgeClipboardText( + await Clipboard.getStringAsync(), + ); + if (!result) { + throw { + code: 'host_error', + message: 'clipboard text unavailable', + } satisfies HostBridgeError; + } + + return result; +} + +async function runHaptics(payload: unknown) { + const style = normalizeHostBridgeHapticsImpactStyle( + (payload as HapticsImpactPayload | undefined)?.style, + ); + if (!style) { + throw invalidRequest('haptics impact style must be light, medium, or heavy'); + } + + const impactStyle = + style === 'heavy' + ? Haptics.ImpactFeedbackStyle.Heavy + : style === 'medium' + ? Haptics.ImpactFeedbackStyle.Medium + : Haptics.ImpactFeedbackStyle.Light; + + await Haptics.impactAsync(impactStyle); + return true; +} + +function setBadgeCount(payload: unknown) { + if (Platform.OS !== 'ios') { + throw { + code: 'unsupported_capability', + message: 'app badge count is only supported on iOS mobile shell', + } satisfies HostBridgeError; + } + + const count = normalizeHostBridgeBadgeCount( + (payload as SetBadgeCountPayload | undefined)?.count, + ); + if (count === null) { + throw invalidRequest('count must be an integer between 0 and 99999'); + } + + PushNotificationIOS.setApplicationIconBadgeNumber(count); + return true; +} + +function hasNotificationPermission( + permission: Awaited>, +) { + return ( + permission.granted || + permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL + ); +} + +async function ensureNotificationPermission() { + const currentPermission = await Notifications.getPermissionsAsync(); + if (hasNotificationPermission(currentPermission)) { + return; + } + + const requestedPermission = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: false, + allowSound: false, + }, + }); + if (!hasNotificationPermission(requestedPermission)) { + throw { + code: 'host_error', + message: 'notification permission denied', + } satisfies HostBridgeError; + } +} + +async function showLocalNotification(payload: unknown) { + const notification = normalizeHostBridgeLocalNotification(payload); + if (!notification) { + throw invalidRequest('title is required'); + } + + await ensureNotificationPermission(); + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync( + LOCAL_NOTIFICATION_CHANNEL_ID, + { + name: 'Genarrative', + importance: Notifications.AndroidImportance.DEFAULT, + }, + ); + } + + await Notifications.scheduleNotificationAsync({ + content: notification, + trigger: + Platform.OS === 'android' + ? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID } + : null, + }); + return true; +} + +function getColorScheme() { + return { + colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()), + }; +} + +function openNativePage(payload: unknown) { + if (!navigation) { + throw unsupported('navigation.openNativePage'); + } + + const url = (payload as NavigateNativePagePayload | undefined)?.url; + if (typeof url !== 'string') { + throw invalidRequest('url is required'); + } + + const webViewUrl = resolveMobileShellWebViewUrl( + url, + navigation.allowedOrigin, + ); + if (!webViewUrl) { + throw invalidRequest('url must be an allowed same-origin web path'); + } + + navigation.openWebViewUrl(webViewUrl); + return true; +} + +function reloadWebView() { + if (!navigation) { + throw unsupported('app.reloadWebView'); + } + + navigation.reloadWebView(); + return true; +} + +export async function dispatchMobileHostBridgeRequest( + request: HostBridgeRequest, +) { + switch (request.method) { + case 'host.getRuntime': + return ok(request, { + shell: 'expo_mobile', + platform: Platform.OS === 'ios' ? 'ios' : 'android', + hostVersion: MOBILE_SHELL_HOST_VERSION, + bridgeVersion: HOST_BRIDGE_VERSION, + capabilities: resolveMobileHostCapabilities(), + }); + case 'appearance.getColorScheme': + return ok(request, getColorScheme()); + case 'app.openExternalUrl': + return ok(request, await openExternalUrl(request.payload)); + case 'app.reloadWebView': + return ok(request, reloadWebView()); + case 'network.status': + return ok(request, await getMobileNetworkStatus()); + case 'clipboard.writeText': + return ok(request, await writeClipboard(request.payload)); + case 'clipboard.readText': + return ok(request, await readClipboard()); + case 'file.exportText': + return ok(request, await exportTextFile(request.payload)); + case 'file.importText': + return ok(request, await importTextFile()); + case 'file.exportImage': + return ok(request, await exportImageFile(request.payload)); + case 'file.importImage': + return ok(request, await importImageFile()); + case 'file.captureImage': + return ok(request, await captureImageFile()); + case 'file.importAudio': + return ok(request, await importAudioFile()); + case 'file.exportAudio': + return ok(request, await exportAudioFile(request.payload)); + case 'haptics.impact': + return ok(request, await runHaptics(request.payload)); + case 'notification.showLocal': + return ok(request, await showLocalNotification(request.payload)); + case 'app.setBadgeCount': + return ok(request, setBadgeCount(request.payload)); + case 'share.open': + return ok(request, await openShare(request.payload, currentShareTarget)); + case 'share.setTarget': + currentShareTarget = + request.payload && typeof request.payload === 'object' + ? (request.payload as { target?: unknown }).target + : null; + return ok(request, true); + case 'navigation.openNativePage': + return ok(request, openNativePage(request.payload)); + case 'auth.requestLogin': + case 'payment.request': + return failure(request, unsupported(request.method)); + default: + return failure(request, unsupported(request.method)); + } +} + +export function resetMobileHostBridgeDispatchForTest() { + currentShareTarget = null; + navigation = null; +} diff --git a/apps/mobile-shell/src/host-bridge/protocol.ts b/apps/mobile-shell/src/host-bridge/protocol.ts index cb95f80a2..3b352cee6 100644 --- a/apps/mobile-shell/src/host-bridge/protocol.ts +++ b/apps/mobile-shell/src/host-bridge/protocol.ts @@ -1,9 +1,6 @@ -import { Platform } from 'react-native'; - import { HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, - type HostBridgeCapability, type HostBridgeError, type HostBridgeMethod, type HostBridgeRequest, @@ -14,43 +11,6 @@ import { export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; -export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ - 'host.getRuntime', - 'appearance.getColorScheme', - 'host.events', - 'app.lifecycle', - 'share.open', - 'share.setTarget', - 'navigation.openNativePage', - 'navigation.canGoBack', - 'app.reloadWebView', - 'app.openExternalUrl', - 'network.status', - 'network.statusChanged', - 'clipboard.writeText', - 'clipboard.readText', - 'file.exportText', - 'file.importText', - 'file.exportImage', - 'file.importImage', - 'file.captureImage', - 'file.importAudio', - 'file.exportAudio', - 'haptics.impact', - 'notification.showLocal', -]; - -export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ - ...MOBILE_HOST_CAPABILITIES, - 'app.setBadgeCount', -]; - -export function resolveMobileHostCapabilities(platform = Platform.OS) { - return platform === 'ios' - ? IOS_MOBILE_HOST_CAPABILITIES - : MOBILE_HOST_CAPABILITIES; -} - export type MobileHostBridgeNavigation = { allowedOrigin: string; openWebViewUrl: (url: string) => void; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 350a23e83..f1fdec6cd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2475,6 +2475,6 @@ ## 2026-06-18 三端宿主桥接层文件结构对齐 - 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 -- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,`apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。 +- 决策:三端桥接层按职责对齐,但保留各宿主真实边界。微信小程序页面路由不改,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留生命周期和装配,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts`,分别负责 envelope / request 校验 / ok-failure 响应 / replay 基础类型、能力清单与 iOS 差异、method 分发、文件能力、分享能力和 WebView message 入口 / request id replay 编排;根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,`apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`capabilities.rs`、`dispatch.rs`、`files.rs`、`share.rs` 和 command facade `mod.rs`,分别负责 envelope / method 白名单 / request 校验 / replay 状态、能力清单、method 分发、文件能力、分享能力和 `host_bridge_request` command / replay 编排;`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。 - 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 - 验证方式:`npm run test -- miniprogram/host-bridge/webView.test.js miniprogram/host-bridge/payment.test.js miniprogram/host-bridge/shareGrid.test.js miniprogram/host-bridge/subscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 8d5990121..97ab475d7 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -64,7 +64,7 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。 ## HostBridge 消息协议 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 04844096c..48a5c43ea 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,7 +37,7 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 ## 首批能力 diff --git a/miniprogram/host-bridge/dispatch.js b/miniprogram/host-bridge/dispatch.js new file mode 100644 index 000000000..86425a270 --- /dev/null +++ b/miniprogram/host-bridge/dispatch.js @@ -0,0 +1,13 @@ +const payment = require('./payment'); +const protocol = require('./protocol'); +const shareGrid = require('./shareGrid'); +const subscribeMessage = require('./subscribeMessage'); +const webView = require('./webView'); + +module.exports = { + payment, + protocol, + shareGrid, + subscribeMessage, + webView, +}; diff --git a/miniprogram/host-bridge/protocol.js b/miniprogram/host-bridge/protocol.js new file mode 100644 index 000000000..c7003e4af --- /dev/null +++ b/miniprogram/host-bridge/protocol.js @@ -0,0 +1,33 @@ +const WECHAT_HOST_CAPABILITIES = [ + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', +]; + +const WECHAT_WEB_VIEW_PAGE_URL = '/pages/web-view/index'; +const WECHAT_AUTH_PAGE_URL = + `${WECHAT_WEB_VIEW_PAGE_URL}?authAction=login&returnTo=previous`; +const WECHAT_PAY_PAGE_URL = '/pages/wechat-pay/index'; +const WECHAT_SHARE_GRID_PAGE_URL = '/pages/share-grid/index'; +const WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL = '/pages/subscribe-message/index'; +const WECHAT_PAY_RESULT_HASH_KEY = 'wx_pay_result'; +const WECHAT_SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result'; +const WECHAT_PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; +const WECHAT_SUBSCRIBE_RESULT_STORAGE_KEY = + 'genarrative:wechat-subscribe-result'; +const WECHAT_SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target'; + +module.exports = { + WECHAT_AUTH_PAGE_URL, + WECHAT_HOST_CAPABILITIES, + WECHAT_PAY_PAGE_URL, + WECHAT_PAY_RESULT_HASH_KEY, + WECHAT_PAY_RESULT_STORAGE_KEY, + WECHAT_SHARE_GRID_PAGE_URL, + WECHAT_SHARE_TARGET_MESSAGE_TYPE, + WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL, + WECHAT_SUBSCRIBE_RESULT_HASH_KEY, + WECHAT_SUBSCRIBE_RESULT_STORAGE_KEY, + WECHAT_WEB_VIEW_PAGE_URL, +}; diff --git a/miniprogram/host-bridge/protocol.test.js b/miniprogram/host-bridge/protocol.test.js new file mode 100644 index 000000000..9a25bd6f4 --- /dev/null +++ b/miniprogram/host-bridge/protocol.test.js @@ -0,0 +1,80 @@ +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const protocolPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/protocol.js', +); +const dispatchPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/dispatch.js', +); +const paymentPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/payment.js', +); +const shareGridPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/shareGrid.js', +); +const subscribeMessagePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/subscribeMessage.js', +); +const webViewPath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/webView.js', +); + +describe('wechat mini program host bridge protocol index', () => { + test('keeps native page routes and result keys centralized', () => { + const protocol = loadCommonJsModule(protocolPath); + + expect(protocol.WECHAT_HOST_CAPABILITIES).toEqual([ + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', + ]); + expect(protocol.WECHAT_AUTH_PAGE_URL).toBe( + '/pages/web-view/index?authAction=login&returnTo=previous', + ); + expect(protocol.WECHAT_PAY_PAGE_URL).toBe('/pages/wechat-pay/index'); + expect(protocol.WECHAT_SHARE_GRID_PAGE_URL).toBe('/pages/share-grid/index'); + expect(protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL).toBe( + '/pages/subscribe-message/index', + ); + expect(protocol.WECHAT_PAY_RESULT_HASH_KEY).toBe('wx_pay_result'); + expect(protocol.WECHAT_SUBSCRIBE_RESULT_HASH_KEY).toBe( + 'wx_subscribe_result', + ); + }); + + test('dispatch index exposes current bridge ability modules', () => { + const payment = loadCommonJsModule(paymentPath); + const protocol = loadCommonJsModule(protocolPath); + const shareGrid = loadCommonJsModule(shareGridPath); + const subscribeMessage = loadCommonJsModule(subscribeMessagePath); + const webView = loadCommonJsModule(webViewPath); + const dispatch = loadCommonJsModule(dispatchPath, { + './payment': payment, + './protocol': protocol, + './shareGrid': shareGrid, + './subscribeMessage': subscribeMessage, + './webView': webView, + }); + + expect(dispatch.protocol.WECHAT_PAY_PAGE_URL).toBe('/pages/wechat-pay/index'); + expect(typeof dispatch.webView.resolveWebViewUrlFromRuntimeConfig).toBe( + 'function', + ); + expect(typeof dispatch.payment.requestWechatPayment).toBe('function'); + expect(typeof dispatch.shareGrid.buildShareGridTilePlan).toBe('function'); + expect(typeof dispatch.subscribeMessage.buildSubscribeResultValue).toBe( + 'function', + ); + }); +}); diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 0b62b7d76..88b1ffe2f 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -12,8 +12,11 @@ const productionShellRoots = [ 'miniprogram', ]; const expectedWechatHostBridgeFiles = [ + 'dispatch.js', 'payment.js', 'payment.test.js', + 'protocol.js', + 'protocol.test.js', 'shareGrid.js', 'shareGrid.test.js', 'subscribeMessage.js', @@ -34,6 +37,8 @@ const expectedWechatShellFiles = [ const expectedMobileHostBridgeFiles = [ 'bridge.test.ts', 'bridge.ts', + 'capabilities.ts', + 'dispatch.ts', 'files.ts', 'protocol.ts', 'share.ts', @@ -57,6 +62,8 @@ const expectedMobileShellFiles = [ 'webViewPolicy.ts', ]; const expectedDesktopHostBridgeRustFiles = [ + 'capabilities.rs', + 'dispatch.rs', 'files.rs', 'mod.rs', 'protocol.rs',