From 13f1738d9cb7145a1432615f9dd03be97e932636 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 21:50:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=A3=B3=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=A0=88=E7=8A=B6=E6=80=81=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Tauri 壳声明 navigation.canGoBack 能力 注入固定脚本追踪 H5 当前文档路由栈并回放 HostBridge 事件 补充 H5 返回栈事件订阅 facade 与测试 更新原生壳门禁、方案文档和共享决策 --- apps/desktop-shell/scripts/check-config.mjs | 9 ++ .../src-tauri/src/host_bridge/capabilities.rs | 2 + apps/desktop-shell/src-tauri/src/main.rs | 8 +- .../src-tauri/src/shell/lifecycle.rs | 2 + .../src-tauri/src/shell/navigation.rs | 127 +++++++++++++++++- .../src-tauri/src/shell/webview.rs | 3 +- apps/desktop-shell/src-tauri/tauri.conf.json | 4 +- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- src/services/host-bridge/hostBridge.test.ts | 109 +++++++++++++++ src/services/host-bridge/hostBridge.ts | 23 ++++ 11 files changed, 283 insertions(+), 9 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index d013d7df3..e7679ea05 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1144,6 +1144,7 @@ const requiredRustHostSnippets = [ '"share.open"', '"share.setTarget"', '"navigation.openNativePage"', + '"navigation.canGoBack"', '"app.reloadWebView"', '"app.setTitle"', '"app.setBadgeCount"', @@ -1205,6 +1206,14 @@ const requiredRustHostSnippets = [ 'window.is_focused()', 'resolve_desktop_network_status', 'network.statusChanged', + 'register_desktop_navigation_events', + 'desktop_navigation_state_script', + 'navigation.canGoBack', + '__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__', + 'window.history.pushState', + 'window.history.replaceState', + "window.addEventListener('popstate'", + '__genarrativeDesktopHistoryIndex', 'file.imageDropped', 'app.notification().builder()', 'desktop_entry_url_with_platform', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs index 70a35c070..a37c49eb0 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs @@ -7,6 +7,7 @@ pub(crate) fn capabilities() -> Vec<&'static str> { "share.open", "share.setTarget", "navigation.openNativePage", + "navigation.canGoBack", "app.reloadWebView", "app.openExternalUrl", "app.setTitle", @@ -43,6 +44,7 @@ mod tests { "share.open", "share.setTarget", "navigation.openNativePage", + "navigation.canGoBack", "app.reloadWebView", "app.openExternalUrl", "app.setTitle", diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 6e6e6b5e7..16c7865fc 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -11,9 +11,10 @@ use shell::tray::{ use shell::webview::{ desktop_window_config_with_runtime_platform, emit_desktop_lifecycle_event, open_desktop_external_navigation, register_desktop_file_drop_events, - register_desktop_lifecycle_events, register_desktop_network_events, - replay_desktop_webview_state, should_allow_desktop_webview_download, - should_allow_desktop_webview_navigation, should_replay_desktop_webview_state_on_page_load, + register_desktop_lifecycle_events, register_desktop_navigation_events, + register_desktop_network_events, replay_desktop_webview_state, + should_allow_desktop_webview_download, should_allow_desktop_webview_navigation, + should_replay_desktop_webview_state_on_page_load, }; use shell::window_state::desktop_window_state_plugin; use tauri::webview::NewWindowResponse; @@ -73,6 +74,7 @@ fn main() { register_desktop_lifecycle_events(&window); let _ = emit_desktop_lifecycle_event(&window, "active", true, "created"); let _ = register_desktop_network_events(&window); + let _ = register_desktop_navigation_events(&window); register_desktop_file_drop_events(&window); register_desktop_deep_link_events(app)?; register_desktop_deep_link_schemes(app); diff --git a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs index 082203b93..6b230dc97 100644 --- a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs +++ b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs @@ -1,4 +1,5 @@ use crate::shell::events::host_bridge_event_script; +use crate::shell::navigation::register_desktop_navigation_events; use crate::shell::network::register_desktop_network_events; use serde_json::json; use tauri::webview::PageLoadEvent; @@ -51,6 +52,7 @@ pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { let _ = emit_desktop_lifecycle_event(window, state, focused, native_state); let _ = register_desktop_network_events(window); + let _ = register_desktop_navigation_events(window); } #[cfg(test)] diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs index 2b532ed8e..74438204c 100644 --- a/apps/desktop-shell/src-tauri/src/shell/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -1,6 +1,8 @@ +use crate::shell::events::host_bridge_event_script; use crate::shell::url::WEB_APP_ORIGIN; +use serde_json::{json, Value}; use tauri::webview::DownloadEvent; -use tauri::Url; +use tauri::{Url, WebviewWindow}; use tauri_plugin_opener::OpenerExt; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; @@ -32,6 +34,98 @@ pub(crate) fn normalize_external_url(raw_url: &str) -> Option { Some(url.to_string()) } +pub(crate) fn desktop_navigation_can_go_back_payload(can_go_back: bool) -> Value { + json!({ + "canGoBack": can_go_back, + }) +} + +pub(crate) fn desktop_navigation_state_script() -> Result { + let can_go_back_script = host_bridge_event_script( + "navigation.canGoBack", + desktop_navigation_can_go_back_payload(true), + )?; + let cannot_go_back_script = host_bridge_event_script( + "navigation.canGoBack", + desktop_navigation_can_go_back_payload(false), + )?; + + // 中文注释:桌面壳只追踪当前 H5 文档内由应用写入的 history index,不读取平台私有 back stack。 + Ok(format!( + "(() => {{ + if (window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__) {{ + if (typeof window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__ === 'function') {{ + window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__(); + }} + return true; + }} + + const historyIndexKey = '__genarrativeDesktopHistoryIndex'; + const readIndex = (state) => {{ + if (!state || typeof state !== 'object') {{ + return null; + }} + const value = state[historyIndexKey]; + return Number.isInteger(value) && value >= 0 ? value : null; + }}; + const stateWithIndex = (state, index) => {{ + if (state && typeof state === 'object' && !Array.isArray(state)) {{ + return {{ ...state, [historyIndexKey]: index }}; + }} + return {{ [historyIndexKey]: index }}; + }}; + const originalPushState = window.history.pushState.bind(window.history); + const originalReplaceState = window.history.replaceState.bind(window.history); + let currentIndex = readIndex(window.history.state) ?? 0; + const emitCanGoBack = () => {{ {} }}; + const emitCannotGoBack = () => {{ {} }}; + const emitNavigationState = () => {{ + if (currentIndex > 0) {{ + emitCanGoBack(); + }} else {{ + emitCannotGoBack(); + }} + }}; + const replaceCurrentState = () => {{ + try {{ + originalReplaceState(stateWithIndex(window.history.state, currentIndex), '', window.location.href); + }} catch (_error) {{}} + }}; + + window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__ = emitNavigationState; + window.history.pushState = (state, title, url) => {{ + const nextIndex = currentIndex + 1; + const result = originalPushState(stateWithIndex(state, nextIndex), title, url); + currentIndex = nextIndex; + emitNavigationState(); + return result; + }}; + window.history.replaceState = (state, title, url) => {{ + const result = originalReplaceState(stateWithIndex(state, currentIndex), title, url); + emitNavigationState(); + return result; + }}; + window.addEventListener('popstate', (event) => {{ + const nextIndex = readIndex(event.state); + currentIndex = nextIndex ?? 0; + if (nextIndex === null) {{ + replaceCurrentState(); + }} + emitNavigationState(); + }}); + window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__ = true; + replaceCurrentState(); + emitNavigationState(); + return true; + }})();", + can_go_back_script, cannot_go_back_script + )) +} + +pub(crate) fn register_desktop_navigation_events(window: &WebviewWindow) -> tauri::Result<()> { + window.eval(desktop_navigation_state_script().map_err(tauri::Error::Json)?) +} + fn is_desktop_packaged_asset_url(url: &Url) -> bool { if url.scheme() == "tauri" { return true; @@ -213,4 +307,35 @@ mod tests { None ); } + + #[test] + fn desktop_navigation_can_go_back_payload_reports_boolean_state() { + assert_eq!( + desktop_navigation_can_go_back_payload(true), + json!({ + "canGoBack": true, + }) + ); + assert_eq!( + desktop_navigation_can_go_back_payload(false), + json!({ + "canGoBack": false, + }) + ); + } + + #[test] + fn desktop_navigation_state_script_tracks_h5_history_changes() { + let script = desktop_navigation_state_script().expect("navigation state script"); + + assert!(script.contains("navigation.canGoBack")); + assert!(script.contains("window.history.pushState")); + assert!(script.contains("window.history.replaceState")); + assert!(script.contains("window.addEventListener('popstate'")); + assert!(script.contains("__genarrativeDesktopHistoryIndex")); + assert!(script.contains("(currentIndex > 0)")); + assert!(script.contains("\\\"canGoBack\\\":true")); + assert!(script.contains("\\\"canGoBack\\\":false")); + assert!(script.contains("window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__")); + } } diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index ceacc786c..cbeaaf8ee 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -5,7 +5,8 @@ pub(crate) use crate::shell::lifecycle::{ }; pub(crate) use crate::shell::navigation::{ normalize_external_url, normalize_native_page_url, open_desktop_external_navigation, - should_allow_desktop_webview_download, should_allow_desktop_webview_navigation, + register_desktop_navigation_events, should_allow_desktop_webview_download, + should_allow_desktop_webview_navigation, }; pub(crate) use crate::shell::network::{ register_desktop_network_events, resolve_desktop_network_status, diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 80422189e..76d9a5381 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,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", + "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,appearance.getColorScheme,host.events,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,navigation.canGoBack,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", "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,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", + "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,host.events,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,navigation.canGoBack,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", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 58097564e..d47735768 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -37,6 +37,7 @@ - 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。 - 2026-06-18 移动壳 WebView 状态重放:Expo WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、受控刷新、H5 刷新和系统回收 WebView 进程后的新 JS 上下文;补发不新增 HostBridge capability,也不向 `about:blank`、外域或错误页注入宿主状态。 - 2026-06-18 桌面壳 WebView 状态重放:Tauri 主 WebView 每次页面加载完成后都会回放当前 `app.lifecycle` 并重新安装 `network.statusChanged` 监听脚本,覆盖托盘刷新、`app.reloadWebView` 和 H5 自刷新后的新 JS 上下文;桌面 runtime 同步声明 `host.events` 表示该真实事件通道可用,但仍不开放 Tauri event 插件或额外 command。 +- 2026-06-18 桌面壳 H5 返回栈事件:Tauri 壳开始声明 `navigation.canGoBack`,但只通过固定注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈并派发 HostBridge event;不把该能力实现为 request method,不开放 H5 到 Tauri 的 event 写入通道,也不声明跨文档 native back-forward list 真相。 - 2026-06-18 外部生成队列轮询接入宿主网络状态:H5 新增 `useHostNetworkOnline()`,宿主未声明网络能力时按在线处理以保持浏览器和旧壳行为;宿主明确 `isConnected=false` 或 `isInternetReachable=false` 时,平台外部生成队列概览暂停 HTTP 轮询,恢复在线后重新刷新。该能力只减少离线请求,不改变外部生成队列、作品架、弹窗或后端任务状态事实。 - 2026-06-18 桌面图片导入:新增 `file.importImage` 与 `file.imageDropped` HostBridge capability,Tauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。 - 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index cef98d5e2..6bba0aac3 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -124,7 +124,7 @@ type HostBridgeEvent = { | `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持同源 H5 route | | `app.reloadWebView` | 受控刷新当前 WebView | 支持 WebView reload | 支持主窗口 reload | | `app.lifecycle` | 通知 H5 宿主前后台 / 焦点状态 | 支持 AppState 事件 | 支持窗口 focus / blur 事件 | -| `navigation.canGoBack` | 通知 H5 宿主返回栈状态 | 支持事件 | 不声明 | +| `navigation.canGoBack` | 通知 H5 宿主返回栈状态 | 支持事件 | 支持 H5 当前文档路由栈事件 | | `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 | | `app.setTitle` | 同步宿主窗口标题 | 不声明 | 支持 | | `app.setBadgeCount` | 设置应用 / 任务栏角标 | 仅 iOS 支持,Android 不声明 | 支持;平台底层不支持时返回错误 | @@ -336,7 +336,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `