收口原生导航桥接边界

将 Expo 外链打开和受控 WebView 导航收口到 navigation 模块

将 Tauri 外链打开和主窗口导航刷新收口到 navigation 模块

同步原生壳结构门禁、架构文档和共享记忆
This commit is contained in:
2026-06-19 22:01:57 +08:00
parent f04daee4a4
commit 177c18ddcd
11 changed files with 264 additions and 126 deletions
+37 -2
View File
@@ -86,6 +86,14 @@ const desktopHostBridgeDispatchSource = fs.readFileSync(
desktopHostBridgeDispatchPath,
'utf8',
);
const desktopHostBridgeNavigationPath = new URL(
'../src-tauri/src/host_bridge/navigation.rs',
import.meta.url,
);
const desktopHostBridgeNavigationSource = fs.readFileSync(
desktopHostBridgeNavigationPath,
'utf8',
);
const desktopHostBridgeNotificationsPath = new URL(
'../src-tauri/src/host_bridge/notifications.rs',
import.meta.url,
@@ -216,6 +224,7 @@ const expectedDesktopHostBridgeRustFiles = [
'dispatch.rs',
'files.rs',
'mod.rs',
'navigation.rs',
'notifications.rs',
'protocol.rs',
'share.rs',
@@ -1650,13 +1659,38 @@ if (
}
if (
!desktopHostBridgeDispatchSource.includes('open_normalized_desktop_external_url(&app, url)') ||
!desktopHostBridgeDispatchSource.includes(
'open_desktop_host_bridge_external_url(&app, &request)',
) ||
!desktopHostBridgeDispatchSource.includes(
'open_desktop_host_bridge_native_page(&app, &request)',
) ||
!desktopHostBridgeDispatchSource.includes(
'reload_desktop_host_bridge_webview(&app, &request)',
) ||
desktopHostBridgeDispatchSource.includes('open_normalized_desktop_external_url(&app, url)') ||
desktopHostBridgeDispatchSource.includes('normalize_external_url') ||
desktopHostBridgeDispatchSource.includes('normalize_native_page_url') ||
desktopHostBridgeDispatchSource.includes('app.opener().open_url(')
) {
throw new Error(
'desktop shell app.openExternalUrl must use the shared external opener helper',
'desktop shell navigation HostBridge methods must delegate to navigation module',
);
}
for (const snippet of [
'open_desktop_host_bridge_external_url',
'normalize_external_url',
'open_normalized_desktop_external_url(app, url)',
'open_desktop_host_bridge_native_page',
'normalize_native_page_url',
'window.navigate(url)',
'reload_desktop_host_bridge_webview',
'window.reload()',
]) {
if (!desktopHostBridgeNavigationSource.includes(snippet)) {
throw new Error(`desktop shell navigation module is missing ${snippet}`);
}
}
if (config.build?.frontendDist !== '../../../dist') {
throw new Error('desktop shell must package the root H5 dist');
@@ -1828,6 +1862,7 @@ const expectedRustHostBridgeFiles = [
'dispatch.rs',
'files.rs',
'mod.rs',
'navigation.rs',
'notifications.rs',
'protocol.rs',
'share.rs',
@@ -7,16 +7,17 @@ use crate::host_bridge::files::{
import_document_file_payload, import_image_file_payload, import_text_file_payload,
write_export_bytes_file, write_export_text_file,
};
use crate::host_bridge::navigation::{
open_desktop_host_bridge_external_url, open_desktop_host_bridge_native_page,
reload_desktop_host_bridge_webview,
};
use crate::host_bridge::notifications::show_desktop_local_notification;
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::{
desktop_platform, normalize_external_url, normalize_native_page_url,
open_normalized_desktop_external_url, resolve_desktop_network_status,
};
use crate::shell::webview::{desktop_platform, resolve_desktop_network_status};
use serde_json::json;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
@@ -67,56 +68,11 @@ pub(super) async fn execute_host_bridge_request(
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 open_normalized_desktop_external_url(&app, url) {
Ok(()) => ok(request.id, json!(true)),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
open_desktop_host_bridge_external_url(&app, &request)
}
"appearance.getColorScheme" => desktop_appearance_color_scheme(&app, &request),
"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"),
},
"navigation.openNativePage" => open_desktop_host_bridge_native_page(&app, &request),
"app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request),
"clipboard.writeText" => {
let text = match required_string_payload(&request, "text") {
Ok(text) => text,
@@ -4,6 +4,7 @@ mod badge;
mod clipboard;
mod dispatch;
pub(crate) mod files;
mod navigation;
mod notifications;
pub(crate) mod protocol;
mod share;
@@ -0,0 +1,72 @@
use crate::host_bridge::protocol::{
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
};
use crate::shell::webview::{
normalize_external_url, normalize_native_page_url, open_normalized_desktop_external_url,
};
use serde_json::json;
use tauri::Manager;
pub(crate) fn open_desktop_host_bridge_external_url(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let url = match required_string_payload(request, "url")
.ok()
.and_then(normalize_external_url)
{
Some(url) => url,
None => {
return failed(
request.id.clone(),
"invalid_request",
"url must use an allowed external protocol",
)
}
};
match open_normalized_desktop_external_url(app, url) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
}
}
pub(crate) fn open_desktop_host_bridge_native_page(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let url = match required_string_payload(request, "url")
.ok()
.and_then(normalize_native_page_url)
{
Some(url) => url,
None => {
return failed(
request.id.clone(),
"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.clone(), json!(true)),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
},
None => failed(request.id.clone(), "host_error", "main window not found"),
}
}
pub(crate) fn reload_desktop_host_bridge_webview(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
match app.get_webview_window("main") {
Some(window) => match window.reload() {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
},
None => failed(request.id.clone(), "host_error", "main window not found"),
}
}
+39 -3
View File
@@ -22,6 +22,14 @@ const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url);
const hapticsSource = fs.readFileSync(hapticsPath, 'utf8');
const hostBridgeNavigationPath = new URL(
'../src/host-bridge/navigation.ts',
import.meta.url,
);
const hostBridgeNavigationSource = fs.readFileSync(
hostBridgeNavigationPath,
'utf8',
);
const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.meta.url);
const notificationsSource = fs.readFileSync(notificationsPath, 'utf8');
const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url);
@@ -87,6 +95,7 @@ const requiredMobileShellSourceModules = [
'host-bridge/dispatch.ts',
'host-bridge/files.ts',
'host-bridge/haptics.ts',
'host-bridge/navigation.ts',
'host-bridge/notifications.ts',
'host-bridge/protocol.ts',
'host-bridge/scanner.ts',
@@ -1621,10 +1630,12 @@ assertMobileDocumentPickerBoundary(
);
if (
!hostBridgeSource.includes(
!hostBridgeNavigationSource.includes(
'const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(',
) ||
!hostBridgeSource.includes('openMobileShellExternalNavigation(Linking, externalUrlPayload.url)')
!hostBridgeNavigationSource.includes(
'openMobileShellExternalNavigation(navigator, externalUrlPayload.url)',
)
) {
throw new Error(
'mobile shell app.openExternalUrl must normalize payloads and use the shared external navigation helper',
@@ -1855,7 +1866,32 @@ for (const snippet of [
}
}
if (!dispatchSource.includes('openMobileShellExternalNavigation(Linking, externalUrlPayload.url)')) {
if (
!dispatchSource.includes('openMobileHostBridgeExternalUrl(Linking, request.payload)') ||
!dispatchSource.includes('openMobileHostBridgeNativePage(navigation, request.payload)') ||
!dispatchSource.includes('reloadMobileHostBridgeWebView(navigation)') ||
dispatchSource.includes('openMobileShellExternalNavigation') ||
dispatchSource.includes('resolveMobileShellWebViewUrl') ||
dispatchSource.includes('normalizeHostBridgeExternalUrlPayload') ||
dispatchSource.includes('buildMobileShellUrl')
) {
throw new Error('mobile shell navigation HostBridge methods must delegate to navigation module');
}
for (const snippet of [
'openMobileHostBridgeExternalUrl',
'openMobileShellExternalNavigation(navigator, externalUrlPayload.url)',
'normalizeHostBridgeExternalUrlPayload',
'openMobileHostBridgeNativePage',
'resolveMobileShellWebViewUrl',
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
'reloadMobileHostBridgeWebView',
]) {
if (!hostBridgeNavigationSource.includes(snippet)) {
throw new Error(`mobile shell navigation module is missing ${snippet}`);
}
}
if (!hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(navigator, externalUrlPayload.url)')) {
throw new Error(
'mobile shell HostBridge external URL flow must use the shared external navigation helper',
);
+14 -63
View File
@@ -5,17 +5,9 @@ import {
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeRequest,
type NavigateNativePagePayload,
normalizeHostBridgeExternalUrlPayload,
normalizeHostBridgeLocalNotification,
type OpenExternalUrlPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
openMobileShellExternalNavigation,
resolveMobileShellWebViewUrl,
} from '../shell/navigation';
import { getMobileNetworkStatus } from '../shell/network';
import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime';
import { resolveMobileHostCapabilities } from './capabilities';
@@ -38,7 +30,6 @@ import {
} from './protocol';
import { resetQrScannerForTest, scanQrCode } from './scanner';
import { openShare } from './share';
import { buildMobileShellUrl } from '../shell/url';
import { showMobileLocalNotification } from './notifications';
import {
readMobileClipboardText,
@@ -47,6 +38,11 @@ import {
import { runMobileHapticsImpact } from './haptics';
import { setMobileAppBadgeCount } from './badge';
import { getMobileAppearanceColorScheme } from './appearance';
import {
openMobileHostBridgeExternalUrl,
openMobileHostBridgeNativePage,
reloadMobileHostBridgeWebView,
} from './navigation';
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
@@ -57,24 +53,6 @@ export function configureMobileHostBridgeNavigation(
navigation = nextNavigation;
}
async function openExternalUrl(payload: unknown) {
const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!externalUrlPayload) {
throw invalidRequest('url must use an allowed external protocol');
}
if (!(await openMobileShellExternalNavigation(Linking, externalUrlPayload.url))) {
throw {
code: 'host_error',
message: 'external URL cannot be opened',
} satisfies HostBridgeError;
}
return true;
}
async function writeClipboard(payload: unknown) {
if (
!(await writeMobileClipboardText(
@@ -123,39 +101,6 @@ function getRuntime() {
};
}
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(
buildMobileShellUrl(webViewUrl, navigation.urlOptions),
);
return true;
}
function reloadWebView() {
if (!navigation) {
throw unsupported('app.reloadWebView');
}
navigation.reloadWebView();
return true;
}
export async function dispatchMobileHostBridgeRequest(
request: HostBridgeRequest,
) {
@@ -165,9 +110,12 @@ export async function dispatchMobileHostBridgeRequest(
case 'appearance.getColorScheme':
return ok(request, getMobileAppearanceColorScheme());
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
return ok(
request,
await openMobileHostBridgeExternalUrl(Linking, request.payload),
);
case 'app.reloadWebView':
return ok(request, reloadWebView());
return ok(request, reloadMobileHostBridgeWebView(navigation));
case 'network.status':
return ok(request, await getMobileNetworkStatus());
case 'clipboard.writeText':
@@ -207,7 +155,10 @@ export async function dispatchMobileHostBridgeRequest(
: null;
return ok(request, true);
case 'navigation.openNativePage':
return ok(request, openNativePage(request.payload));
return ok(
request,
openMobileHostBridgeNativePage(navigation, request.payload),
);
case 'auth.requestLogin':
case 'payment.request':
return failure(request, unsupported(request.method));
@@ -0,0 +1,78 @@
import {
type HostBridgeError,
type NavigateNativePagePayload,
normalizeHostBridgeExternalUrlPayload,
type OpenExternalUrlPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
type MobileShellExternalNavigator,
openMobileShellExternalNavigation,
resolveMobileShellWebViewUrl,
} from '../shell/navigation';
import { buildMobileShellUrl } from '../shell/url';
import {
type MobileHostBridgeNavigation,
invalidRequest,
unsupported,
} from './protocol';
export async function openMobileHostBridgeExternalUrl(
navigator: MobileShellExternalNavigator,
payload: unknown,
) {
const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!externalUrlPayload) {
throw invalidRequest('url must use an allowed external protocol');
}
if (
!(await openMobileShellExternalNavigation(navigator, externalUrlPayload.url))
) {
throw {
code: 'host_error',
message: 'external URL cannot be opened',
} satisfies HostBridgeError;
}
return true;
}
export function openMobileHostBridgeNativePage(
navigation: MobileHostBridgeNavigation | null,
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(
buildMobileShellUrl(webViewUrl, navigation.urlOptions),
);
return true;
}
export function reloadMobileHostBridgeWebView(
navigation: MobileHostBridgeNavigation | null,
) {
if (!navigation) {
throw unsupported('app.reloadWebView');
}
navigation.reloadWebView();
return true;
}
@@ -2785,3 +2785,10 @@
- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS`,作为桌面壳主站可达性探测超时的声明来源;Tauri Rust 侧保留同名职责镜像 `DESKTOP_NETWORK_CHECK_TIMEOUT_MS`,由 `apps/desktop-shell/scripts/check-config.mjs` 反查共享值并拒绝漂移。该边界只服务桌面宿主内部网络状态,不新增 H5 任意网络探测能力。
- 影响范围:`packages/shared/src/contracts/hostBridge.ts``apps/desktop-shell/src-tauri/src/shell/network.rs``apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-19 原生壳导航桥接边界
- 背景:`app.openExternalUrl``navigation.openNativePage``app.reloadWebView` 已是受控 HostBridge 能力,但移动端和桌面端 `dispatch` 仍直接承接外链归一、同源 H5 跳转、宿主上下文补写和 WebView 刷新细节,后续继续补壳能力时容易让分发层重新变厚。
- 决策:Expo 移动壳新增 `apps/mobile-shell/src/host-bridge/navigation.ts`,统一承接 HostBridge 外链打开、受控 H5 跳转和 WebView 刷新,底层继续复用 `src/shell/navigation.ts``src/shell/url.ts`Tauri 桌面壳新增 `apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`,统一承接外链打开、同源 H5 跳转和主窗口刷新,底层继续复用 `shell::navigation` 的 URL 归一和宿主上下文补写。两端 `dispatch` 只保留 method 委托和响应包装,配置检查会拒绝分发层直接调用外链 opener、URL 归一或 WebView navigate / reload 细节。
- 影响范围:`apps/mobile-shell/src/host-bridge/navigation.ts``apps/mobile-shell/src/host-bridge/dispatch.ts``apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs``apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs``apps/desktop-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run mobile-shell:typecheck``npm run mobile-shell:test -- src/host-bridge/bridge.test.ts``npm run desktop-shell:typecheck``cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml``npm run check:native-shells``npm run check:encoding``git diff --check`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -258,6 +258,7 @@ const expectedMobileHostBridgeFiles = [
'dispatch.ts',
'files.ts',
'haptics.ts',
'navigation.ts',
'notifications.ts',
'protocol.ts',
'scanner.ts',
@@ -301,6 +302,7 @@ const expectedDesktopHostBridgeRustFiles = [
'dispatch.rs',
'files.rs',
'mod.rs',
'navigation.rs',
'notifications.rs',
'protocol.rs',
'share.rs',