收口宿主事件白名单

新增共享 HostBridge event 白名单与类型守卫

让 H5、Expo 和 Tauri 只分发白名单宿主事件

补齐移动端、桌面端和根级原生壳门禁

更新宿主壳方案文档和项目决策记录
This commit is contained in:
2026-06-19 02:18:36 +08:00
parent 75cd4f1abc
commit 429e65952d
12 changed files with 192 additions and 16 deletions
@@ -929,6 +929,10 @@ const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const sharedEvents = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
const sharedDesktopCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
@@ -1046,6 +1050,7 @@ const desktopHostBridgePayloadLimits = {
),
};
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
const desktopEvents = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_EVENTS');
const desktopHostBridgeProtocol = extractRustStringConst(
rustHostSource,
'HOST_BRIDGE_PROTOCOL',
@@ -1140,6 +1145,12 @@ assertSameList(
);
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
assertSameList(desktopEvents, sharedEvents, 'desktop shell HostBridge event whitelist');
for (const eventName of sharedEvents) {
if (!sharedCapabilities.includes(eventName)) {
throw new Error(`shared HostBridge event must also be a capability: ${eventName}`);
}
}
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
(method) => !sharedMethods.includes(method),
);
@@ -1486,6 +1497,7 @@ const requiredRustHostSnippets = [
'.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())',
'PageLoadEvent',
'host_bridge_event_script',
'is_host_bridge_event_name',
'origin: window.location.origin',
'source: window',
'should_replay_desktop_webview_state_on_page_load',
@@ -1557,6 +1569,7 @@ if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
}
for (const snippet of [
'function createNativeHostBridgeTimeoutError()',
'isHostBridgeEventName(candidate.event)',
'async function invokeTauriHostBridgeWithTimeout',
'Promise.race',
'tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND',
@@ -1,10 +1,28 @@
use crate::host_bridge::protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION};
use serde_json::{json, Value};
const HOST_BRIDGE_EVENTS: [&str; 4] = [
"app.lifecycle",
"network.statusChanged",
"navigation.canGoBack",
"file.imageDropped",
];
fn is_host_bridge_event_name(event: &str) -> bool {
HOST_BRIDGE_EVENTS.contains(&event)
}
pub(crate) fn host_bridge_event_script(
event: &str,
payload: Value,
) -> Result<String, serde_json::Error> {
if !is_host_bridge_event_name(event) {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown HostBridge event: {}", event),
)));
}
let message = json!({
"bridge": HOST_BRIDGE_PROTOCOL,
"version": HOST_BRIDGE_VERSION,
@@ -45,4 +63,12 @@ mod tests {
assert!(script.contains("\\\"state\\\":\\\"active\\\""));
assert!(script.contains("\\\"focused\\\":true"));
}
#[test]
fn host_bridge_event_script_rejects_unknown_events() {
let error =
host_bridge_event_script("unknown.event", json!({})).expect_err("unknown event");
assert!(error.to_string().contains("unknown HostBridge event"));
}
}
@@ -481,6 +481,10 @@ const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const sharedEvents = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
const sharedMobileBaseCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
@@ -571,6 +575,15 @@ if (
throw new Error('mobile shell must not redeclare HostBridge capability profiles');
}
const unknownSharedEvents = sharedEvents.filter(
(eventName) => !sharedCapabilities.includes(eventName),
);
if (unknownSharedEvents.length > 0) {
throw new Error(
`shared HostBridge events must also be capabilities: ${unknownSharedEvents.join(', ')}`,
);
}
const unknownHandledMobileMethods = handledMobileMethods.filter(
(method) => !sharedMethods.includes(method),
);
@@ -878,6 +891,26 @@ for (const snippet of [
}
}
for (const snippet of [
'type HostBridgeEventName',
'(event: HostBridgeEventName, payload: unknown)',
]) {
if (!shellAppSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge event injection missing ${snippet}`);
}
}
for (const eventName of sharedEvents) {
if (
mobileCapabilitySet.has(eventName) &&
!shellAppSource.includes(`'${eventName}'`)
) {
throw new Error(
`mobile shell advertises HostBridge event ${eventName} but ShellApp does not inject it`,
);
}
}
for (const snippet of [
'MobileShellLoadFailureInput',
'normalizeMobileShellLoadFailure',
+14 -10
View File
@@ -16,6 +16,7 @@ import type { WebViewMessageEvent } from 'react-native-webview';
import { WebView } from 'react-native-webview';
import {
type HostBridgeEventName,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
} from '../../../../packages/shared/src/contracts/hostBridge';
@@ -98,16 +99,19 @@ export default function ShellApp() {
const reloadCurrentWebView = useCallback(() => {
webViewRef.current?.reload();
}, []);
const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => {
webViewRef.current?.injectJavaScript(
buildHostBridgeMessageScript({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
event,
payload,
}),
);
}, []);
const injectHostBridgeEvent = useCallback(
(event: HostBridgeEventName, payload: unknown) => {
webViewRef.current?.injectJavaScript(
buildHostBridgeMessageScript({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
event,
payload,
}),
);
},
[],
);
const injectLifecycleEvent = useCallback(
(state: AppStateStatus) => {
injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state));