桌面壳补齐返回栈状态事件

为 Tauri 壳声明 navigation.canGoBack 能力

注入固定脚本追踪 H5 当前文档路由栈并回放 HostBridge 事件

补充 H5 返回栈事件订阅 facade 与测试

更新原生壳门禁、方案文档和共享决策
This commit is contained in:
2026-06-18 21:50:18 +08:00
parent 49b0f8c445
commit 13f1738d9c
11 changed files with 283 additions and 9 deletions
@@ -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',
@@ -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",
+5 -3
View File
@@ -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);
@@ -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)]
@@ -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<String> {
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<String, serde_json::Error> {
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__"));
}
}
@@ -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,
+2 -2
View File
@@ -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,
@@ -37,6 +37,7 @@
- 2026-06-18 原生壳网络状态:新增 `network.status``network.statusChanged` HostBridge capabilityExpo 壳通过 `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 capabilityTauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `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`
File diff suppressed because one or more lines are too long
+109
View File
@@ -40,6 +40,7 @@ import {
showHostLocalNotification,
subscribeHostAppLifecycle,
subscribeHostImageDrop,
subscribeHostNavigationCanGoBack,
subscribeHostNetworkStatusChange,
subscribeHostRuntimeChange,
writeHostClipboardText,
@@ -202,6 +203,114 @@ describe('hostBridge', () => {
});
});
test('订阅原生 App 返回栈状态事件并归一化 payload', () => {
const listener = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['host.events', 'navigation.canGoBack']),
);
const unsubscribe = subscribeHostNavigationCanGoBack(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: 'yes',
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribe();
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: false,
},
}),
origin: window.location.origin,
source: window,
}),
);
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenNthCalledWith(1, { canGoBack: true });
expect(listener).toHaveBeenNthCalledWith(2, { canGoBack: false });
});
test('未声明事件通道和返回栈状态能力时不订阅宿主事件', () => {
const listener = vi.fn();
window.history.replaceState(null, '', nativeAppPath(['host.events']));
const unsubscribe = subscribeHostNavigationCanGoBack(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribe();
expect(listener).not.toHaveBeenCalled();
window.history.replaceState(
null,
'',
nativeAppPath(['navigation.canGoBack']),
);
const unsubscribeWithoutEvents = subscribeHostNavigationCanGoBack(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeWithoutEvents();
expect(listener).not.toHaveBeenCalled();
});
test('未声明生命周期能力时不订阅原生事件', () => {
const listener = vi.fn();
window.history.replaceState(null, '', nativeAppPath());
+23
View File
@@ -19,6 +19,7 @@ import type {
HostBridgeRuntimeResult,
HostBridgeTextMimeType,
LocalNotificationPayload,
NavigationCanGoBackEventPayload,
NetworkStatusResult,
OpenExternalUrlPayload,
SetBadgeCountPayload,
@@ -132,6 +133,8 @@ export type HostAppLifecycleSnapshot = AppLifecycleEventPayload;
export type HostNetworkStatusSnapshot = NetworkStatusResult;
export type HostNavigationCanGoBackSnapshot = NavigationCanGoBackEventPayload;
export type HostImageDropSnapshot = FileImportImageResult;
const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
@@ -1176,6 +1179,26 @@ export function subscribeHostNetworkStatusChange(
);
}
export function subscribeHostNavigationCanGoBack(
listener: (payload: HostNavigationCanGoBackSnapshot) => void,
) {
if (
!canUseNativeHostCapability('host.events') ||
!canUseNativeHostCapability('navigation.canGoBack')
) {
return () => undefined;
}
return subscribeNativeAppHostBridgeEvent<NavigationCanGoBackEventPayload>(
'navigation.canGoBack',
(payload) => {
listener({
canGoBack: payload?.canGoBack === true,
});
},
);
}
export function subscribeHostImageDrop(
listener: (payload: HostImageDropSnapshot) => void,
) {