桌面壳补齐返回栈状态事件
为 Tauri 壳声明 navigation.canGoBack 能力 注入固定脚本追踪 H5 当前文档路由栈并回放 HostBridge 事件 补充 H5 返回栈事件订阅 facade 与测试 更新原生壳门禁、方案文档和共享决策
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user