统一三端桥接层结构

Tauri入口main.rs收敛为应用装配

桌面HostBridge按协议分发文件分享WebView和托盘职责拆分模块

微信小程序桥接逻辑迁入host-bridge目录并保留页面装配

原生壳检查锁定微信移动桌面桥接层文件结构

宿主壳文档和共享决策记录三端桥接层结构约定
This commit is contained in:
2026-06-18 15:42:10 +08:00
parent e8713a396b
commit e92e016549
24 changed files with 3021 additions and 2764 deletions
+36 -11
View File
@@ -36,6 +36,7 @@ const nativeAppHostBridgePath = new URL(
const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8');
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
const main = fs.readFileSync(mainPath, 'utf8');
const rustSourceDir = new URL('../src-tauri/src/', import.meta.url);
const productionSourceRoots = [
new URL('../package.json', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
@@ -261,7 +262,7 @@ function assertNoBlockedDesktopSdkSnippets() {
const sources = [
['tauri.conf.json', JSON.stringify(config)],
['build.rs', buildScript],
['main.rs', main],
['src-tauri/src', rustHostSource],
];
for (const [sourceName, source] of sources) {
@@ -392,6 +393,14 @@ function collectProductionSourceFiles(entry) {
return [entry];
}
const productionSourceFiles = productionSourceRoots.flatMap((root) =>
collectProductionSourceFiles(root),
);
const rustHostSourceFiles = collectProductionSourceFiles(rustSourceDir);
const rustHostSource = rustHostSourceFiles
.map((file) => fs.readFileSync(file, 'utf8'))
.join('\n');
function assertNoDevScaffoldTerms(files) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
@@ -415,9 +424,7 @@ function assertNoDevScaffoldTerms(files) {
}
}
assertNoDevScaffoldTerms(
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
);
assertNoDevScaffoldTerms(productionSourceFiles);
assertNoBlockedNpmDependencies();
assertNoTauriGuestNpmDependencies(packageConfig, 'desktop shell package');
assertNoTauriGuestNpmDependencies(rootPackageConfig, 'root H5 package');
@@ -721,6 +728,10 @@ function extractTauriInvokeCommands(source) {
.filter(Boolean);
}
function collectRustSourceBasenames(files) {
return files.map((file) => file.pathname.split('/').pop()).sort();
}
function extractNativeAppTauriInvokeCommands(source) {
return [...source.matchAll(/\btauriInvoke[\s\S]*?\(\s*([^,\n]+?)\s*,/g)]
.map((match) => match[1].trim())
@@ -785,9 +796,9 @@ const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const desktopMethods = extractRustStringArrayConst(main, 'HOST_BRIDGE_METHODS');
const desktopCapabilities = extractDesktopCapabilities(main);
const desktopHandledMethods = extractDesktopHandledMethods(main);
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
const desktopCapabilities = extractDesktopCapabilities(rustHostSource);
const desktopHandledMethods = extractDesktopHandledMethods(rustHostSource);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
@@ -861,7 +872,7 @@ if (extractCargoPackageString(cargoManifest, 'version') !== config.version) {
throw new Error('desktop shell Cargo package version must match tauri.conf.json version');
}
if (!main.includes('host_version: env!("CARGO_PKG_VERSION")')) {
if (!rustHostSource.includes('host_version: env!("CARGO_PKG_VERSION")')) {
throw new Error('desktop shell runtime response must use the Cargo package version');
}
@@ -1012,7 +1023,16 @@ const sharedTauriCommand = extractTsStringConst(
'HOST_BRIDGE_TAURI_COMMAND',
);
const allowedTauriCommands = [sharedTauriCommand];
const requiredMainSnippets = [
const requiredRustHostModules = [
'desktop_host_bridge.rs',
'desktop_host_bridge_files.rs',
'desktop_host_bridge_protocol.rs',
'desktop_host_bridge_share.rs',
'desktop_shell_tray.rs',
'desktop_shell_webview.rs',
'main.rs',
];
const requiredRustHostSnippets = [
'tauri_plugin_single_instance::init',
'resolve_desktop_single_instance_action',
'tauri_plugin_clipboard_manager::init()',
@@ -1117,6 +1137,11 @@ assertSameList(
['host_bridge_request'],
'shared Tauri HostBridge command',
);
for (const moduleName of requiredRustHostModules) {
if (!collectRustSourceBasenames(rustHostSourceFiles).includes(moduleName)) {
throw new Error(`desktop shell Rust bridge module missing ${moduleName}`);
}
}
assertSameList(
extractNativeAppTauriInvokeCommands(nativeAppHostBridgeSource),
['HOST_BRIDGE_TAURI_COMMAND'],
@@ -1189,8 +1214,8 @@ if (main.includes('single-instance",') || main.includes('"single-instance"')) {
throw new Error('desktop shell must not emit secondary-instance argv to H5');
}
for (const snippet of requiredMainSnippets) {
if (!main.includes(snippet)) {
for (const snippet of requiredRustHostSnippets) {
if (!rustHostSource.includes(snippet)) {
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,397 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, Condvar, Mutex};
pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
pub(crate) const HOST_BRIDGE_VERSION: u8 = 1;
pub(crate) const HOST_BRIDGE_METHODS: [&str; 23] = [
"host.getRuntime",
"appearance.getColorScheme",
"auth.requestLogin",
"payment.request",
"share.setTarget",
"share.open",
"navigation.openNativePage",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.exportImage",
"file.importImage",
"file.captureImage",
"file.importAudio",
"file.exportAudio",
"haptics.impact",
"notification.showLocal",
];
pub(crate) const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120;
const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostBridgeRequest {
pub(crate) bridge: String,
pub(crate) version: u8,
pub(crate) id: String,
pub(crate) method: String,
pub(crate) payload: Option<Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostBridgeRuntime {
pub(crate) shell: &'static str,
pub(crate) platform: &'static str,
pub(crate) host_version: &'static str,
pub(crate) bridge_version: u8,
pub(crate) capabilities: Vec<&'static str>,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct HostBridgeError {
pub(crate) code: &'static str,
pub(crate) message: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostBridgeResponse {
pub(crate) bridge: &'static str,
pub(crate) version: u8,
pub(crate) id: String,
pub(crate) ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<HostBridgeError>,
}
#[derive(Debug, Default)]
pub(crate) struct HostBridgeReplayState {
cache: Mutex<HostBridgeReplayCache>,
}
#[derive(Debug, Default)]
struct HostBridgeReplayCache {
order: Vec<String>,
slots: HashMap<String, Arc<HostBridgeReplaySlot>>,
}
#[derive(Debug, Default)]
pub(crate) struct HostBridgeReplaySlot {
response: Mutex<Option<HostBridgeResponse>>,
ready: Condvar,
}
#[derive(Debug)]
pub(crate) enum HostBridgeReplayReservation {
Execute(Arc<HostBridgeReplaySlot>),
Wait(Arc<HostBridgeReplaySlot>),
}
impl HostBridgeReplayState {
pub(crate) fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation {
let mut cache = self.cache.lock().expect("host bridge replay cache lock");
if let Some(slot) = cache.slots.get(request_id) {
return HostBridgeReplayReservation::Wait(slot.clone());
}
let slot = Arc::new(HostBridgeReplaySlot::default());
cache.order.push(request_id.to_string());
cache.slots.insert(request_id.to_string(), slot.clone());
while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX {
if let Some(oldest_request_id) = cache.order.first().cloned() {
cache.order.remove(0);
cache.slots.remove(&oldest_request_id);
}
}
HostBridgeReplayReservation::Execute(slot)
}
pub(crate) fn complete(
&self,
slot: Arc<HostBridgeReplaySlot>,
response: HostBridgeResponse,
) -> HostBridgeResponse {
let mut stored_response = slot.response.lock().expect("host bridge replay slot lock");
*stored_response = Some(response.clone());
slot.ready.notify_all();
response
}
pub(crate) fn wait_for_response(slot: Arc<HostBridgeReplaySlot>) -> HostBridgeResponse {
let mut stored_response = slot.response.lock().expect("host bridge replay slot lock");
while stored_response.is_none() {
stored_response = slot
.ready
.wait(stored_response)
.expect("host bridge replay slot wait");
}
stored_response
.clone()
.expect("host bridge replay response")
}
}
pub(crate) fn capabilities() -> Vec<&'static str> {
vec![
"host.getRuntime",
"appearance.getColorScheme",
"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",
]
}
pub(crate) fn ok(id: String, result: Value) -> HostBridgeResponse {
HostBridgeResponse {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id,
ok: true,
result: Some(result),
error: None,
}
}
pub(crate) fn failed(
id: String,
code: &'static str,
message: impl Into<String>,
) -> HostBridgeResponse {
HostBridgeResponse {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id,
ok: false,
result: None,
error: Some(HostBridgeError {
code,
message: message.into(),
}),
}
}
pub(crate) fn has_control_character(value: &str) -> bool {
value.chars().any(|character| {
let code_point = character as u32;
code_point <= 31 || code_point == 127
})
}
pub(crate) fn normalize_request_id(raw_id: &str) -> Option<String> {
let id = raw_id.trim();
if id.is_empty()
|| id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH
|| has_control_character(id)
{
return None;
}
Some(id.to_string())
}
pub(crate) fn is_host_bridge_method(method: &str) -> bool {
HOST_BRIDGE_METHODS.contains(&method)
}
pub(crate) fn validate_request(request: &HostBridgeRequest) -> Option<HostBridgeResponse> {
let Some(request_id) = normalize_request_id(&request.id) else {
return Some(failed(
"invalid".to_string(),
"invalid_request",
"invalid host bridge request id",
));
};
if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION {
return Some(failed(
request_id,
"invalid_request",
"invalid host bridge envelope",
));
}
if !is_host_bridge_method(&request.method) {
return Some(failed(
request_id,
"invalid_request",
"invalid host bridge method",
));
}
None
}
pub(crate) fn required_string_payload<'a>(
request: &'a HostBridgeRequest,
field: &'static str,
) -> Result<&'a str, HostBridgeResponse> {
request
.payload
.as_ref()
.and_then(|value| value.get(field))
.and_then(Value::as_str)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
format!("{} is required", field),
)
})
}
#[cfg(test)]
pub(crate) fn request(method: &str) -> HostBridgeRequest {
HostBridgeRequest {
bridge: HOST_BRIDGE_PROTOCOL.to_string(),
version: HOST_BRIDGE_VERSION,
id: "request-1".to_string(),
method: method.to_string(),
payload: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
#[test]
fn invalid_envelope_is_rejected() {
let mut invalid = request("host.getRuntime");
invalid.bridge = "OtherBridge".to_string();
let response = validate_request(&invalid).expect("invalid envelope");
assert!(!response.ok);
assert_eq!(response.error.expect("error").code, "invalid_request");
}
#[test]
fn invalid_request_id_and_unknown_method_are_rejected() {
for id in ["", "request\n1"] {
let mut invalid = request("share.open");
invalid.id = id.to_string();
let response = validate_request(&invalid).expect("invalid id");
assert!(!response.ok);
assert_eq!(response.id, "invalid");
assert_eq!(response.error.expect("error").code, "invalid_request");
}
let mut oversized = request("share.open");
oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1);
let response = validate_request(&oversized).expect("oversized id");
assert!(!response.ok);
assert_eq!(response.id, "invalid");
assert_eq!(response.error.expect("error").code, "invalid_request");
let mut multibyte_boundary = request("host.getRuntime");
multibyte_boundary.id = "".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH);
assert!(validate_request(&multibyte_boundary).is_none());
let response =
validate_request(&request("host.runArbitraryCommand")).expect("unknown method");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "invalid host bridge method");
}
#[test]
fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() {
let replay_state = HostBridgeReplayState::default();
let mut side_effect_count = 0;
let first_reservation = replay_state.reserve("request-1");
let first_response = match first_reservation {
HostBridgeReplayReservation::Execute(slot) => {
side_effect_count += 1;
replay_state.complete(slot, ok("request-1".to_string(), json!(true)))
}
HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"),
};
let second_response = match replay_state.reserve("request-1") {
HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"),
HostBridgeReplayReservation::Wait(slot) => {
HostBridgeReplayState::wait_for_response(slot)
}
};
assert_eq!(side_effect_count, 1);
assert_eq!(second_response.ok, first_response.ok);
assert_eq!(second_response.result, first_response.result);
}
#[test]
fn invalid_string_payload_is_rejected() {
let mut invalid = request("clipboard.writeText");
invalid.payload = Some(json!({ "text": 123 }));
let response = required_string_payload(&invalid, "text").expect_err("invalid payload");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "text is required");
}
#[test]
fn runtime_capability_list_stays_ordered() {
assert_eq!(
capabilities(),
vec![
"host.getRuntime",
"appearance.getColorScheme",
"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",
]
);
assert!(Value::from(capabilities()).as_array().is_some());
}
}
@@ -0,0 +1,147 @@
use crate::desktop_host_bridge_protocol::{failed, HostBridgeRequest, HostBridgeResponse};
use crate::desktop_shell_webview::WEB_APP_ORIGIN;
use serde_json::Value;
use std::sync::Mutex;
#[derive(Debug, Default)]
pub(crate) struct DesktopShareState {
pub(crate) target: Mutex<Option<Value>>,
}
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
}
fn share_target_payload(value: &Value) -> &Value {
value.get("target").unwrap_or(value)
}
fn work_detail_url(work: &str) -> String {
format!("{}/works/detail?work={}", WEB_APP_ORIGIN, work)
}
fn share_text_from_value(value: &Value) -> Option<String> {
let target = share_target_payload(value);
let payload = target.get("payload").unwrap_or(target);
let title = payload_string(payload, "title");
let message = payload_string(payload, "message");
let url = payload_string(payload, "url").or_else(|| payload_string(payload, "href"));
let work_url = payload_string(payload, "work").map(work_detail_url);
let path_url = payload_string(payload, "path")
.or_else(|| payload_string(payload, "targetPath"))
.map(|path| format!("{}{}", WEB_APP_ORIGIN, path));
let resolved_url = url.map(str::to_owned).or(work_url).or(path_url);
let parts = [title, message, resolved_url.as_deref()]
.into_iter()
.flatten()
.collect::<Vec<_>>();
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
pub(crate) fn share_text_from_request(
request: &HostBridgeRequest,
share_state: &DesktopShareState,
) -> Result<String, HostBridgeResponse> {
if let Some(payload) = request.payload.as_ref() {
if let Some(text) = share_text_from_value(payload) {
return Ok(text);
}
}
let stored_target = share_state
.target
.lock()
.map_err(|_| {
failed(
request.id.clone(),
"host_error",
"share target lock poisoned",
)
})?
.clone();
stored_target
.as_ref()
.and_then(share_text_from_value)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"share target is required",
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::desktop_host_bridge_protocol::request;
use serde_json::json;
#[test]
fn share_text_uses_direct_share_payload() {
let state = DesktopShareState::default();
let mut open = request("share.open");
open.payload = Some(json!({
"title": "测试作品",
"message": "来玩这个作品",
"url": "https://app.genarrative.world/works/detail?work=PZ-1"
}));
let text = share_text_from_request(&open, &state).expect("share text");
assert_eq!(
text,
"测试作品\n来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1"
);
}
#[test]
fn share_text_uses_stored_work_target() {
let state = DesktopShareState::default();
let mut set_target = request("share.setTarget");
set_target.payload = Some(json!({
"target": {
"type": "genarrative:share-target",
"payload": {
"work": "PZ-1",
"title": "测试作品"
}
}
}));
let target = set_target
.payload
.as_ref()
.and_then(|payload| payload.get("target"))
.expect("target");
*state.target.lock().expect("share target lock") = Some(target.clone());
let text = share_text_from_request(&request("share.open"), &state).expect("share text");
assert_eq!(
text,
"测试作品\nhttps://app.genarrative.world/works/detail?work=PZ-1"
);
}
#[test]
fn share_text_requires_payload_or_stored_target() {
let state = DesktopShareState::default();
let response =
share_text_from_request(&request("share.open"), &state).expect_err("missing target");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "share target is required");
}
}
@@ -0,0 +1,200 @@
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{Manager, WebviewWindow, WindowEvent};
const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray";
pub(crate) const TRAY_MENU_SHOW: &str = "show-main-window";
pub(crate) const TRAY_MENU_RELOAD: &str = "reload-main-window";
pub(crate) const TRAY_MENU_QUIT: &str = "quit-desktop-shell";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DesktopTrayAction {
ShowMainWindow,
ReloadMainWindow,
QuitApp,
Ignore,
}
#[derive(Debug, PartialEq, Eq)]
enum DesktopWindowCloseAction {
HideToTray,
CloseWindow,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DesktopSingleInstanceAction {
ShowMainWindow,
}
pub(crate) fn resolve_desktop_tray_menu_action(menu_id: &str) -> DesktopTrayAction {
match menu_id {
TRAY_MENU_SHOW => DesktopTrayAction::ShowMainWindow,
TRAY_MENU_RELOAD => DesktopTrayAction::ReloadMainWindow,
TRAY_MENU_QUIT => DesktopTrayAction::QuitApp,
_ => DesktopTrayAction::Ignore,
}
}
pub(crate) fn resolve_desktop_tray_icon_action(
button: MouseButton,
button_state: MouseButtonState,
) -> DesktopTrayAction {
if button == MouseButton::Left && button_state == MouseButtonState::Up {
DesktopTrayAction::ShowMainWindow
} else {
DesktopTrayAction::Ignore
}
}
fn resolve_desktop_window_close_action(tray_registered: bool) -> DesktopWindowCloseAction {
if tray_registered {
DesktopWindowCloseAction::HideToTray
} else {
DesktopWindowCloseAction::CloseWindow
}
}
pub(crate) fn resolve_desktop_single_instance_action() -> DesktopSingleInstanceAction {
DesktopSingleInstanceAction::ShowMainWindow
}
pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window("main") {
window.show()?;
window.unminimize()?;
window.set_focus()?;
}
Ok(())
}
fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window("main") {
window.reload()?;
}
Ok(())
}
fn handle_desktop_tray_action(app: &tauri::AppHandle, action: DesktopTrayAction) {
match action {
DesktopTrayAction::ShowMainWindow => {
let _ = show_main_window(app);
}
DesktopTrayAction::ReloadMainWindow => {
let _ = reload_main_window(app);
}
DesktopTrayAction::QuitApp => app.exit(0),
DesktopTrayAction::Ignore => {}
}
}
pub(crate) fn register_desktop_tray(app: &tauri::App) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?;
let reload_item = MenuItem::with_id(app, TRAY_MENU_RELOAD, "刷新", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出", true, None::<&str>)?;
let tray_menu = Menu::with_items(app, &[&show_item, &reload_item, &quit_item])?;
let mut tray_builder = TrayIconBuilder::with_id(DESKTOP_TRAY_ID)
.menu(&tray_menu)
.tooltip("Genarrative")
.show_menu_on_left_click(false)
.on_menu_event(|app, event| {
let menu_id = event.id().0.as_str();
handle_desktop_tray_action(app, resolve_desktop_tray_menu_action(menu_id));
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button,
button_state,
..
} = event
{
handle_desktop_tray_action(
tray.app_handle(),
resolve_desktop_tray_icon_action(button, button_state),
);
}
});
if let Some(icon) = app.default_window_icon().cloned() {
tray_builder = tray_builder.icon(icon);
}
tray_builder.build(app)?;
Ok(())
}
pub(crate) fn register_desktop_window_close_events(window: &WebviewWindow, tray_registered: bool) {
let close_window = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
if resolve_desktop_window_close_action(tray_registered)
== DesktopWindowCloseAction::HideToTray
{
api.prevent_close();
let _ = close_window.hide();
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_tray_menu_ids_map_to_real_window_actions() {
assert_eq!(
resolve_desktop_tray_menu_action(TRAY_MENU_SHOW),
DesktopTrayAction::ShowMainWindow
);
assert_eq!(
resolve_desktop_tray_menu_action(TRAY_MENU_RELOAD),
DesktopTrayAction::ReloadMainWindow
);
assert_eq!(
resolve_desktop_tray_menu_action(TRAY_MENU_QUIT),
DesktopTrayAction::QuitApp
);
assert_eq!(
resolve_desktop_tray_menu_action("unknown"),
DesktopTrayAction::Ignore
);
}
#[test]
fn desktop_tray_left_click_restores_main_window_only_on_release() {
assert_eq!(
resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Up),
DesktopTrayAction::ShowMainWindow
);
assert_eq!(
resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Down),
DesktopTrayAction::Ignore
);
assert_eq!(
resolve_desktop_tray_icon_action(MouseButton::Right, MouseButtonState::Up),
DesktopTrayAction::Ignore
);
}
#[test]
fn desktop_close_hides_to_tray_only_when_tray_is_registered() {
assert_eq!(
resolve_desktop_window_close_action(true),
DesktopWindowCloseAction::HideToTray
);
assert_eq!(
resolve_desktop_window_close_action(false),
DesktopWindowCloseAction::CloseWindow
);
}
#[test]
fn desktop_single_instance_only_restores_existing_window() {
assert_eq!(
resolve_desktop_single_instance_action(),
DesktopSingleInstanceAction::ShowMainWindow
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2469,3 +2469,10 @@
- 决策:Expo 和 Tauri 注入给 H5 的 HostBridge response / event 统一带 `origin: window.location.origin``source: window``nativeAppHostBridge` listener 只接受无外部 source 或当前窗口 source 的消息,并拒绝非当前页面 origin。AI sandbox 后续继续使用独立 GameBridge allowlist,不允许直接结算 HostBridge 请求。
- 影响范围:`apps/mobile-shell/App.tsx``apps/desktop-shell/src-tauri/src/main.rs``src/services/host-bridge/nativeAppHostBridge.ts`、两端壳配置检查和 HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 三端宿主桥接层文件结构对齐
- 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。
- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳保持 `mobileHostBridge.ts` 负责协议分发,`mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳拆成 `desktop_host_bridge*.rs``desktop_shell*.rs``main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层文件清单。
- 影响范围:`miniprogram/host-bridge/``miniprogram/pages/*/index.js``apps/mobile-shell/src/``apps/desktop-shell/src-tauri/src/``scripts/check-native-shells.mjs`、宿主壳方案文档。
- 验证方式:`npm run test -- miniprogram/host-bridge/wechatHostBridgeWebView.test.js miniprogram/host-bridge/wechatHostBridgePayment.test.js miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
@@ -49,6 +49,8 @@ AI 生成 H5 游戏 iframe
apps/
mobile-shell/ # Expo + React Native App 壳
desktop-shell/ # Tauri 桌面 App 壳
miniprogram/
host-bridge/ # 微信小程序宿主桥接逻辑,页面只做装配
packages/
shared/
src/contracts/
@@ -61,6 +63,8 @@ src/
已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`Expo 移动壳使用 `apps/mobile-shell/src/mobileHostBridge.ts` 承接协议分发,`mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/desktop_host_bridge*.rs` 承接协议、分发、文件和分享,`desktop_shell*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。
## HostBridge 消息协议
H5 进入原生 App 壳时由壳层附加稳定 query:
@@ -407,6 +411,8 @@ GameBridge 禁止:
2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage``about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。
2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳继续保持 `mobileHostBridge.ts` + `mobileShell*.ts`;桌面壳 Rust 源码拆成 `desktop_host_bridge.rs``desktop_host_bridge_protocol.rs``desktop_host_bridge_files.rs``desktop_host_bridge_share.rs``desktop_shell_webview.rs``desktop_shell_tray.rs` 和薄 `main.rs`。根级 `npm run check:native-shells` 会锁定三端桥接层文件清单,避免后续把能力逻辑重新散落到页面或桌面入口。
### Phase 4:宿主能力扩展
- 移动端接入系统分享、推送、原生登录和渠道支付。
@@ -429,7 +435,7 @@ GameBridge 禁止:
- AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。
- Tauri release 包不允许任意远端页面调用桌面命令。
- Expo WebView 外链离开主站后不保留完整 HostBridge。
- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测和两端生产壳临时替身词扫描。
- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测和两端生产壳临时替身词扫描。
## 参考资料
@@ -30,13 +30,15 @@ H5 业务层
-> HostBridge 能力接口
-> browserHostBridge
-> wechatMiniProgramHostBridge
-> nativeAppHostBridge(预留)
-> nativeAppHostBridge
AI H5 sandbox
-> GameBridge 受限协议
-> parent HostBridge adapter
```
桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面装配;Expo 移动壳使用 `mobileHostBridge.ts``mobileShell*.ts`Tauri 桌面壳使用 `desktop_host_bridge*.rs``desktop_shell*.rs``main.rs` 不再承载 HostBridge 分发细节。`npm run check:native-shells` 会检查这些文件清单。
## 首批能力
- `getHostRuntime()`:识别 `browser``wechat_mini_program``native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。
@@ -70,7 +72,7 @@ AI H5 sandbox
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。
5. 每次新增或调整 native capability 后,必须运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;排查单端问题时再单独运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run mobile-shell:export``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`
5. 每次新增或调整 native capability 后,必须运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test 和桌面 release `--no-bundle` 构建烟测;排查单端问题时再单独运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run mobile-shell:export``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`
## 验收
@@ -79,7 +81,7 @@ AI H5 sandbox
- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。
- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。
- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、两端壳实现、Expo managed config、移动端 production bundle 和桌面 release 构建入口没有漂移。
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle 和桌面 release 构建入口没有漂移。
## 后续
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import wechatPayBridge from './index.shared.js';
import wechatPayBridge from './wechatHostBridgePayment.js';
const {
appendPayResult,
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import shareGridBridge from './index.shared.js';
import shareGridBridge from './wechatHostBridgeShareGrid.js';
const {
buildShareGridTileFileName,
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import subscribeMessageBridge from './index.shared.js';
import subscribeMessageBridge from './wechatHostBridgeSubscribeMessage.js';
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import webViewBridge from './index.shared.js';
import webViewBridge from './wechatHostBridgeWebView.js';
const {
appendLaunchTargetToEntryUrl,
+1 -1
View File
@@ -5,7 +5,7 @@ const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = require('./index.shared');
} = require('../../host-bridge/wechatHostBridgeShareGrid');
function downloadImage(imageUrl) {
return new Promise((resolve, reject) => {
+3 -1
View File
@@ -1,7 +1,9 @@
/* global Page */
const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config');
const { createSubscribeMessagePage } = require('./index.shared');
const {
createSubscribeMessagePage,
} = require('../../host-bridge/wechatHostBridgeSubscribeMessage');
Page(
createSubscribeMessagePage(null, {
+1 -1
View File
@@ -16,7 +16,7 @@ const {
buildWebViewShareTimelineQuery,
resolveShareTargetFromWebViewMessage,
resolveWebViewUrlFromRuntimeConfig,
} = require('./index.shared');
} = require('../../host-bridge/wechatHostBridgeWebView');
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
+1 -1
View File
@@ -1,3 +1,3 @@
const { createWechatPayPage } = require('./index.shared');
const { createWechatPayPage } = require('../../host-bridge/wechatHostBridgePayment');
Page(createWechatPayPage());
+92
View File
@@ -7,6 +7,36 @@ import path from 'node:path';
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const productionShellRoots = ['apps/mobile-shell', 'apps/desktop-shell'];
const expectedWechatHostBridgeFiles = [
'wechatHostBridgePayment.js',
'wechatHostBridgePayment.test.js',
'wechatHostBridgeShareGrid.js',
'wechatHostBridgeShareGrid.test.js',
'wechatHostBridgeSubscribeMessage.js',
'wechatHostBridgeSubscribeMessage.test.js',
'wechatHostBridgeWebView.js',
'wechatHostBridgeWebView.test.js',
];
const expectedMobileShellFiles = [
'mobileHostBridge.ts',
'mobileShellDeepLink.ts',
'mobileShellLifecycle.ts',
'mobileShellNavigation.ts',
'mobileShellNetwork.ts',
'mobileShellRuntime.ts',
'mobileShellSafeArea.ts',
'mobileShellUrl.ts',
'mobileShellWebViewPolicy.ts',
];
const expectedDesktopShellRustFiles = [
'desktop_host_bridge.rs',
'desktop_host_bridge_files.rs',
'desktop_host_bridge_protocol.rs',
'desktop_host_bridge_share.rs',
'desktop_shell_tray.rs',
'desktop_shell_webview.rs',
'main.rs',
];
const productionShellExtensions = new Set([
'.json',
'.mjs',
@@ -145,6 +175,65 @@ function assertNoProductionShellDevScaffoldTerms() {
}
}
function assertSameList(actual, expected, label) {
if (
actual.length !== expected.length ||
actual.some((value, index) => value !== expected[index])
) {
throw new Error(
`${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`,
);
}
}
function assertHostBridgeLayerLayout() {
const wechatBridgeFiles = fs
.readdirSync('miniprogram/host-bridge', { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(
wechatBridgeFiles,
expectedWechatHostBridgeFiles,
'wechat host bridge files',
);
for (const pagePath of [
'miniprogram/pages/web-view/index.js',
'miniprogram/pages/wechat-pay/index.js',
'miniprogram/pages/share-grid/index.js',
'miniprogram/pages/subscribe-message/index.js',
]) {
const source = fs.readFileSync(pagePath, 'utf8');
if (source.includes("require('./index.shared')")) {
throw new Error(`${pagePath} must import from miniprogram/host-bridge`);
}
}
const mobileShellFiles = fs
.readdirSync('apps/mobile-shell/src', { withFileTypes: true })
.filter((entry) => entry.isFile() && !entry.name.endsWith('.test.ts'))
.map((entry) => entry.name)
.filter((name) => name.startsWith('mobileHostBridge') || name.startsWith('mobileShell'))
.sort();
assertSameList(
mobileShellFiles,
expectedMobileShellFiles,
'mobile shell bridge files',
);
const desktopShellRustFiles = fs
.readdirSync('apps/desktop-shell/src-tauri/src', { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.rs'))
.map((entry) => entry.name)
.sort();
assertSameList(
desktopShellRustFiles,
expectedDesktopShellRustFiles,
'desktop shell Rust bridge files',
);
}
for (const step of steps) {
console.log(`[check:native-shells] ${step.label}`);
const result = spawnSync(step.command, step.args, {
@@ -171,6 +260,9 @@ for (const step of steps) {
}
}
console.log('[check:native-shells] host-bridge-layer-layout');
assertHostBridgeLayerLayout();
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();