统一三端桥接层结构
Tauri入口main.rs收敛为应用装配 桌面HostBridge按协议分发文件分享WebView和托盘职责拆分模块 微信小程序桥接逻辑迁入host-bridge目录并保留页面装配 原生壳检查锁定微信移动桌面桥接层文件结构 宿主壳文档和共享决策记录三端桥接层结构约定
This commit is contained in:
@@ -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 policy;Tauri 桌面壳拆成 `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 policy;Tauri 桌面壳使用 `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
-1
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import shareGridBridge from './index.shared.js';
|
||||
import shareGridBridge from './wechatHostBridgeShareGrid.js';
|
||||
|
||||
const {
|
||||
buildShareGridTileFileName,
|
||||
+1
-1
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import webViewBridge from './index.shared.js';
|
||||
import webViewBridge from './wechatHostBridgeWebView.js';
|
||||
|
||||
const {
|
||||
appendLaunchTargetToEntryUrl,
|
||||
@@ -5,7 +5,7 @@ const {
|
||||
buildShareGridTileFileName,
|
||||
buildShareGridTilePlan,
|
||||
normalizeShareGridQuery,
|
||||
} = require('./index.shared');
|
||||
} = require('../../host-bridge/wechatHostBridgeShareGrid');
|
||||
|
||||
function downloadImage(imageUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
const { createWechatPayPage } = require('./index.shared');
|
||||
const { createWechatPayPage } = require('../../host-bridge/wechatHostBridgePayment');
|
||||
|
||||
Page(createWechatPayPage());
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user