diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index c453637c..73e9ef82 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -34,7 +34,7 @@ const requiredUrlParts = [ 'hostShell=tauri_desktop', 'hostPlatform=unknown', 'bridgeVersion=1', - 'hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText', + 'hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText', ]; for (const part of requiredUrlParts) { @@ -57,7 +57,10 @@ const requiredMainSnippets = [ '"share.setTarget"', '"app.setTitle"', '"clipboard.writeText"', + '"file.exportText"', + 'tauri_plugin_dialog::init()', '"copied_to_clipboard"', + '"file export cancelled"', 'set_title', ]; diff --git a/apps/desktop-shell/src-tauri/Cargo.lock b/apps/desktop-shell/src-tauri/Cargo.lock index e694380c..037b09b9 100644 --- a/apps/desktop-shell/src-tauri/Cargo.lock +++ b/apps/desktop-shell/src-tauri/Cargo.lock @@ -1282,6 +1282,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", "tauri-plugin-opener", ] @@ -2349,6 +2350,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2915,6 +2917,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3613,6 +3639,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4818,6 +4886,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4851,13 +4928,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4888,6 +4982,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4900,6 +5000,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4912,12 +5018,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4930,6 +5048,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4942,6 +5066,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4954,6 +5084,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4966,6 +5102,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/apps/desktop-shell/src-tauri/Cargo.toml b/apps/desktop-shell/src-tauri/Cargo.toml index 79c83770..5e942c74 100644 --- a/apps/desktop-shell/src-tauri/Cargo.toml +++ b/apps/desktop-shell/src-tauri/Cargo.toml @@ -12,4 +12,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2.11.2", features = [] } tauri-plugin-clipboard-manager = "2.3.2" +tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2.5.4" diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 5e894209..0a7cf8ef 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -1,14 +1,20 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; use std::sync::Mutex; use tauri::Manager; use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; const HOST_BRIDGE_VERSION: u8 = 1; const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; +const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; +const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; +const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -74,6 +80,7 @@ fn capabilities() -> Vec<&'static str> { "app.openExternalUrl", "app.setTitle", "clipboard.writeText", + "file.exportText", ] } @@ -168,6 +175,81 @@ fn normalize_window_title(raw_title: &str) -> Option { Some(title.chars().take(80).collect()) } +fn normalize_export_file_name(raw_file_name: &str) -> String { + let mut file_name = String::new(); + let mut last_was_space = false; + + for character in raw_file_name.trim().chars().take(EXPORT_FILE_NAME_MAX_LENGTH) { + if character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + { + file_name.push('-'); + last_was_space = false; + continue; + } + + if character.is_whitespace() { + if !last_was_space { + file_name.push(' '); + last_was_space = true; + } + continue; + } + + file_name.push(character); + last_was_space = false; + } + + let file_name = file_name + .trim() + .trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace()) + .trim(); + if file_name.is_empty() { + EXPORT_FILE_NAME_FALLBACK.to_string() + } else { + file_name.to_string() + } +} + +fn export_text_payload( + request: &HostBridgeRequest, +) -> Result<(String, String), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName and content are required", + ) + })?; + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(normalize_export_file_name) + .unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string()); + let content = payload + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?; + + if content.len() > EXPORT_TEXT_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "content exceeds file export size limit", + )); + } + + Ok((file_name, content.to_string())) +} + +fn write_export_text_file(path: PathBuf, content: String) -> Result { + fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?; + Ok(content.len()) +} + fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { value .get(field) @@ -266,9 +348,8 @@ fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse } #[tauri::command] -fn host_bridge_request( +async fn host_bridge_request( app: tauri::AppHandle, - share_state: tauri::State<'_, DesktopShareState>, request: HostBridgeRequest, ) -> HostBridgeResponse { if let Some(response) = validate_request(&request) { @@ -307,6 +388,42 @@ fn host_bridge_request( 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, + }), + ) + } "app.setTitle" => { let title = match required_string_payload(&request, "title") .ok() @@ -332,16 +449,19 @@ fn host_bridge_request( let Some(target) = target else { return failed(request.id, "invalid_request", "target is required"); }; + let share_state = app.state::(); - match share_state.target.lock() { + 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, @@ -365,6 +485,7 @@ fn main() { tauri::Builder::default() .manage(DesktopShareState::default()) .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { let window_config = app.config().app.windows.get(0).cloned(); @@ -413,6 +534,10 @@ mod tests { .as_array() .unwrap() .contains(&json!("app.setTitle"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportText"))); } #[test] @@ -484,6 +609,72 @@ mod tests { ); } + #[test] + fn export_file_name_normalization_rejects_path_like_characters() { + assert_eq!( + normalize_export_file_name(" 作品:记录?.txt "), + "作品-记录-.txt" + ); + assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt"); + assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK); + + let long_file_name = "甲".repeat(140); + assert_eq!( + normalize_export_file_name(&long_file_name).chars().count(), + EXPORT_FILE_NAME_MAX_LENGTH + ); + } + + #[test] + fn export_text_payload_requires_text_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": 123 + })); + + let response = export_text_payload(&invalid).expect_err("invalid content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content is required"); + } + + #[test] + fn export_text_payload_rejects_oversized_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1) + })); + + let response = export_text_payload(&invalid).expect_err("oversized content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content exceeds file export size limit"); + } + + #[test] + fn write_export_text_file_persists_utf8_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-export-{}.txt", + std::process::id() + )); + + let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string()) + .expect("write export file"); + + assert_eq!(bytes, "暖灯猫街".len()); + assert_eq!( + fs::read_to_string(&path).expect("read export file"), + "暖灯猫街" + ); + fs::remove_file(path).expect("remove export file"); + } + #[test] fn share_text_uses_direct_share_payload() { let state = DesktopShareState::default(); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 03725f50..f275cc45 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "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,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText", + "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,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText", "frontendDist": "../../../dist" }, "app": { @@ -14,7 +14,7 @@ { "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,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText", + "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index c4383804..604c78a0 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -179,4 +179,17 @@ describe('handleMobileHostBridgeMessage', () => { expect(failedResponse.error.code).toBe('invalid_request'); expect(Linking.openURL).not.toHaveBeenCalled(); }); + + test('移动壳未接入真实导出能力时明确返回 unsupported', async () => { + const response = await send( + request('file.exportText', { + fileName: '作品记录.txt', + content: 'content', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_method'); + }); }); diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index 43823427..69387156 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -211,6 +211,7 @@ async function handleRequest(request: HostBridgeRequest) { return ok(request, openNativePage(request.payload)); case 'auth.requestLogin': case 'payment.request': + case 'file.exportText': return failure(request, unsupported(request.method)); default: return failure(request, unsupported(request.method)); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d9f96402..ee5a7d97 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。 - 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。 -- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板,`app.setTitle` 通过主窗口 API 同步非空窗口标题;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 +- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板,`app.setTitle` 通过主窗口 API 同步非空窗口标题;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 74283c0d..32b490d5 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -120,6 +120,7 @@ type HostBridgeEvent = { | `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 | | `app.setTitle` | 同步宿主窗口标题 | 不声明 | 支持 | | `clipboard.writeText` | 写剪贴板 | 可选 | 可选 | +| `file.exportText` | 导出文本到用户选择的本地文件 | 不声明 | 支持 | | `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 | 每个 method 都必须有明确 payload schema、超时、错误码和能力开关;H5 看到不支持时回退到现有浏览器路径。 @@ -240,7 +241,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`host.events`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;其中 `navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,`app.openExternalUrl` 只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议。登录和支付尚未接入渠道 SDK 时明确返回 unsupported,让 H5 fallback 承接。 +当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`host.events`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;其中 `navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,`app.openExternalUrl` 只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议。登录、支付和本地文件导出尚未接入渠道 SDK / 原生文档能力时明确返回 unsupported,让 H5 fallback 承接。 ### Phase 3:Tauri 桌面壳 MVP @@ -251,7 +252,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符;不把 opener 或 clipboard 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`app.openExternalUrl`、`app.setTitle` 和 `clipboard.writeText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。 +当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符;不把 opener、clipboard 或 dialog 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`app.openExternalUrl`、`app.setTitle`、`clipboard.writeText` 和 `file.exportText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消时返回 `cancelled`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。 ### Phase 4:宿主能力扩展 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 8d06472c..e029ae2a 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -45,6 +45,7 @@ AI H5 sandbox - `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。 - `openHostShareGrid()`:微信小程序九宫格切图页。 - `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 +- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件;文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数。Expo 移动壳当前不声明本地文件导出能力,误调时返回 unsupported,由 H5 浏览器下载或后续真实原生文档能力承接。 ## 迁移顺序 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 65b1d6bf..05e13c5a 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { normalizeHostBridgeExternalUrl } from './hostBridge'; +import { + normalizeHostBridgeExportFileName, + normalizeHostBridgeExternalUrl, +} from './hostBridge'; describe('HostBridge shared contract helpers', () => { test('只允许明确的外链协议交给宿主打开', () => { @@ -24,4 +27,19 @@ describe('HostBridge shared contract helpers', () => { ).toBeNull(); expect(normalizeHostBridgeExternalUrl('/relative/path')).toBeNull(); }); + + test('归一化宿主导出文件名', () => { + expect(normalizeHostBridgeExportFileName(' 作品:记录?.txt ')).toBe( + '作品-记录-.txt', + ); + expect(normalizeHostBridgeExportFileName('../secret.txt')).toBe( + 'secret.txt', + ); + expect(normalizeHostBridgeExportFileName('')).toBe( + 'genarrative-export.txt', + ); + expect(normalizeHostBridgeExportFileName('a'.repeat(140))).toHaveLength( + 120, + ); + }); }); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 64c52d48..75913ef2 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -23,6 +23,7 @@ export type HostBridgeMethod = | 'app.openExternalUrl' | 'app.setTitle' | 'clipboard.writeText' + | 'file.exportText' | 'haptics.impact'; export type HostBridgeCapability = @@ -143,6 +144,18 @@ export type ClipboardWriteTextPayload = { text: string; }; +export type FileExportTextPayload = { + fileName: string; + content: string; + mimeType?: string; +}; + +export type FileExportTextResult = { + action: 'saved'; + fileName: string; + bytes: number; +}; + export type HapticsImpactPayload = { style?: 'light' | 'medium' | 'heavy'; }; @@ -156,3 +169,34 @@ export type ShareOpenPayload = { message?: string; url?: string; }; + +const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt'; +const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120; + +function isHostBridgeInvalidFileNameCharacter(value: string) { + if (hasHostBridgeControlCharacter(value)) { + return true; + } + + return ['<', '>', ':', '"', '/', '\\', '|', '?', '*'].includes(value); +} + +export function normalizeHostBridgeExportFileName(rawFileName: unknown) { + if (typeof rawFileName !== 'string') { + return HOST_BRIDGE_FILE_NAME_FALLBACK; + } + + const fileName = rawFileName + .trim() + .split('') + .map((character) => + isHostBridgeInvalidFileNameCharacter(character) ? '-' : character, + ) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[.\s-]+/, '') + .slice(0, HOST_BRIDGE_FILE_NAME_MAX_LENGTH) + .trim(); + + return fileName || HOST_BRIDGE_FILE_NAME_FALLBACK; +} diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 58f7f3df..9fa3b859 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { canUseHostShareGrid, + exportHostTextFile, getHostRuntime, getNativeAppHostRuntime, isWechatMiniProgramWebViewRuntime, @@ -358,4 +359,91 @@ describe('hostBridge', () => { }), ).resolves.toBe(false); }); + + test('普通浏览器不处理宿主文本导出', async () => { + await expect( + exportHostTextFile({ + fileName: '作品记录.txt', + content: 'content', + }), + ).resolves.toBe(false); + }); + + test('原生 App 宿主通过 HostBridge 导出文本文件', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string; method: string } }) + .request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + action: 'saved', + fileName: '作品记录.txt', + bytes: 7, + }, + }; + }, + ); + window.history.replaceState(null, '', '/?clientRuntime=native_app'); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + await expect( + exportHostTextFile({ + fileName: '作品记录.txt', + content: 'content', + }), + ).resolves.toEqual({ + action: 'saved', + fileName: '作品记录.txt', + bytes: 7, + }); + + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'file.exportText', + payload: { + fileName: '作品记录.txt', + content: 'content', + }, + timeoutMs: 30000, + }), + }); + }); + + test('原生 App 宿主不支持文本导出时回退 H5', async () => { + window.history.replaceState(null, '', '/?clientRuntime=native_app'); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke( + vi.fn(async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: false, + error: { + code: 'unsupported_method', + message: 'unsupported_method', + }, + }; + }), + ), + }, + }; + + await expect( + exportHostTextFile({ + fileName: '作品记录.txt', + content: 'content', + }), + ).resolves.toBe(false); + }); }); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 5ed31a02..f8ff671b 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -1,4 +1,6 @@ import type { + FileExportTextPayload, + FileExportTextResult, HostBridgeMethod, HostBridgeRuntimeResult, } from '../../../packages/shared/src/contracts/hostBridge'; @@ -57,6 +59,8 @@ export type HostShareGridRequest = { publicWorkCode: string; }; +export type HostFileExportTextRequest = FileExportTextPayload; + function isUnsupportedHostBridgeError(error: unknown) { return ( error instanceof Error && @@ -444,3 +448,24 @@ export async function getNativeAppHostRuntime() { 'host.getRuntime', ); } + +export async function exportHostTextFile( + params: HostFileExportTextRequest, +) { + if (getHostRuntime().kind !== 'native_app') { + return false; + } + + try { + return await requestNativeAppHostBridge( + 'file.exportText', + params, + { timeoutMs: 30000 }, + ); + } catch (error) { + if (isUnsupportedHostBridgeError(error)) { + return false; + } + throw error; + } +}